Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/AlonsoSam/vozi-android/llms.txt

Use this file to discover all available pages before exploring further.

All data in VOZI starts on-device. shared_preferences is the primary store — profiles, phoneme progress, attempt history, and settings are serialized as JSON and written locally on every mutation. Supabase sync is an optional overlay: the adult can push local data to the cloud from the parent dashboard, but the app never blocks on a network call and is fully usable without any backend connection.
recognizedText from SpeechAttempt is stored locally and shown in the adult dashboard, but it is never uploaded to Supabase. The remote practice_attempts table has no transcript column. Only anonymized metrics (phoneme_code, target_word, was_correct, score) are synced.

Phoneme Enum

The Phoneme enum declares all nine phonemes/groups that VOZI teaches, in path order. The code string is the stable identifier used everywhere — in shared_preferences keys, Supabase columns, word bank lookups, and UI labels.
Enum valuecodeorder (index)displayName
Phoneme.rR0R
Phoneme.rrRR1RR
Phoneme.sS2S
Phoneme.lL3L
Phoneme.trTR4TR
Phoneme.prPR5PR
Phoneme.plPL6PL
Phoneme.brBR7BR
Phoneme.blBL8BL
displayName is identical to code — the phoneme code is the label shown to both children and adults. order is simply the enum’s declaration index, which defines the learning path sequence.
enum Phoneme {
  r('R'), rr('RR'), s('S'), l('L'),
  tr('TR'), pr('PR'), pl('PL'), br('BR'), bl('BL');

  const Phoneme(this.code);
  final String code;

  String get displayName => code;
  int get order => index;

  static Phoneme fromCode(String code) =>
      values.firstWhere((p) => p.code == code);
}
Use Phoneme.fromCode('RR') to reconstruct a phoneme from a stored string. This is the canonical way to go from a shared_preferences or Supabase value back to the enum.

ChildProfile

ChildProfile is the central data object — one per child managed by an adult. It intentionally stores no personally identifiable information: the name field is an alias or nickname, not a real name, and no birth date is stored.
FieldTypeDescription
idString (UUID)Stable primary key; UUID-compatible with Supabase
nameStringChild’s alias or nickname — not a legal name
ageBandAgeBandyoung (4–5) or older (6–7)
avatarAvatarOptionSelected character from the catalog
pointsintCumulative points from completed phonemes
completedPhonemesSet<String>Phoneme codes where the child achieved ≥90% accuracy
practicedPhonemesSet<String>Phoneme codes attempted at least once (unlocks next station)
updatedAtDateTime (UTC)Timestamp of last local edit; used for last-write-wins sync
isDirtybooltrue when there are local changes not yet pushed to Supabase
deletedAtDateTime?Tombstone timestamp; null means the profile is active
isDeletedbool (getter)true when deletedAt != null — profile is hidden from UI
Key methods:
  • markDirty() — updates updatedAt to now (UTC) and sets isDirty = true. Called automatically by every mutation in ProfileStore.
  • toJson() — serializes to a flat Map<String, dynamic> for shared_preferences storage.
  • ChildProfile.fromJson(Map) — reconstructs from stored JSON with safe fallbacks for missing fields (forward-compatible with older app versions).
// JSON shape produced by toJson()
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "Coco",
  "ageBand": "4-5",
  "avatar": "fox",
  "points": 20,
  "completed": ["R"],
  "practiced": ["R", "RR"],
  "updatedAt": "2025-01-15T10:30:00.000Z",
  "isDirty": false,
  "deletedAt": null
}
completedPhonemes drives the ✓ checkmark and reward unlocking. practicedPhonemes is a lower bar — it unlocks the next station in the learning path without requiring 90% accuracy, so children are never blocked from progressing.

AgeBand Enum

enum AgeBand {
  young('4-5', '4 a 5 años'),
  older('6-7', '6 a 7 años');

  const AgeBand(this.code, this.label);
  final String code;
  final String label;

  static AgeBand fromCode(String code) =>
      values.firstWhere((b) => b.code == code, orElse: () => young);
}
Valuecodelabel
AgeBand.young4-54 a 5 años
AgeBand.older6-76 a 7 años
The age band is informational in the current version — it guides the intended UX pace but does not change which words are presented. fromCode falls back to young if the stored value is unrecognized, making deserialization safe across app updates.

AvatarOption

AvatarOption is a data class, not an enum, to allow the catalog to be extended without changing serialized values. Each avatar has a stable string key (what gets persisted), an emoji (displayed in the UI), and a tint color from VoziTheme.
keyEmojiTint color
fox🦊VoziTheme.peach
bear🐻VoziTheme.sunshine
cat🐱VoziTheme.bubblegum
owl🦉VoziTheme.lavender
fish🐠VoziTheme.teal
starVoziTheme.coral
rabbit🐰VoziTheme.sky
dog🐶VoziTheme.mint
The first six keys (fox, bear, cat, owl, fish, star) are shared with VOZI iOS. rabbit and dog are Android additions; iOS falls back to fox when it encounters an unknown key. Static constructors:
// Preferred: resolve from stored value (handles both key and legacy emoji)
final avatar = AvatarOption.resolve(stored);

// By stable key only
final avatar = AvatarOption.byKey('bear');

// By emoji (legacy paths only)
final avatar = AvatarOption.byEmoji('🐻');
AvatarOption.resolve(String stored) is the canonical deserializer. It first tries to match the stored value as a key, then as an emoji (for profiles created before the key migration in release 8.14B), and finally falls back to fox. This ensures no existing profile ever loses its avatar after an app update.

PracticeWord

PracticeWord wraps a single Spanish word that the child is asked to pronounce. All asset references are derived from the text at runtime — there is no lookup table.
class PracticeWord {
  const PracticeWord(this.text);
  final String text;

  String get imageKey => 'word_${_normalize(text)}';
  String get audioKey => _normalize(text);
}
The normalization function strips accents, lowercases the string, trims whitespace, and replaces spaces with underscores:
Input (text)audioKeyimageKeyAsset paths
"ratón"ratonword_ratonassets/audio/words/raton.mp3 / assets/words/word_raton.png
"sueño"suenoword_suenoassets/audio/words/sueno.mp3 / assets/words/word_sueno.png
"tren"trenword_trenassets/audio/words/tren.mp3 / assets/words/word_tren.png
If assets/audio/words/<audioKey>.mp3 does not exist in the bundle, AudioAssetService.playWord() returns false and the exercise screen falls back to TtsService. If the image asset is missing, the UI shows a placeholder. Neither case crashes the app.

SpeechAttempt

One SpeechAttempt is created for every completed “Speak” interaction — including retries and failed attempts. Records are append-only: once written they are never modified. ProfileRepository enforces a FIFO cap of 500 attempts to prevent unbounded growth in shared_preferences.
FieldTypeDescription
idString (UUID)Stable primary key; shared with the Supabase row if synced
childProfileIdString (UUID)Foreign key to the child’s profile
phonemeCodeStringThe phoneme being practiced (e.g. "R", "BL")
targetWordStringThe word the child was asked to say
recognizedTextStringText returned by on-device STT — local only, never synced
wasCorrectboolPass/fail verdict from WordEvaluator
scoredoubleSimilarity score 0..1 from Levenshtein comparison
createdAtDateTimeUTC timestamp of the attempt
isDirtybooltrue until the attempt has been pushed to Supabase
// JSON shape produced by toJson()
{
  "id": "7f3e4b10-0000-4000-a000-000000000001",
  "childProfileId": "550e8400-e29b-41d4-a716-446655440000",
  "phonemeCode": "R",
  "targetWord": "ratón",
  "recognizedText": "ratón",
  "wasCorrect": true,
  "score": 1.0,
  "createdAt": "2025-01-15T10:32:15.000Z",
  "isDirty": false
}
recognizedText is persisted locally and visible in the adult dashboard so parents can see what the STT heard. It is not included in the Supabase sync payload — the remote practice_attempts table only stores phoneme_code, target_word, was_correct, score, age_band, and created_at.

WordResult

WordResult is a lightweight value object returned by WordEvaluator.evaluate(). It is never persisted — its passed and score fields are immediately mapped to a SpeechAttempt.
class WordResult {
  const WordResult({required this.passed, required this.score});

  /// true if the word was spoken correctly by all three evaluation rules.
  final bool passed;

  /// Levenshtein similarity 0..1; supporting metric, not the sole judge.
  final double score;
}
The evaluator applies three rules in sequence: (1) the target word appears as an exact token in the transcription, (2) the phoneme sound is preserved in the normalized word, and (3) the similarity score meets the 0.8 threshold. All three must pass for WordResult.passed to be true.

Local Persistence (ProfileRepository)

ProfileRepository is a thin wrapper around shared_preferences with four stable storage keys:
KeyTypeContents
vozi_profiles_v1String (JSON)Full list of ChildProfile objects
vozi_selected_id_v1StringUUID of the currently active child
vozi_attempts_v1String (JSON)List of up to 500 SpeechAttempt objects
vozi_sync_last_at_v1StringISO-8601 timestamp of the last successful Supabase sync
All reads and writes are async but non-blocking for the UI — ProfileStore calls _persist() and _persistAttempts() without await in mutation methods, so the UI rebuilds immediately while the disk write happens concurrently. ProfileStore wraps ProfileRepository with ChangeNotifier for reactive UI. Widgets read data through ProfileScope.of(context) and rebuild automatically when the store calls notifyListeners().
// Reading from the store in any widget
final store = ProfileScope.of(context);
final profile = store.selected;
final attempts = store.attemptsFor(profile.id);
final summaries = store.phonemeSummaries(profile.id);

Build docs developers (and LLMs) love