← All projects
Case study · Edtech · Mobile · Payments

English for the job you already have, in 14 vocational fields.

Belka Tech English teaches career-specific English to Algerian workers and students. Bilingual EN/AR lessons, speech-recognition pronunciation practice, PDF certificates, and paid level unlocks in dinars that the phone is not allowed to grant itself. Built solo, on the Play Store since 2026.
ProductBelka Tech English (com.belka.app)
StatusLive on Google Play, v1.4.1, 9 signed releases
PlatformAndroid and iOS, React Native 0.84 bare, New Architecture
StackReact 19, TypeScript, Supabase Postgres + Edge Functions, Chargily Pay
Belka home screen with current track, streak and daily goal
14
vocational fields, 5 levels each
488
lessons, generated and type-checked
1,931
quiz questions, answer keys audited
71
Word documents parsed into TypeScript

Overview.

Who it is for, what was hard, what shipped.
The learner

An Algerian professional who needs English for work

Nurses, electricians, teachers, accountants and trainees who do not need general English. They need the vocabulary of their own trade, explained in Arabic, on a phone, for a price a local salary supports.

The problem

Generic language apps teach the wrong words

Mainstream apps teach travel and small talk. Nothing covered specialised workplace English for these fields in Arabic, and nothing took a payment method Algerians actually hold.

The product

A bilingual, field-specific curriculum with a real paywall

Five graded levels per field, pronunciation practice that gates progress, streaks and certificates for retention, and dinar payments through Chargily enforced in Postgres rather than in the app.

Screens.

Captured from the shipped build running on an iPhone simulator.
A learner enrols in fields, not in a generic English course
01 · My courses

A learner enrols in fields, not in a generic English course

Belka teaches English for a job, so the unit of enrolment is a vocational field. A learner picks from 14 of them at onboarding and every screen after that is scoped to those choices.

  • 1
    Fields, not chaptersEducation, electronics, engineering, IT, law, marketing, medicine, tourism, trades, HR, business, accounting, administration, agriculture.
  • 2
    One entitlement across fieldsA level is unlocked once and applies to every field the learner has enrolled in, which is why the paywall reads “Level 1 unlocked in ALL your fields”.
  • 3
    Progress per fieldCompletion, lesson counts and percentages are aggregated per field from the Postgres lesson_progress table, with an AsyncStorage mirror as a local backup.
Five CEFR-style levels, one free, the rest bought in dinars
02 · The level ladder

Five CEFR-style levels, one free, the rest bought in dinars

Each field is a ladder of five levels. Level 0 is free so a learner can judge the product before paying; levels 1 to 5 are sold individually in DZD, with a global launch-discount flag the business can flip on and off.

  • 1
    Free first rungLevel 0 gives 7 lessons and roughly 70 minutes of content in every enrolled field, with no account restrictions.
  • 2
    Priced per level1,500 to 3,500 DZD per level, or 10,000 DZD for everything, with a server-side discount toggle. The screenshot shows the launch offer live at −25%.
  • 3
    Locked in the database, not the UIThe padlock is cosmetic. What actually gates content is a row in user_level_access that the client has no permission to write.
Lessons unlock in order, so nobody skips the vocabulary
03 · Lesson modules

Lessons unlock in order, so nobody skips the vocabulary

Inside a level, lessons are sequential. Module two stays greyed out until module one is finished, which keeps the vocabulary load cumulative rather than letting a learner jump to the interesting screens.

  • 1
    Roughly 7 lessons per levelAround 488 lessons across the whole catalogue, each carrying five vocabulary words and its own mini-quiz.
  • 2
    A level test at the endEvery level closes with a dedicated test, generated by the same content pipeline as the per-lesson quizzes.
  • 3
    Progress survives reinstallsCompletion, score, time spent and correct/wrong counts are upserted to Postgres on the key (user, field, level, lesson).
The Arabic translation is one tap away, never on screen by default
04 · Bilingual lessons

The Arabic translation is one tap away, never on screen by default

Showing the Arabic next to every English line lets the learner read the answer instead of recalling it, which defeats the exercise. So translations render as a “Show translation” pill and reveal on tap.

  • 1
    Deliberate frictionHidden by default is the pedagogy, not an oversight. Comprehension failures are still one tap from a translation.
  • 2
    A real hide-allA lesson-wide AR toggle is persisted to AsyncStorage. Flipping it back off collapses every individually-revealed line, so “hide” genuinely hides.
  • 3
    Text-to-speech everywhereEvery sentence and vocabulary word has a speaker button wired to the on-device TTS engine, so learners hear the target before they attempt it.
You cannot advance until the recognizer accepts how you said it
05 · Pronunciation practice

You cannot advance until the recognizer accepts how you said it

The vocabulary step is a gate. Listen to the word, then say it back; the app grades the attempt perfect, good or try again and only a pass moves the learner on.

  • 1
    Listen before you speakThe microphone stays disabled until the learner has actually played the word, so the attempt is a reproduction rather than a guess.
  • 2
    Graded, not matchedSpeech recognition output is normalised and compared in tiers rather than by string equality. The deep dive below explains why that matters.
  • 3
    Mic and speaker never overlapStarting a recording stops TTS and playing audio tears down the recognizer, which is the only way to keep an Android speech engine from hearing itself.
Chargily Pay, dinars, and a promo code that cannot be double-spent
06 · Paying in Algeria

Chargily Pay, dinars, and a promo code that cannot be double-spent

Algerian learners pay with a Dahabia card through Chargily Pay. The checkout opens in the system browser and returns by deep link, which makes the interesting part of this feature everything that happens while the app is not in the foreground.

  • 1
    Local payment railChargily Pay V2, created and polled through a Supabase Edge Function. Prices are in DZD and the launch discount is applied server-side.
  • 2
    Promo codes are atomicRedemption is a single SECURITY DEFINER function that validates the code, burns a use and grants access in one transaction, so two taps cannot claim the same code twice.
  • 3
    The app never grants itself accessThe client is a read-only observer of its own entitlements. Grants originate from an HMAC-verified webhook or the promo RPC, never from the phone.
Finish a field, get a PDF certificate with a number on it
07 · Credentials

Finish a field, get a PDF certificate with a number on it

Vocational learners want something to show an employer. Completing all the lessons in a field produces a downloadable, shareable PDF certificate rendered on the device.

  • 1
    Rendered on deviceHTML to PDF locally, so a certificate can be produced and shared without a round trip or a server-side renderer.
  • 2
    Tracked per fieldEarned, ready and in-progress are counted separately, with the outstanding lesson count shown against each field.
  • 3
    Known gapCertificate numbers are currently generated client-side without a uniqueness check. Moving issuance behind a server function is the first fix on the list.
Streaks, daily goals and three bilingual nudges a day
08 · Retention

Streaks, daily goals and three bilingual nudges a day

Retention on a self-paced learning app is the whole game. Belka tracks a daily streak, sets a two-lesson daily goal and schedules randomised local notifications in Arabic and English.

  • 1
    Streaks with milestonesCurrent, longest and total active days, with milestone notifications at 3, 7, 14 and 30 days.
  • 2
    Three windows a dayMorning, afternoon and evening nudges are scheduled on-device through Notifee, randomised inside each window so they do not feel mechanical.
  • 3
    Profile built for AlgeriaSign-up captures the learner's wilaya from all 58 provinces, plus city and chosen fields, so cohorts can be read geographically.
Light and dark themes
58 Algerian wilayas
Syllable stress hints
On-device TTS
Account deletion endpoint
17 screens, 6 shared components

Architecture.

No custom server. Supabase is the backend, and the curriculum is compiled into the bundle.
Device
React Native app17 screens on one stack navigator. Context for auth and theme, refs for lifecycle-critical state.
On-device enginesTTS, speech recognizer, Notifee scheduler, HTML-to-PDF certificate renderer.
AsyncStorageSession, progress backup, streak, theme, Arabic visibility.
Bundled curriculum~64,000 lines of typed lesson data. No CMS, no over-the-air content.
→
Supabase
Postgres + RLSprofiles, lesson_progress, field_progress_summary, user_level_access, promo_codes, certificates, app_config.
AuthEmail and password, session persisted and auto-refreshed, redirect on refresh failure.
Edge Functionschargily-checkout, chargily-webhook, delete-account.
claim_promo_code()SECURITY DEFINER. Validate, burn and grant in one transaction.
→
Third party
Chargily Pay V2Algerian card and Dahabia payments in DZD. HMAC-signed webhooks.
Build-time pipeline71 .docx → Python parser → typed TypeScript modules, checked by tsc.
Native patches5 patch-package patches against RN 0.84 New Architecture dependencies.

Engineering deep dives.

The six problems that took the longest, including what each one cost.

1. Closing a payment-bypass privilege escalation

Problem
Level unlocks were originally written by the client. Because user_level_access carried a client INSERT policy, any signed-up user could insert rows and grant themselves every paid level. A second hole let a promo row with a NULL level unlock anything.
Approach
All entitlement writes moved server-side and the client's write path was removed entirely. Grants now come only from the chargily-webhook function after verifying Chargily's HMAC-signed checkout.paid event, or from claim_promo_code(), a SECURITY DEFINER function that validates, burns and grants inside one transaction. Codes without an explicit level are rejected outright.
Trade-off
The unlock is no longer instant from the client's point of view; the app has to wait for an out-of-band webhook, which forced the polling design below.
Lives in: src/utils/levelAccess.ts, supabase/migrations/*_payment_tables.sql

2. Reconciling a payment the phone never sees

Problem
The learner pays in an external browser and the grant reaches Supabase from Chargily over a channel the phone is not part of. The app can come back to the foreground before, during or after the webhook lands, and on Android may be cold-launched by the deep link.
Approach
Three independent re-entry detectors converge on one idempotent verification routine: a Linking url listener for the warm deep link, getInitialURL() for a cold launch, and an AppState transition for the learner who just taps back. Verification then polls user_level_access every 1.5s for up to 20s, waiting for the server-written grant rather than trusting the payment processor's word.
Trade-off
Polling was chosen over Realtime for simplicity; it wastes requests and caps out at 20s, after which the learner taps Verify again. The poll loop has no abort signal, so it can outlive the screen.
Lives in: src/screens/PaymentScreen.tsx, src/utils/levelAccess.ts

3. Phonetic-tolerant grading on a noisy recognizer

Problem
Grading a spoken word against a target is not string equality. The recognizer returns competing hypotheses, writes numbers as digits, swaps homophones and adds diacritics, while a genuinely correct attempt still has to pass and a wrong one still has to fail.
Approach
A normalise-then-grade pipeline: Unicode NFD decomposition, combining marks stripped, lowercased, non-alphanumerics removed, then a hand-built map canonicalising digits, ordinals and known homophones. Grading runs in tiers, from exact or containment matches down to a length-gated character diff for single words. Every recognizer hypothesis is graded and the best result wins, rather than trusting the top one.
Trade-off
The single-word comparator is a positional character diff, not true Levenshtein, so one inserted character mid-word misaligns the tail and can fail a near-correct attempt. The homophone map is hand-curated and grows manually.
Lives in: src/screens/LessonDetailsScreen.tsx

4. Microphone, speech and animation on one screen

Problem
A single screen drives a TTS engine, a speech recognizer, three looping animations and several timers. Mic and speaker must never run together, recognizer callbacks can arrive after the step has changed, some Android engines emit a spurious error right after a valid result, and RN 0.84 broke Tts.removeEventListener.
Approach
Refs mirror all step state so async callbacks read current values instead of stale closures. Mutual exclusion is explicit in both directions. A latch swallows the spurious post-result error, partial hypotheses are held in a ref to avoid a re-render per audio chunk, and an 8s watchdog auto-stops a stuck recording and is cleared on all seven exit paths.
Trade-off
Correct on the paths that matter but not exhaustive: a quiz auto-advance timer and several error-reset timers are still uncleared and can fire after unmount.
Lives in: src/screens/LessonDetailsScreen.tsx

5. Turning 71 inconsistent Word documents into typed content

Problem
The whole curriculum arrived as 71 .docx files written by different people over time, in five mutually incompatible structural formats, bilingual, with six different vocabulary-card layouts and answer keys written six different ways. Hand-transcribing was not viable and a silent mis-parse would ship wrong answers to paying learners.
Approach
A format-sniffing Python parser picks a strategy per file, splits into lessons and screens and dispatches to per-format extractors, with regexes deliberately tolerant of real malformation. Because the source documents' own answer keys are sometimes wrong, a manual override table keyed by field, level, lesson and question patches verified errors, each with a comment recording the evidence. Output is serialised to typed TypeScript, so the whole corpus is type-checked at build time.
Trade-off
Regex parsing is brittle against genuinely new source formats and needs a re-run plus re-verification whenever the documents change. The corpus compiles into the bundle, so fixing one wrong answer currently requires a full app release.
Lives in: scripts/parse_lessons.py (~1,270 lines)

6. Bilingual pedagogy encoded in the component tree

Problem
The Arabic translation has to be available without being visible, and a lesson-wide “hide all” has to genuinely hide lines the learner already revealed by tapping.
Approach
A dedicated ArabicText component renders a reveal pill instead of the text and tracks its own revealed state. A lesson-wide override persisted to AsyncStorage can force everything visible; an effect keyed on that override collapses every individually-revealed line when it is switched back off.
Trade-off
The friction is the point but costs a tap per line for beginners. The app right-aligns Arabic without enabling I18nManager RTL, so it is Arabic text inside an LTR layout rather than a true RTL interface.
Lives in: src/components/ArabicText.tsx, src/screens/LessonDetailsScreen.tsx

Payment flow.

From tap to unlock, with the phone deliberately out of the trust path.
1 · TapLearner picks a level. The app asks an Edge Function to create a Chargily checkout.
2 · PayCheckout opens in the system browser. Dahabia or card, in dinars.
3 · WebhookChargily posts an HMAC-signed checkout.paid event to Supabase, which writes the grant.
4 · ReturnDeep link, cold launch or app resume, whichever happens first, triggers one idempotent verification.
5 · ConfirmThe app polls its own entitlement row until the server-written grant appears, then unlocks.

Entitlement rules.

What the database guarantees, whatever the app does.
No client writesThe client INSERT policy on the entitlements table was removed. The app can read its own grants and nothing else.
Row-level securitySelect and insert are restricted to auth.uid() = user_id, so one learner can never see or touch another's access.
Atomic redemptionValidating a promo code, burning a use and granting access happen in a single transaction, immune to concurrent double-redemption.
No blank-level codesA promo row without an explicit level is rejected, closing the hole where one code unlocked everything.
Server is the source of truthThe client never decides it has been paid for. It waits for the grant row the webhook writes.
One entitlement axisThere is no admin role and no role column. The only authorization question the system asks is which levels this user owns.