DEV Community

Cover image for I built an Android app for remembering the little things about people you love (no AI, no internet permission)
Blake Riding
Blake Riding

Posted on

I built an Android app for remembering the little things about people you love (no AI, no internet permission)

My brain drops things. Not the big stuff, but the small stuff that makes someone feel known: her coffee order, that she stopped to look at a blue scarf, that she doesn't want to always be the one who plans everything.

So I built Little Things, an Android app for people with ADHD (or just busy brains) to capture those details the moment they come up and get them resurfaced later, when they're useful.

This post is about the few design decisions that mattered, and the code behind them.

The constraint: capture has to take about five seconds

If saving a note takes more than a tap or two, I won't do it in the moment, and then the note never exists. So the home screen has one big button, the keyboard opens immediately, and there's a "Save + another" option for brain dumps.

The part that makes it feel instant is that you never pick a category. You just type, and the app guesses.

"Smart" categorization without AI

Everyone's first assumption is that this uses a model. It doesn't. It looks for key words and phrases in what you type, and nothing leaves the phone.

The whole thing is one function on the Category enum:

fun guess(text: String): Category? {
    val t = " ${text.lowercase().replace('’', '\'')} "
    fun has(vararg words: String) = words.any { t.contains(it) }
    return when {
        has("doesn't like", "dislike", "hates", "allergic", "avoid") -> DISLIKES
        has("doesn't want to", "needs", "tired of", "overwhelmed", "mental load") -> NEEDS
        has("size", " xs ", " xl ", "inseam") -> SIZES
        has("gift", "present", "wishlist", "for christmas") -> GIFT_IDEAS
        has("favorite", "favourite", " fav ") -> FAVORITES
        has("wants", "want to", "someday", "bucket list") -> WANTS
        has("coffee", " tea ", "latte", "pizza", "vegan", "snack") -> FOOD
        has("loves", "likes", "enjoys", "obsessed", "adores") -> LOVES
        else -> null
    }
}
Enter fullscreen mode Exit fullscreen mode

(Trimmed here for the post; the real lists are longer.)

Order is the whole trick. "Doesn't like cilantro" contains "like", so if LOVES were checked first, you'd file a dislike under loves, which is the worst possible bug in an app about remembering what people care about. Negations go first: "doesn't like" beats "likes", and "doesn't want to" beats "want to". There's a unit test pinned to exactly that:

@Test fun dislikeBeatsLike() =
    assertEquals(Category.DISLIKES, Category.guess("doesn't like cilantro"))
Enter fullscreen mode Exit fullscreen mode

When nothing matches, guess returns null and the user picks manually. A wrong guess is annoying; a missing guess is just one extra tap. I'd rather be conservative.

Why not a model? Three reasons:

  1. Privacy. These notes are about real people in someone's life. The app declares one permission, POST_NOTIFICATIONS. There is no INTERNET permission at all, so it physically can't phone home.
  2. Latency and reliability. A when block returns instantly, offline, every time.
  3. Debuggability. When it guesses wrong, I add a phrase and a test. No prompt tuning.

The daily pick: shuffled, but stable

The home screen and the home-screen widget both show "a little thing for today." They have to agree, and they have to change every day, without storing anything extra.

object TodaysPick {
    fun order(notes: List<Note>, epochDay: Long): List<Note> =
        notes.filter { !it.done }.sortedBy { it.id }.shuffled(Random(epochDay))
}
Enter fullscreen mode Exit fullscreen mode

Sort by id first so the input order is deterministic, then shuffle with a Random seeded by the epoch day. Same day, same order, on every surface. New day, new order. No database column, no sync between the app and the widget.

Nudges that rotate instead of repeat

A daily WorkManager job handles date reminders and the occasional nudge. For nudges I didn't want pure random, because random will happily show you the same note three times in a week while others never come up.

So each note records when it was last surfaced, and the query returns the eight least-recently-surfaced notes. The worker picks randomly from those:

val note = repo.leastRecentlySurfaced(8).randomOrNull() ?: return false
Enter fullscreen mode Exit fullscreen mode

Everything eventually comes up, but it doesn't feel like a queue marching in order.

Some categories also get a follow-up question that nudges toward action instead of just recall. A NEEDS note adds "Anything small you can do for Sam today?" and a GIFT_IDEAS note adds "Maybe this is the week?"

One gotcha worth sharing: WorkManager doesn't fire at the exact same minute daily. My nudge check compares elapsed time against the frequency minus three hours of slack, otherwise a "daily" nudge sometimes skips a day because the job ran a few minutes early.

Birthday reminders that carry a number

For important dates the notification does one more thing: it counts your saved gift ideas.

Tomorrow: Sam's birthday
You have 3 saved gift ideas. Tap to see them.

Or, if you have none: "No gift ideas saved yet. Tap to see what they love." The reminder isn't just a date, it's a prompt to use what you've already captured. A small query (countActiveGiftIdeas) makes it much more useful than a bare calendar alert.

The date math had a few traps, so it's covered by tests: Feb 29 falling back to Feb 28 in non-leap years, past dates rolling to next year, and optional years so "anniversary, 5 years" works but a birthday with no year doesn't invent a count.

Backups without a server

No cloud means no sync, so backup is a single .zip the user keeps wherever they like: a data.json plus a photos/ folder.

Two details I'm glad I did:

  • Parse everything before touching the database. Import decodes the whole file into objects first, then wipes and inserts inside one Room transaction. A corrupt file can't leave you with an empty app.
  • Guard zip entry names. Restoring from an archive means writing files from untrusted names, so anything with a path separator or .. is rejected. Classic zip-slip, easy to forget in a hobby app.

The stack

Kotlin, Jetpack Compose (Material 3), Room, WorkManager, Navigation Compose, and Glance for the widget. Min Android 8.0 (API 26).

Try it

Little Things is an Android app:
https://play.google.com/store/apps/details?id=com.blakeriding.littlethings&referrer=utm_source%3Dwebsite%26utm_medium%3Dreferral%26utm_campaign%3Dsite_hero.

If you try it, I'd love to hear which phrases it guesses wrong.

The repo is private for now, but I'm happy to answer questions about any of the code above in the comments.

Top comments (1)

Collapse
 
alexcodebytes profile image
Oleksandr •

The dislikeBeatsLike unit test is such a great example of defensive design. In an app about remembering what people care about, filing "doesn't like cilantro" under "loves" would be a catastrophic UX failure, and pinning the negation order with a test is exactly how you prevent that from regressing.

The seeded shuffle for the daily pick is also brilliant. Using the epoch day as a seed means the app and the home-screen widget always show the same note without ever needing to sync or store an extra column.

One question on the WorkManager nudges: since you're selecting from the 8 least-recently-surfaced notes, how does the app handle brand-new notes that haven't been surfaced yet? Do they get a priority boost, or do they just wait until the older notes rotate out of the queue?