Inspiration

This app started as homework. My psychologist gave me an exercise: record my moods during the day, not once in the evening but as they happen, so we could look at the real pattern together instead of my memory of it.

So I went looking for an app to do that. I tried a lot of them, and they all failed in the same two ways. Either they were too heavy: open the app, wait, pick a category, pick an intensity, write something, save. By the third day I skipped it. Or they were light enough but lived behind an icon, and I simply forgot they existed. Days went by with nothing recorded, and the exercise was worthless.

Most of them also asked the same question: "On a scale of 1 to 10, how was your day?" A single number hides the interesting part. A day can hold calm in the morning, irritation at noon and joy at night, and none of that survives in a "7".

I decided the only way to solve my own problem was to build the app I wished existed: recording a feeling takes one tap in the moment it happens, and the picture that emerges over weeks and months is not a line chart but a map of your emotions, where the feelings that fill your days literally take up more space on the screen, and the balance between good and bad mood is visible without reading a single number.

Both failures I had seen came down to one thing: recording has to be effortless. If you have to unlock the phone, find the app, open it and wait, you record twice a day at best and the honest moments are lost. So from the start, the home-screen widget was not an extra but the heart of the app: the emotions sit right on the home screen, one tap records, and a glance at the widget shows how your day is going.

The first version was a native iOS app (SwiftUI, Core Data, CloudKit). It worked, but only for iPhone users. The goal of this project was to bring the same app to Android and the web from a single codebase, and later to run that same shared code on iOS too.

What it does

  • Record in one tap. Twenty emotions, from joy and calm to irritation and sadness. Tap the one you feel right now.
  • Record from the home screen. This is the feature everything else depends on. The widgets on Android and iOS put the emotions right on the home screen, so recording is one tap with no app to open, no unlock-and-search, no friction. The same widget is an overview on the fly: a glance shows what you have recorded today and how the good/bad balance looks, without ever launching the app. It also acts as a gentle reminder to check in with yourself, simply by being there. The result is that people record ten times a day instead of once, and that is what makes the long-term picture honest.
  • Add a note while it's fresh. Right after recording, an "Add notes" button appears for a few seconds. Notes stay editable only on the day they were written, so your history stays honest.
  • See what you feel most. The record page is a two-zone cell chart: a positive zone on top, a negative zone below. Each recorded emotion is a cell whose area grows with its record count, and the divider between the zones moves with your mood balance.
  • Look back. Long-press an emotion for all its records and notes, search the history by note, date or emotion, and set any timeframe to see how a week, a month or a year went.
  • Understand how emotions connect. A read-only relations map shows which emotions tend to lead to one another.
  • Yours, everywhere. Sign in with Apple, Google or email, and your records follow you across devices. No ads, no purchases, in-app account deletion. Available in English, Danish, Russian and Ukrainian.

How we built it

One shared codebase. The app is a Kotlin Multiplatform project with Compose Multiplatform for the UI. All screens, view models, domain logic and data access live in shared/commonMain, compiled to Android, to WebAssembly (wasmJs) for the browser, and to an iOS framework. The code follows a strict three-layer architecture: domain (pure Kotlin models, repository interfaces, use cases), data (Firebase implementations) and presentation (one ViewModel per screen plus Composables). Koin handles dependency injection, and the theme is Material 3 Expressive with dynamic color on Android.

Backend. Persistence moved from iCloud/CloudKit to Firebase: Firebase Authentication (email/password, Google, Sign in with Apple) and Cloud Firestore. We deliberately kept the Firestore schema compatible with the original iOS records so the two worlds can converge later. We also chose no Firestore snapshot listeners: every screen loads on open and on pull-to-refresh, and repositories cache the last fetch in a StateFlow that is updated on every local mutation. It is simpler, cheaper and, for a journal, perfectly responsive.

The chart is math, not pictures. Each emotion has a valence $v \in {-4,\dots,+4}$ (0 is calm, which counts as positive). Its fill opacity is

$$ \alpha = 0.8 \cdot \frac{|v|}{4} $$

so stronger emotions are painted more intensely. Given the record counts $c_i$ in the active timeframe, the positive zone gets the height share

$$ p = \operatorname{clamp}!\left(\frac{\sum_{i \in \text{positive}} c_i}{\sum_i c_i},\ 0.15,\ 0.85\right) $$

(and $p = 0.5$ when nothing is recorded, so both zones always show). Inside a zone, each emotion receives a cell of area

$$ A_i = \frac{c_i}{\sum_{j \in \text{zone}} c_j} \cdot A_{\text{zone}} $$

and the zone is tiled with a squarified treemap (Bruls, Huizing & van Wijk). Emotions are placed heaviest first; a row along the shorter side $s$ of the remaining space keeps accepting the next cell as long as the worst aspect ratio

$$ \max_{i \in \text{row}} \max!\left(\frac{s^2 a_i}{S^2},\ \frac{S^2}{s^2 a_i}\right), \qquad S = \sum_{i \in \text{row}} a_i $$

does not get worse. The result is a gap-free tiling where cells stay close to square no matter how many emotions you have recorded. The negative zone is mirrored, so the lightest cells of both zones meet at the divider. The whole layout is pure, deterministic Kotlin with unit tests, and the Composable only draws the rectangles it is handed.

Widgets first. Because the widget is the main way people record, it got the same care as the app. On Android it is a Glance widget that records an emotion with one tap through a deep link and refreshes its own overview after each record; an app shortcut covers the launcher. On iOS the widget is WidgetKit + SwiftUI, showing the same cell chart and recording through the shared emotionalbin:// scheme. Both read and write the same records as the app, so the widget and the app never disagree.

Platform pieces. On iOS the shared Compose UI runs as an extra build configuration (Debug-Compose / Release-Compose) on the same app target and bundle id, switched by a Swift compilation condition, while the widget stays SwiftUI. The web build is deployed to Firebase Hosting. The privacy, support and account-deletion pages both stores require are served by a small AWS Lambda Function URL.

Shipping. Store copy in four languages, screenshots captured from a dedicated screenshot mode in the app, App Privacy and Data safety questionnaires, privacy manifests, and a reviewer demo account.

Challenges we ran into

  • From bubbles to cells. The iOS original packed circles along a Fermat spiral and scaled to fit. Circles waste space and read badly with 15+ emotions, so we switched to the treemap. We first tried nesting cells by valence group, but a group of two emotions can only be split by one straight cut, which turns the lighter one into a long sliver. Flat squarified packing, ordered by count, then by $|v|$, then by catalog order, gave far better shapes.
  • Swift ↔ Kotlin Firebase bridging. We initially believed Swift could not override Kotlin suspend functions. It can: Swift subclasses of the exported Kotlin adapters with async throws overrides delegate to the native Firebase SDK, as long as the app target stays in Swift 5 language mode.
  • WidgetKit cannot host Compose. The widget is the most important surface of the app, and on iOS it cannot be written in the shared UI code. It had to remain SwiftUI, reading the same data the Compose app writes, which meant keeping two widget implementations in sync by hand.
  • Firestore on WebAssembly. The JS interop needed its own adapter: query snapshots had to be read through snap.docs.map(...), and the web app silently read from the wrong Firestore database until the (default) database was set explicitly.
  • Xcode project hygiene. Hand-editing project.pbxproj in a multi-configuration project breaks things fast, so every Xcode change went through the UI, with the rules written down for future us.
  • Scope. Sharing your emotions with other people was fully working on iOS via CloudKit. Porting it needs a Cloud Function and a paid Firebase plan, so it was consciously deferred to version 2 rather than half-shipped.

Accomplishments that we're proud of

  • It solved the original problem. The homework that no existing app could make me keep up with is now something I do without thinking: a tap on the home screen, ten times a day, for months. The psychologist's exercise finally produced real data.
  • One codebase, three platforms. The same Kotlin code runs the app on Android, in the browser via WebAssembly, and on iOS, with native widgets on both phones. A feature is written once and appears everywhere.
  • A chart people understand at a glance. The two-zone treemap needs no legend and no numbers. The divider moving up or down tells you how your month went before you read anything.
  • Shipped for real. Submitted to the App Store, ready for Google Play, live on the web, with store texts in four languages, legal pages, privacy questionnaires and a reviewer account. Not a prototype.
  • Honest by design. No ads, no tracking, no anonymous data lakes, in-app account deletion, and notes that cannot be rewritten after the day they were written.

What we learned

  • Friction decides whether a journal gets used. The widget removed every step between feeling something and recording it, and gave an overview without opening the app. That single decision did more for the quality of the data than any feature inside the app.
  • Put the algorithm in the domain layer. Because the chart layout is pure Kotlin with no UI dependency, it was testable in milliseconds and identical on Android, web and iOS. Every visual bug turned into a unit test.
  • Kotlin Multiplatform is production-ready for real apps, including auth flows, Firestore and home-screen widgets, but each platform still keeps a thin native edge: Glance on Android, SwiftUI for WidgetKit, a JS adapter for Wasm.
  • Design constraints are features. Notes editable only on the day of recording, no anonymous mode, no listeners: each "no" made the app simpler and more honest.
  • Shipping is half the project. Store listings, privacy questionnaires, legal pages, screenshots for five device classes and reviewer accounts took as long as several feature phases, and they deserved it.

What's next for XEmotions

Sharing. The app began as an exercise between me and my psychologist, and the natural next step is to close that loop: share your emotions with the people who help you. Version 2 lets you invite specific people, a psychologist, a partner, a close friend, to see your emotion map and history read-only, and revoke that access at any time. A therapist opens the session with your real month in front of them instead of your memory of it. The feature already worked on iOS with CloudKit; the Firebase version needs a Cloud Function for invitations, which is the first thing on the list.

After that: the iOS release on the shared Compose codebase, and richer statistics on top of the same timeframe filter, so that what you share is as clear for the other person as it is for you.

Built With

  • android
  • aws-lambda
  • cloud-firestore
  • compose-multiplatform
  • firebase
  • firebase-authentication
  • firebase-hosting
  • glance
  • google-sign-in
  • gradle
  • ios
  • jetpack-compose
  • koin
  • kotlin
  • kotlin-multiplatform
  • kotlinx-coroutines
  • material-design-3
  • mobile
  • sign-in-with-apple
  • swift
  • swiftui
  • wasm
  • webassembly
  • widgetkit
  • xcode
Share this project:

Updates

Submission history