Building a Mental Health Journal App: Mood Tracking, Notifications, Support

When I first decided to build Melo, my goal was simple: create a mental health journal app that people could use. I wanted it to be more than just a text editor where you can write your thoughts. I wanted it it to be a tool that provides meaningful insights, encourages mindfulness, and supported users when they need it most.

In planning this project, I had initially divided it into four phases and by the time I reached the fourth phase of this project, I had built the core features: secure journal entries, sentiment analysis, mood tracking. The app could now analyze journal entries and categorize them as positive, neutral, or negative, giving users a way to reflect on their emotional patterns. But I still felt like something was missing. Tracking mood trends was useful, but without actionable insights, it felt incomplete. That’s when I decided to add a fifth and final phase of the project and take things bit further.

So for phase 4 and phase 5, I focused on make the app more supportive rather than just informative. First I improved the mood and sentiment dashboard by adding a dropdown filter with options to see your trends in the past week, the past month, and the entire year, and providing insights based on that filtered data. I then add push notifications to remind users to journal daily, making it easier to build a habit. But the most meaning addition for me was the safe mode, a feature designed to provide calming experience when the user writes negative/distressing journal entries

In this post, I’ll share the key features I build in these final phases, what I learned from the process and how these additions made the app more valuable.

Understanding Mood Trends (Phase 4 Recap)

The goal for this phase was to better help users recognize emotional patterns over time so they could reflect, adjust, and improve their mental well-being.

Since in the previous phase I had already built a mood trends dashboard with a line graph for the user selected moods and the generated sentiment score, I decided to add a dropdown button with options to fetch data for that last seven days, the month, and the year, and below the graph added a carousel animation that displays different insights derived from the fetched data.

I also had a bottom navigation bar item with a view dedicated to insights from the Collected data, where I add pie charts. The final design for the fourth phase featured:

  • A Mood Trends Dashboard that visually mapped emotions over a week, a month, a year
  • A Mood and Sentiment Breakdown carousel showing how often users expressed different emotions
  • A Mood Distribution Pie Chart summarizing emotional patterns

By the end of phase 4, I had built a system that not only tracked moods, but also helped users reflect on their mental health over time. However, I knew that simply tracking moods might not be enough for users that needed support during difficult moments. That’s where phase 5 came in.

Making the App More Supportive (Phase 5 Features)

Finally I wanted the the app to proactive, and supportive, rather that reactive. For me this meant add features that not only help users maintain the good habit of journaling but could help them when they need it the most. Phase 5 introduced:

  • Push Notifications to encourage consistent journaling
  • Personalized Suggestions based on mood trends
  • Safe Mode for moments of emotional distress

Push Notification: Encouraging Consistency

One of the biggest challenges with journaling is consistency. People start with good intentions, but life gets busy, and habits fade. I implemented push notifications to gently remind users once a day to write in their journals.

The tricky part was balancing reminders without them feeling intrusive. I gave users full control:

  • Set a reminder time that works for you
  • Toggle reminders on or off anytime

This allows journaling to become a regular habit without feeling like a chore.

250

Personalized Suggestions: Smart Mindfulness Recommendations

While analyzing mood trends in phase 4, I saw an opportunity to go further and suggest mindfulness exercises based on past journal entries

I built a a recommendation system that suggests actions like:

  • Taking a short mindfulness break
  • Reflecting on a happy moment
  • Going for a short walk outside or
  • Listening to your favorite song

I’ve learned from personal experience that these seemingly small things can make a big difference, helping users take immediate action to improve their mood rather than just observing it.

Safe Mood: A Calm Space For Difficult Moments

One of the most important features I added was Safe Mode—a dedicated space within the app that provides a moment of calm for users who might be struggling.

When a journal entry shows negative sentiment, the app enters Safe Mode after the user submits the entry. It’s also accessible anytime from the home screen by double tapping the the animated floating action button. Once activated, Safe Mode:

  • Switches to a soothing UI with calming colors
  • Guides users through a breathing animation
  • Plays relaxing sounds to ease stress

This feature wasn’t just about functionality, it was about empathy. I wanted the app to feel like a supportive space, not just a tool.

With these core features in place, I could finally step back and see how the app had evolved from an idea into something truly meaningful

Technical Deep Dive

Adding push notifications, personalized suggestions, and the safe mode features wasn’t about just about adding new UI elelements- it required tackling background scheduling, real-time sentiment analysis, and user customization. In this section, I’ll break down some of the technical challenges I faced and how I solved them.

Implementing Push Notifications

Push notifications needed to be flexible and reliable. I wanted users to set their own reminders without the app constantly running in the background

As I have in the previous phases, I followed clean architecture and TDD, and used flutter_local_nofications for on-device reminders, shared_preferences to save user preferences locally for customization, and workmanager for background scheduling, which allowed notifications to be triggered even if the app wasn’t open.

To implement daily reminders with push notifications the FlutterLocalNotificationsPlugin has the function zonedSchedule(), which is supposed to schedules a notification to be shown at the specified date and time relative to a specific time zone.

However I discovered after writing unit tests and manually testing it, that it didn’t push notifications.

The only function that seemed to push the notifications was the function show(), which according to the documentations, show a notification with an optional payload that will be passed back to the app when a notification is tapped.

So to work around this obstacle I add the WorkManager plugin to handle scheduling the time for the daily reminder and wrapped the FlutterLocalNotificationsPlugin with the class NotificationsService to make it easier to write unit tests for its functions…

class NotificationsService {
  NotificationsService() {
    _initNotification();
    Workmanager().initialize(
      callbackDispatcher,
    );
  }

  final _notificationPlugin = FlutterLocalNotificationsPlugin();

  var _initialized = false;

  bool get isInitialized => _initialized;

  Future<void> _initNotification() async {
    if (_initialized) return;

    tz.initializeTimeZones();
    try {
      final timeZoneName = await FlutterTimezone.getLocalTimezone();
      final detectedLocation = tz.getLocation(timeZoneName);
      tz.setLocalLocation(detectedLocation);

      debugPrint('Detected device time zone: $timeZoneName');
    } on Exception catch (e) {
      debugPrint('Error detecting time zone: $e');
      tz.setLocalLocation(tz.getLocation('UTC'));
    }
    // android init settings
    const initSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');

    // ios init settings
    const initSettingsIOS = DarwinInitializationSettings();

    // init settings
    const initSettings = InitializationSettings(
      android: initSettingsAndroid,
      iOS: initSettingsIOS,
    );

    // initialize plugin
    final initialized = await _notificationPlugin.initialize(initSettings);
    _initialized = initialized ?? false;
  }

  NotificationDetails _notificationDetails() {
    return const NotificationDetails(
      android: AndroidNotificationDetails(
        'journal_channel',
        'Journal Reminders',
        importance: Importance.max,
        priority: Priority.high,
      ),
      iOS: DarwinNotificationDetails(),
    );
  }

  tz.TZDateTime _nextInstanceOfTime(DateTime time) {
    final now = tz.TZDateTime.now(tz.local);
    var scheduledDate = tz.TZDateTime(
      tz.local,
      now.year,
      now.month,
      now.day,
      time.hour,
      time.minute,
    );

    if (scheduledDate.isBefore(now)) {
      scheduledDate = scheduledDate.add(const Duration(days: 1));
    }
    debugPrint('Current time: $now');
    debugPrint('Final scheduled notification time: $scheduledDate');

    return scheduledDate;
  }

  Future<void> scheduleNotification({
    required int id,
    required String title,
    required String body,
    required DateTime date,
  }) async {
    await _initNotification();

    final now = DateTime.now();
    debugPrint('🚀 Attempting to schedule notification...');
    debugPrint('⏰ Requested notification time: $date');
    if (date.isBefore(now)) {
      date = date.add(const Duration(days: 1));
    }
    debugPrint('📌 Final scheduled time (converted): $date');

    final scheduledTime = _nextInstanceOfTime(date);
    final initialDelay = scheduledTime.difference(now);
    debugPrint('initial delay: $initialDelay');

    await NotificationUtils.scheduleJournalReminder(
      initialDelay: initialDelay,
      id: id,
      title: title,
      body: body,
    );
  }

  Future<void> cancelNotification(int id) async {
    debugPrint('cancelNotification');
    await NotificationUtils.cancelJournalReminder();
    return _notificationPlugin.cancel(id);
  }

  Future<void> cancelAllNotifications() async {
    await NotificationUtils.cancelJournalReminder();
    return _notificationPlugin.cancelAll();
  }

  Future<List<PendingNotificationRequest>> getScheduledNotifications() async {
    return _notificationPlugin.pendingNotificationRequests();
  }

  /// ** Show Instant Notification (Used in Workmanager)**
  Future<void> showInstantNotification({
    required int id,
    required String title,
    required String body,
  }) async {
    // await _initNotification();
    debugPrint('showInstantNotification');
    await _notificationPlugin.show(
      1,
      title,
      body,
      _notificationDetails(),
    );
  }
}

and used NotificationsService as a dependency for NotificationLocalDataSource

abstract class NotificationLocalDataSource {
  const NotificationLocalDataSource();

  Future<void> scheduleNotification(NotificationEntity notification);

  Future<void> cancelNotification(int id);

  Future<List<NotificationModel>> getScheduledNotifications();
}

class NotificationLocalDataSourceImpl implements NotificationLocalDataSource {
  NotificationLocalDataSourceImpl({
    required NotificationsService notificationPlugin,
  }) : _notificationsService = notificationPlugin;

  final NotificationsService _notificationsService;

  @override
  Future<void> cancelNotification(int id) async {
    try {
      if (id < 0) {
        throw CancelNotificationException(
          message: 'Invalid notification ID: $id',
          statusCode: 'INVALID_ID',
        );
      }
      await _notificationsService.cancelNotification(id);
    } on PlatformException catch (e, s) {
      debugPrintStack(label: e.toString(), stackTrace: s);
      throw CancelNotificationException(
        message: 'Failed to cancel notification: ${e.message}',
        statusCode: 'PLATFORM_ERROR',
      );
    } on MissingPluginException catch (e, s) {
      debugPrintStack(label: e.toString(), stackTrace: s);
      throw CancelNotificationException(
        message: 'Notification plugin not initialized: ${e.message}',
        statusCode: 'PLUGIN_MISSING',
      );
    } catch (e, s) {
      debugPrintStack(label: e.toString(), stackTrace: s);
      throw CancelNotificationException(
        message: 'Unexpected error while canceling notification: $e',
        statusCode: 'UNKNOWN_ERROR',
      );
    }
  }

  @override
  Future<List<NotificationModel>> getScheduledNotifications() async {
    try {
      final notifications = await _notificationsService.getScheduledNotifications();

      return notifications
          .map(
            (notification) => NotificationModel(
              id: notification.id,
              title: notification.title ?? '',
              body: notification.body ?? '',
              scheduledTime: DateTime.now(),
            ),
          )
          .toList();
    } on PlatformException catch (e, s) {
      debugPrintStack(label: e.toString(), stackTrace: s);
      throw GetScheduledNotificationsException(
        message: e.message ?? 'Failed to retrieve scheduled notifications',
        statusCode: e.code,
      );
    } on MissingPluginException catch (e, s) {
      debugPrintStack(label: e.toString(), stackTrace: s);
      throw const GetScheduledNotificationsException(
        message: 'Notification plugin not initialized',
        statusCode: 'PLUGIN_NOT_INITIALIZED',
      );
    } catch (e, s) {
      debugPrintStack(label: e.toString(), stackTrace: s);
      throw GetScheduledNotificationsException(
        message: e.toString(),
        statusCode: 'UNKNOWN_ERROR',
      );
    }
  }

  @override
  Future<void> scheduleNotification(NotificationEntity notification) async {
    try {
      await _notificationsService.scheduleNotification(
        id: notification.id,
        title: notification.title,
        body: notification.body,
        date: notification.scheduledTime,
      );
    } on PlatformException catch (e) {
      throw ScheduleNotificationException(
        message: 'Failed to schedule notification: ${e.message}',
        statusCode: 'PLATFORM_ERROR',
      );
    } on MissingPluginException catch (e) {
      throw ScheduleNotificationException(
        message: 'Notification plugin not initialized: ${e.message}',
        statusCode: 'PLUGIN_MISSING',
      );
    } catch (e, s) {
      debugPrintStack(stackTrace: s);
      debugPrint(e.toString());
      throw ScheduleNotificationException(
        message: 'Unexpected error while scheduling notification: $e',
        statusCode: 'UNKNOWN_ERROR',
      );
    }
  }
}

Building Personalized Suggestions

The personalized suggestions system required analyzing past journal entries and providing relevant recommendations based on mood trends

I implemented this using a simple rule-based system (for now) that suggests mindfulness exercise based on recent sentiments. In the future, this could evolve into a machine learning model that adapts over time.

In the core_untils.dart file I create the generateMoodInsights() method, which I used to provide insights and recommandations based on on journal entries

static List<String> generateMoodInsights(List<JournalEntry> entries, String selectedFilter) {
    final insights = <String>[];
    final moods = ['happy', 'sad', 'angry'];
    final moodCounts = {
      'happy': 0,
      'neutral': 0,
      'sad': 0,
      'angry': 0,
    };
    final consecutiveMoodDays = {
      'happy': 0,
      'neutral': 0,
      'sad': 0,
      'angry': 0,
    };
    String? prevMood;

    var sentimentSum = 0.0;

    for (final entry in entries) {
      final entryMood = entry.selectedMood.toLowerCase();

      if (moodCounts.containsKey(entryMood)) {
        moodCounts[entryMood] = moodCounts[entryMood]! + 1;
      }
      for (final mood in moods) {
        if (entryMood == mood) {
          consecutiveMoodDays[entryMood] = (prevMood == mood) ? consecutiveMoodDays[entryMood]! + 1 : 1;
        } else {
          if (consecutiveMoodDays[mood]! >= 3) {
            insights.add("You've been $mood for "
                '${consecutiveMoodDays[mood]} days in a row!');
          }
          consecutiveMoodDays[entryMood] = 0;
        }
      }
      sentimentSum += entry.sentimentScore;
      prevMood = entryMood;
    }
    final averageSentiment = sentimentSum / entries.length;

    for (final mood in moods) {
      if (moodCounts[mood]! >= 1) {
        insights.add('You’ve been $mood ${moodCounts[mood]} '
            'times this ${selectedFilter.toLowerCase()}.');
      }
      if (consecutiveMoodDays[mood]! >= 3) {
        insights.add("You've been $mood for "
            '${consecutiveMoodDays[mood]} days in a row!');
      }
    }

    if (averageSentiment > 0.1) {
      insights.add('Overall, your sentiment has been quite positive lately! 😊');
    } else if (averageSentiment < -0.05) {
      final suggestions = <String>[
        'Write something positive about your day ✍🏻✍🏽✍🏾',
        'Take a short mindfulness break 🏝️',
        'Reflect on a happy moment 💭',
        'Listen to your favorite song 🎶',
        'Go for a short walk outside🚶🏻🚶🚶🏽',
      ];

      final suggestion = (suggestions..shuffle()).first;
      insights.add(
        'It looks like your sentiment has been more negative recently. '
        '\n$suggestion',
      );
    } else {
      insights.add('Your overall sentiment has been relatively neutral.');
    }

    return insights;
  }

The suggestions are appear in a carousel slider that updates every few seconds, offering multiple ways to improve mood. I also use this function in the safe mode feature.

Safe Mode: A Calming Experience

Safe Mode needed to be visually distinct, easy to access, and genuinely helpful during stressful moments.

The key features of Safe Mode include:

  • Breathing Animation: Explands & contracts to guide deep breathing. This is accompanied with text that fades in and out. “Breathe in…” fades in as the animation expands, “Breathe out…” comes in and fades out as the animation contracts
  • Relaxing sounds: Plays calming background audio
  • Soothing UI: Soft colors, minimalist design, no distractions
class SafeModeScreen extends StatefulWidget {
  const SafeModeScreen({super.key});

  static const id = '/safe-mode';

  @override
  State<SafeModeScreen> createState() => _SafeModeScreenState();
}

class _SafeModeScreenState extends State<SafeModeScreen> with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _sizeAnimation;
  late Animation<double> _fadeAnimation;
  bool isBreathingIn = true;

  final AudioPlayer _audioPlayer = AudioPlayer();
  bool isPlaying = true;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 4),
    );

    _sizeAnimation = Tween<double>(begin: 0.7, end: 1).animate(
      CurvedAnimation(
        parent: _animationController,
        curve: Curves.easeInOut,
      ),
    );

    // Fade Animation for Text
    _fadeAnimation = Tween<double>(begin: 0, end: 1).animate(
      CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
    );

    // Listen to animation status to switch text
    _animationController
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          setState(() => isBreathingIn = false); // Switch to "Breathe Out"
          _animationController.reverse(); // Shrink back
        } else if (status == AnimationStatus.dismissed) {
          setState(() => isBreathingIn = true); // Switch to "Breathe In"
          _animationController.forward(); // Expand again
        }
      })

      // Start breathing animation loop
      ..forward();

    _playMusic();
  }

  @override
  void dispose() {
    _animationController.dispose();
    _audioPlayer.dispose();
    super.dispose();
  }

  Future<void> _toggleMusic() async {
    if (_audioPlayer.state == PlayerState.playing) {
      await _pauseMusic();
      setState(() {
        isPlaying = false;
      });
    } else if (_audioPlayer.state == PlayerState.paused) {
      await _resumeMusic();
      setState(() {
        isPlaying = true;
      });
    } else if (_audioPlayer.state == PlayerState.stopped) {
      await _playMusic();
      setState(() {
        isPlaying = true;
      });
    } else if (_audioPlayer.state == PlayerState.completed) {
      await _stopMusic();
      setState(() {
        isPlaying = false;
      });
    } else {
      await _stopMusic();
      setState(() {
        isPlaying = false;
      });
    }
  }

  Future<void> _playMusic() async {
    await _audioPlayer.play(
      AssetSource(MediaResources.meditationMusic),
    );
  }

  Future<void> _resumeMusic() async {
    await _audioPlayer.resume();
  }

  Future<void> _pauseMusic() async {
    await _audioPlayer.pause();
  }

  Future<void> _stopMusic() async {
    await _audioPlayer.stop();
  }

  void _exitSafeMode(BuildContext context) {
    _stopMusic();
    Navigator.pop(context);
    Future.delayed(const Duration(milliseconds: 300), () {
      _showSelfCareSuggestion(context);
    });
  }

  void _showSelfCareSuggestion(BuildContext context) {
    final suggestions = <String>[
      'Write something positive about your day ✍🏻✍🏽✍🏾',
      'Take a short mindfulness break 🏝️',
      'Reflect on a happy moment 💭',
      'Listen to your favorite song 🎶',
      'Go for a short walk outside🚶🏻🚶🚶🏽',
    ];

    final suggestion = (suggestions..shuffle()).first; // Pick a random suggestion

    showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Self-Care Suggestion'),
        content: Text(suggestion),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('OK'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.blueGrey.shade900,
      appBar: AppBar(
        backgroundColor: Colors.blueGrey.shade900,
        foregroundColor: Colors.white,
        title: const Text(
          'Safe Mode',
          style: TextStyle(color: Colors.white),
        ),
        centerTitle: true,
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              const SizedBox(height: 5),
              Column(
                children: [
                  // Fading "Breathe in / Breathe out" Text
                  AnimatedBuilder(
                    animation: _fadeAnimation,
                    builder: (context, child) {
                      return Opacity(
                        opacity: _fadeAnimation.value,
                        child: Text(
                          isBreathingIn ? 'Breathe in...' : 'Breathe out...',
                          style: const TextStyle(fontSize: 24, color: Colors.white),
                          textAlign: TextAlign.center,
                        ),
                      );
                    },
                  ),
                  const SizedBox(height: 50),

                  AnimatedBuilder(
                    animation: _sizeAnimation,
                    builder: (context, child) {
                      return Transform.scale(
                        scale: _sizeAnimation.value,
                        child: Container(
                          width: 325,
                          height: 325,
                          decoration: const BoxDecoration(
                            shape: BoxShape.circle,
                            color: Colors.blueAccent,
                          ),
                          child: Center(
                            child: ImageIcon(
                              isBreathingIn
                                  ? const AssetImage(
                                      MediaResources.breatheIn,
                                    )
                                  : const AssetImage(MediaResources.breatheOut),
                              size: 125,
                              color: Colors.white,
                            ),
                          ),
                        ),
                      );
                    },
                  ),
                ],
              ),
              Column(
                children: [
                  ElevatedButton.icon(
                    onPressed: _toggleMusic,
                    icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
                    label: Text(
                      _audioPlayer.state == PlayerState.paused
                          ? 'Resume Music'
                          : isPlaying
                              ? 'Pause Music'
                              : 'Listen to Calm Music',
                      style: const TextStyle(fontSize: 16),
                    ),
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.white,
                      foregroundColor: Colors.black,
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 10),
              Column(
                children: [
                  ElevatedButton(
                    onPressed: () => _exitSafeMode(context),
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.green,
                      foregroundColor: Colors.white,
                    ),
                    child: const Text(
                      'Positive Reflection',
                      style: TextStyle(fontSize: 16),
                    ),
                  ),
                  TextButton(
                    onPressed: () => Navigator.pop(context), // Exit Safe Mode
                    style: ElevatedButton.styleFrom(
                      foregroundColor: Colors.white,
                    ),
                    child: const Text(
                      'Exit',
                      style: TextStyle(fontSize: 16),
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 10)
            ],
          ),
        ),
      ),
    );
  }
}

Conclusion

Building this mental health journal app was about more than just adding features—it was about creating an experience that truly supports users. Weeks 4 and 5 focused on making the app more insightful, engaging, and comforting with mood trends, push notifications, and Safe Mode.

Seeing emotions visualized over time helps with self-reflection, reminders encourage consistent journaling, and Safe Mode provides a moment of calm when needed most. This project pushed my Flutter, Firebase, and AI skills while reinforcing the importance of designing for real impact.

The MVP is complete, but there’s always room for growth. What features would make journaling even more effective? Let’s discuss!

Christian
Christian
Articles: 12

One comment

Comments are closed.