A To-Do List app built with Expo, TypeScript, and expo-router, featuring voice-powered task creation via the Deepgram speech-to-text API.
Built for the AAIR Labs React Native Developer Exercise (Stage 1).
Core (per the brief)
- Add, view, complete/incomplete, and delete tasks
- Task list with clear visual distinction between completed and incomplete tasks
- Data persists locally via AsyncStorage — tasks survive app restarts and force-quits
- Navigation between a Task List screen and an Add/Edit Task screen
- Voice input via a floating action button: tap, speak one or several tasks naturally, review an editable transcript, confirm to add
- Handles edge cases: empty task title is blocked, empty task list shows a proper empty state
Bonus
- Due dates, with dynamic sectioning by due-date proximity (Overdue / Today / This Week / Later / No Due Date)
- Search/filter functionality, plus All / Pending / Completed filter tabs with live counts
- Light/dark theme, toggleable, persisted
- TypeScript throughout, no
anyin application code - Unit tests across the app's core logic (see Testing)
- Animations and transitions: swipe-to-complete/delete on task cards, animated theme toggle, animated empty states, reactive waveform during voice recording, entrance animations on the onboarding screen
Beyond the brief
- Full Task Detail screen with a created → due → completed timeline
- Editing existing tasks (separate from creating new ones, same form component, same validation)
- Zod + React Hook Form validation on the task form
- A splitting heuristic that distinguishes "log one task with a longer description" from "log several short tasks in one breath" — see Architecture Notes
| Concern | Choice |
|---|---|
| Framework | Expo (managed workflow) |
| Routing | expo-router (file-based) |
| Language | TypeScript |
| State | React Context + useReducer |
| Forms & validation | React Hook Form + Zod |
| Persistence | @react-native-async-storage/async-storage |
| Animation | React Native Reanimated |
| Gestures | React Native Gesture Handler |
| Voice recording | expo-av |
| Speech-to-text | Deepgram API (nova-2 model) |
| Testing | Jest (jest-expo preset) + @testing-library/react-native |
-
Install dependencies
npm install # or yarn install -
Environment variables — create a
.envfile in the project root:EXPO_PUBLIC_DEEPGRAM_API_KEY=your_deepgram_api_key_hereRequired for voice input. Get a free key at console.deepgram.com — includes $200 in free credits, no card required to start.
.envis gitignored; it is not included in this submission. -
Start the dev server
npx expo start
-
Run it
ifor iOS simulator,afor Android emulator, or scan the QR code with Expo Go on a physical device.- Voice input requires a real device, or a simulator/emulator with working microphone passthrough — many default simulators don't support live mic input, and the recording step will silently fail or error out on those.
app/ # expo-router routes (thin — logic lives in src/screens)
_layout.tsx # Root layout: providers (Theme, Task), Stack navigator
index.tsx # Redirects to /onboarding
onboarding.tsx
(tabs)/
_layout.tsx # Tab navigator
index.tsx # → DashboardScreen
task/[id].tsx # → TaskDetailScreen
create-task.tsx # → TaskFormScreen (mode="create")
edit-task/[id].tsx # Resolves task, → TaskFormScreen (mode="edit")
src/
components/
core/ # Reusable primitives (Input, HeaderTitle, EmptyState, GoBackBtn...)
dashboard/ # FilterTabs, SectionContainer, TodoItemCard, VoiceCaptureFAB
forms/ # FormInput, FormDateField, TaskFormFields, DisplayStatus
voice/ # VoiceRecordingModal, Waveform
layout/ # FormContainer
context/ # ThemeContext, TaskContext (state + AsyncStorage persistence)
hooks/ # useZodForm, useTaskForm, useGroupedTasks, useVoiceRecorder
services/ # storage.ts (AsyncStorage), speechToText.ts (Deepgram)
utils/ # taskSplitter, date helpers, scaling, id generator
validations/ # Zod schemas
theme/ # Light/dark color tokens
constants/ # Metrics, typography scale
types/ # Shared TypeScript types
screenshots/ # Submission screenshots (see below)
__tests__ folders live alongside the code they test (e.g. src/utils/__tests__/).
State management — React Context + useReducer, not a state library like Zustand. The brief's own evaluation criteria calls out "components, hooks, state, navigation" as what's being assessed, so leaning on raw fundamentals felt like the more honest signal for a skills exercise, even though Zustand is my usual default on other projects.
Voice input pipeline — expo-av records audio locally; the finished clip is sent to Deepgram's pre-recorded transcription endpoint (nova-2 model). This is a batch, not streaming, API — so there is no live word-by-word transcript while the user is still talking. Instead:
- During recording: a waveform reacts to live mic metering (via
onRecordingStatusUpdate) for real-time confidence that you're being heard. - The moment recording stops: the clip is transcribed, and a fully editable transcript field appears before anything is saved — this is the actual "visible feedback of what you said," just positioned after processing completes rather than live, since a batch API has no other honest way to offer it.
Stop behavior — hybrid by design: automatic silence detection (stops ~1.5s after the user goes quiet, mirroring how voice assistants behave) plus a manual Stop button as an always-available override. Silence detection only arms after a minimum recording duration and only after real speech has already been detected, so it can't fire before the user starts talking.
Task splitting (src/utils/taskSplitter.ts) — two behaviors depending on what was actually said:
- Multiple short items joined by "and"/"then" ("buy provisions and call mom") → split into separate, title-only tasks.
- A single longer dictation with no connector words → split into a title (first clause/sentence) + a description (the remainder, hard-capped at 500 words).
Why the OpenAI API key was swapped for Deepgram — the brief names "the OpenAI API or another speech-to-text API." The available OpenAI key didn't have Whisper model access enabled at the project-permission level (not something fixable client-side, and dashboard access to fix it wasn't available). Deepgram was substituted, using the exact same "single isolated service file" architecture (src/services/speechToText.ts) — swapping providers touched only that one file, nothing else in the voice pipeline changed.
Security — the Deepgram API key ships in the client bundle, since EXPO_PUBLIC_ env vars are inlined at build time. Acceptable for a take-home exercise; in a production app this call would be proxied through a backend so the key never reaches the client. Noted explicitly here rather than left unstated.
Being upfront about tradeoffs rather than leaving them for a reviewer to discover:
- The task-splitting heuristic isn't perfect. It treats "and"/"then" as task boundaries, so a single dictated task that happens to use "and" as an ordinary conjunction (e.g. "submit the report and make sure it includes the Q3 numbers") will get split into two tasks rather than one task with a longer description. A more robust version would use an LLM call to classify intent rather than a connector-word regex — noted as the natural next iteration, not implemented here to keep the voice pipeline's cost and latency predictable for a take-home.
- No live partial transcript during recording — an inherent constraint of using a batch (not streaming) transcription API, explained above.
- Voice input doesn't work in most simulators without explicit microphone passthrough configuration — test on a real device for the most reliable experience.
npm testWeighted deliberately toward pure-logic and isolated-component tests over full screen integration tests — in React Native, screen-level tests require heavy mocking of native modules (Reanimated, gesture-handler, expo-router) and tend to end up testing the mocks more than the app. Covered:
| File | What it covers |
|---|---|
src/utils/__tests__/taskSplitter.test.ts |
Multi-task vs. title/description splitting, truncation, 500-word cap, punctuation cleanup, connector-word edge cases |
src/utils/__tests__/date.test.ts |
Relative date formatting, overdue detection, calendar-day boundary handling |
src/services/__tests__/storage.test.ts |
AsyncStorage read/write round-trip, corrupted-data fallback, write/read failure handling |
src/context/__tests__/taskReducer.test.ts |
Every TaskContext reducer action in isolation, including no-op/unknown-action cases |
src/hooks/__tests__/useGroupedTasks.test.ts |
Dynamic sectioning across all three filter tabs, search filtering, recomputation on prop changes |
src/components/core/__tests__/EmptyState.test.tsx |
Component-level render + interaction test |
Note on renderHook: the current version of @testing-library/react-native made renderHook (and its rerender) async, so hook tests await renderHook(...) and await rerender(...) throughout — this is intentional, not a leftover bug.
Required by the brief, plus a few extra to show off theming and the detail view. Save each capture with the exact filename below so these embeds resolve correctly — PNG or JPG, full device screen, no mockup frames.
Onboarding Screen
![]() Dark |
![]() Light |
Task List
![]() Empty State |
![]() Mixed Completed/Incomplete |
Add Task Screen
Voice Input
![]() Listening / FAB Active |
![]() Review & Confirm |
Theming
![]() Dark Mode |
![]() Light Mode |
Search & Due Dates
![]() Search In Use |
![]() Due Dates & Sectioning |
Task Detail Screen
Voice Input Flow — Screen Recording
A short screen recording of the full voice flow (tap → listen → process → review → confirm) is included at ./src/screenshots/voice-input-demo.mov.
Built by Emmanuel Chika.











