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

Overview.
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.
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.
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.

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.
- 1Fields, not chaptersEducation, electronics, engineering, IT, law, marketing, medicine, tourism, trades, HR, business, accounting, administration, agriculture.
- 2One 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”.
- 3Progress per fieldCompletion, lesson counts and percentages are aggregated per field from the Postgres
lesson_progresstable, with an AsyncStorage mirror as a local backup.

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.
- 1Free first rungLevel 0 gives 7 lessons and roughly 70 minutes of content in every enrolled field, with no account restrictions.
- 2Priced 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%.
- 3Locked in the database, not the UIThe padlock is cosmetic. What actually gates content is a row in
user_level_accessthat the client has no permission to write.

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.
- 1Roughly 7 lessons per levelAround 488 lessons across the whole catalogue, each carrying five vocabulary words and its own mini-quiz.
- 2A level test at the endEvery level closes with a dedicated test, generated by the same content pipeline as the per-lesson quizzes.
- 3Progress 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
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.
- 1Deliberate frictionHidden by default is the pedagogy, not an oversight. Comprehension failures are still one tap from a translation.
- 2A real hide-allA lesson-wide AR toggle is persisted to AsyncStorage. Flipping it back off collapses every individually-revealed line, so “hide” genuinely hides.
- 3Text-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
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.
- 1Listen before you speakThe microphone stays disabled until the learner has actually played the word, so the attempt is a reproduction rather than a guess.
- 2Graded, not matchedSpeech recognition output is normalised and compared in tiers rather than by string equality. The deep dive below explains why that matters.
- 3Mic 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
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.
- 1Local payment railChargily Pay V2, created and polled through a Supabase Edge Function. Prices are in DZD and the launch discount is applied server-side.
- 2Promo 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.
- 3The 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
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.
- 1Rendered on deviceHTML to PDF locally, so a certificate can be produced and shared without a round trip or a server-side renderer.
- 2Tracked per fieldEarned, ready and in-progress are counted separately, with the outstanding lesson count shown against each field.
- 3Known 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
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.
- 1Streaks with milestonesCurrent, longest and total active days, with milestone notifications at 3, 7, 14 and 30 days.
- 2Three windows a dayMorning, afternoon and evening nudges are scheduled on-device through Notifee, randomised inside each window so they do not feel mechanical.
- 3Profile built for AlgeriaSign-up captures the learner's wilaya from all 58 provinces, plus city and chosen fields, so cohorts can be read geographically.
Architecture.
Engineering deep dives.
1. Closing a payment-bypass privilege escalation
- Problem
- Level unlocks were originally written by the client. Because
user_level_accesscarried 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-webhookfunction after verifying Chargily's HMAC-signedcheckout.paidevent, or fromclaim_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.
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
urllistener for the warm deep link,getInitialURL()for a cold launch, and an AppState transition for the learner who just taps back. Verification then pollsuser_level_accessevery 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.
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.
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.
5. Turning 71 inconsistent Word documents into typed content
- Problem
- The whole curriculum arrived as 71
.docxfiles 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.
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
ArabicTextcomponent 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.
Payment flow.
checkout.paid event to Supabase, which writes the grant.Entitlement rules.
auth.uid() = user_id, so one learner can never see or touch another's access.What’s next.
Engineering debt I would pay first
There is no crash reporting, no analytics and no CI, and the test suite is a single smoke test, so the money path and the pronunciation grader are effectively untested. Four of the eight database objects have no migration in the repo, and the Edge Functions live only in Supabase. Certificate numbers need to be issued server-side with a uniqueness constraint.
Product roadmap
Move the curriculum out of the bundle so a wrong quiz answer does not need an app-store release. Ship a 32-bit Android build, since arm64-only excludes older devices that still matter in this market. Replace the positional character diff in the pronunciation grader with proper alignment, and add retention instrumentation to find out whether the notification system actually works.