DEV Community

Muhammad Aamir Yameen
Muhammad Aamir Yameen

Posted on

Building an Offline-First Productivity App with React Native, Supabase & WatermelonDB

After months of building, I just shipped Thinkora — an all-in-one
productivity app for Android. Here's the technical breakdown of what I
built, the stack I chose, and the lessons I learned along the way.

The Problem

I was juggling 5 different apps to stay organized:

  • Notion for notes
  • Todoist for tasks
  • CamScanner for documents
  • Forest for focus
  • Google Keep for quick notes

Each app did one thing well, but nothing was integrated. And worse —
most of them broke the moment I lost internet.

So I built Thinkora: one offline-first app that does it all.

What's Inside

  • 📝 Rich notes with sketches, voice memos, and attachments
  • ✅ Tasks with AI-generated subtasks
  • 📷 Document scanner with OCR in 5 languages
  • 📅 Time blocking with a draggable daily timeline
  • ☁️ Cloud sync across devices
  • 🍅 Pomodoro timer + habit tracker + mood journal
  • 🤖 Built-in AI assistant (no API key required)

The Stack

Layer Choice Why
Framework React Native 0.81 Single codebase, fast iteration
Storage WatermelonDB Offline-first, JSI-powered SQLite
Backend Supabase Auth, cloud sync, edge functions
AI Gemini via Supabase Edge Functions Free for users, secure proxy
OCR Google ML Kit On-device, 5 language scripts
Notifications Notifee Native Android channels & alarms
Navigation React Navigation Stack + tabs with custom UI

Architectural Decisions

1. Offline-First Storage with WatermelonDB

I started with AsyncStorage but quickly hit performance walls with
1000+ notes. Switching to WatermelonDB (SQLite via JSI) was a
game-changer:


typescript
async function setNotes(notes: Note[]): Promise<void> {
  await database.write(async () => {
    const existing = await notesCollection.query().fetch();
    const existingMap = new Map(existing.map((r) => [r.id, r]));
    const incomingIds = new Set(notes.map((n) => n.id));
    const ops: any[] = [];

    // Atomic batch operation: delete, update, create
    for (const row of existing) {
      if (!incomingIds.has(row.id)) ops.push(row.prepareDestroyPermanently());
    }
    for (const note of notes) {
      const row = existingMap.get(note.id);
      if (row) {
        ops.push(row.prepareUpdate((r) => { /* ... */ }));
      } else {
        ops.push(notesCollection.prepareCreate((r) => { /* ... */ }));
      }
    }
    await database.batch(...ops);
  });
}

Try It Out
If any of this resonates, I'd love your feedback:

📲 Free on Play Store: https://play.google.com/store/apps/details?id=com.thinkora
Enter fullscreen mode Exit fullscreen mode

Top comments (0)