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.

VOZI treats the device as the source of truth. Cloud sync is manual — the adult taps a button to trigger it — and it always runs pull-first: remote data is downloaded and merged before any local changes are pushed. If sync fails at any point, all data remains safe on the device and nothing is lost. Supabase is a convenience layer on top of a fully offline-capable app, not a dependency.

Authentication (AuthService)

Authentication in VOZI is adults-only. Children never log in and have no account in Supabase. The AuthService class manages the entire auth lifecycle as a ChangeNotifier, so any widget that depends on session state rebuilds automatically when the session changes. AuthService initializes by calling SupabaseClientProvider.client. If the client is null (no .env configuration), it sets its status to signedOut immediately and every subsequent operation returns a friendly error message without crashing.

Session State

AuthService exposes the following observable properties:
PropertyTypeDescription
statusSessionStatusCurrent session state: unknown, signedOut, or signedIn.
isSignedInbooltrue when status == SessionStatus.signedIn.
emailString?The signed-in adult’s email address, or null if not signed in.
isWorkingbooltrue while an auth operation is in progress. Use this to disable buttons.
errorMessageString?A user-friendly error string, or null. Never contains credentials.
infoMessageString?An informational message (e.g. “Check your email to confirm your account”), or null.
isConfiguredbooltrue if a Supabase client is available in this installation.

Methods

signIn({required String email, required String password}) Calls supabase.auth.signInWithPassword. The email is normalized (trimmed, lowercased) before the call. On success, the Supabase auth state listener fires and _apply(session) sets status to signedIn. On failure, errorMessage is set to a translated, friendly string — raw Supabase error messages are never shown directly. signUp({required String email, required String password}) Calls supabase.auth.signUp. If the Supabase project requires email confirmation, res.session will be null and infoMessage is set to prompt the adult to check their inbox. If confirmation is disabled, the session is returned immediately and the adult is signed in. signOut() Calls supabase.auth.signOut. Local data — child profiles, progress, attempts — is not affected. Everything that was stored on the device stays there. clearMessages() Clears errorMessage and infoMessage. Call this when the user switches between sign-in and sign-up mode to avoid stale messages.

Example Usage

// Listen to auth state in a widget
final auth = AuthScope.of(context); // ChangeNotifier via InheritedWidget

// Sign in
await auth.signIn(email: emailController.text, password: passwordController.text);

if (auth.isSignedIn) {
  // Session is active — sync is now available
} else if (auth.errorMessage != null) {
  // Show auth.errorMessage to the adult
} else if (auth.infoMessage != null) {
  // Show auth.infoMessage (e.g. "Check your email")
}

// Sign out (local data is untouched)
await auth.signOut();
Children never log in. The SessionStatus enum and everything in AuthService applies exclusively to the adult account. Child profiles live entirely in local storage, and their data is only pushed to Supabase when the adult explicitly initiates a sync while signed in.

Sync Flow (SyncService)

SyncService.sync() is the single entry point for cloud synchronization. It returns a SyncResult that describes what happened in enough detail for both the UI and debug logging. The sync order is always pull first, then push. This ensures that if the adult has used the app on another device and their data is already in Supabase, that data is merged into the local store before local changes are uploaded. A failure in one table does not abort the others — errors are isolated per table, collected into debugDetail, and surfaced in the final SyncResult without exposing raw database error messages to the user.

Pull Phase

The pull phase fetches children and sound_progress from Supabase. RLS limits the response to rows that belong to the signed-in adult, so no explicit adult_id filter is needed in the query.
// children pulled from Supabase
final remoteChildren = await client
    .from('children')
    .select('id, name, age, avatar, total_points');

// sound_progress pulled for all children
final remoteProgress = await client
    .from('sound_progress')
    .select('child_id, sound_code, is_completed, attempts_count');
After fetching, SyncService groups the progress rows by child_id and calls ProfileStore.applyRemoteChild() for each remote child. The children table has no updated_at or deleted_at columns, so the merge rule is conservative: a local child with isDirty = true wins over the remote version. If the child does not exist locally at all, it is created from the remote data. pulled is incremented for each profile that was created or updated. If the children pull fails, the sync does not attempt to merge sound_progress either (there are no local IDs to match against). The error is recorded and the push phase still runs.

Push Phase

The push phase uploads dirty local data in three steps, each independently guarded:
  1. Children — all local child profiles where isDirty == true and isDeleted == false are upserted to children using onConflict: 'id'. Deleted profiles are not pushed; they are only tombstoned locally.
  2. Sound progress — if the children upsert succeeded, aggregated sound_progress rows are built from each dirty child’s attempts and upserted using onConflict: 'child_id,sound_code'. After a successful push, those children are marked as synced locally.
  3. Practice attempts — all local attempts where isDirty == true are upserted to practice_attempts with onConflict: 'id' and ignoreDuplicates: true. Because the table is append-only, duplicate IDs are silently skipped rather than overwriting existing rows. Successfully pushed attempts are marked as synced.
// Push children
await client.from('children').upsert(childRows, onConflict: 'id');

// Push aggregated sound progress
await client.from('sound_progress').upsert(spRows, onConflict: 'child_id,sound_code');

// Push practice attempts (append-only, safe to re-push)
await client.from('practice_attempts').upsert(
  attemptRows,
  onConflict: 'id',
  ignoreDuplicates: true,
);
After the push phase, _store.purgeSyncedTombstones() is called to clean up deleted profiles that have already been removed from the local store.

SyncResult and SyncOutcome

SyncService.sync() always returns a SyncResult. The fields are:
FieldTypeDescription
outcomeSyncOutcomeOne of six enum values describing the overall result (see table below).
messageStringA user-friendly message suitable for display in the UI. Never contains credentials or raw DB errors.
pushedintNumber of local records successfully written to Supabase. Defaults to 0.
pulledintNumber of remote profiles applied to the local store. Defaults to 0.
debugDetailString?Technical detail for debug logging only. null when there are no errors. Never shown to the user.
okboolGetter. true only when outcome == SyncOutcome.success.
recoveredboolGetter. true when outcome is success or partial — use this to decide whether to update a “last synced” timestamp.
SyncResult.outcome is one of six values that the UI maps to a user-facing message and icon:
OutcomeMeaning
successPull and push both completed without errors.
partialPull succeeded (data was recovered from the cloud) but one or more push steps failed. Local changes are still saved on the device.
notSignedInNo active adult session. The adult must sign in before syncing.
notConfiguredNo Supabase client in this installation (.env is missing or invalid).
offlineA SocketException or network error was detected. Data is safe locally.
errorA non-network error occurred and the pull also failed. Data is safe locally.

DTO Mapping (SyncDtos)

SyncDtos is a stateless utility class that converts local model objects into the exact column maps expected by each Supabase table. It has three static methods: SyncDtos.childRow(ChildProfile c, String adultId) Returns a Map<String, dynamic> for an upsert into the children table. The adult_id is injected from the authenticated session — it is not stored in the local model. Because the children table has no updated_at column, the local updatedAt timestamp is written to created_at as a sync watermark.
static Map<String, dynamic> childRow(ChildProfile c, String adultId) => {
  'id': c.id,
  'adult_id': adultId,
  'name': c.name,
  'age': c.ageBand.code,       // '4-5' | '6-7'
  'avatar': c.avatar.key,      // stable key, e.g. 'fox'
  'total_points': c.points,
  'created_at': c.updatedAt.toUtc().toIso8601String(),
};
SyncDtos.soundProgressRows(ChildProfile c, List<SpeechAttempt> attempts) Returns a List<Map<String, dynamic>> for upserting into sound_progress — one row for every sound that has either been practiced or completed. Metrics are aggregated from the child’s local attempts: attempts_count, correct_count, and best_score are derived from attempts; is_completed comes from c.completedPhonemes. The conflict target is the natural key (child_id, sound_code).
static List<Map<String, dynamic>> soundProgressRows(
  ChildProfile c,
  List<SpeechAttempt> attempts,
) {
  // Unions sounds-with-attempts and completed sounds.
  // Each row: child_id, sound_code, attempts_count, correct_count,
  //           best_score, is_completed, updated_at
}
SyncDtos.practiceAttemptRow(SpeechAttempt a) Returns a Map<String, dynamic> for an append-only upsert into practice_attempts. Only safe metrics are included. recognizedText, audio, and raw transcription are explicitly excluded and have no column in the remote table.
static Map<String, dynamic> practiceAttemptRow(SpeechAttempt a) => {
  'id': a.id,
  'child_id': a.childProfileId,
  'sound_code': a.phonemeCode,
  'target_word': a.targetWord,
  'score': a.score.clamp(0.0, 1.0).toDouble(),
  'was_correct': a.wasCorrect,
  'created_at': a.createdAt.toUtc().toIso8601String(),
  // NEVER: recognizedText / raw transcription / audio.
};

What Is and Isn’t Synced

VOZI never transmits audio recordings or speech transcripts over the network. There is no column in any Supabase table that could hold them. This is a deliberate privacy guarantee enforced at both the schema level and the SyncDtos mapping layer.
DataSynced?Notes
Child profilesName (alias), age band, avatar key, total points
Sound progressAttempts count, correct count, best score, completion status
Practice attempts✅ (metrics only)Phoneme code, target word, score (0–1), pass/fail flag — no audio, no transcript
Premium statusStored in the premium table; synced separately by PremiumStore (not SyncService)
Audio recordingsProcessed on-device only; never stored or transmitted
Speech transcriptsDiscarded after scoring; never stored or transmitted

Offline Behavior

SyncService detects network failures by inspecting the exception type and message. A SocketException, or any error whose string representation contains socket, network, failed host, clientexception, or connection, is classified as an offline condition:
bool _looksOffline(Object e) {
  if (e is SocketException) return true;
  final s = e.toString().toLowerCase();
  return s.contains('socket') ||
      s.contains('network') ||
      s.contains('failed host') ||
      s.contains('clientexception') ||
      s.contains('connection');
}
If an offline condition is detected and the pull phase also failed, sync() returns SyncOutcome.offline. All local data — profiles, progress, attempts — is unaffected. The next sync attempt will pick up where the previous one left off, because dirty flags are only cleared after a confirmed successful write.

Premium Sync

PremiumStore has its own lightweight sync path that runs independently of SyncService. It connects directly to the premium table to read and write the adult’s entitlement status. On sign-in: PremiumStore._observeAccount() listens to onAuthStateChange. When a session becomes active, refreshFromAccount() is called automatically. It reads the is_premium column from the adult’s row in the premium table and applies it locally. If no row exists yet, it creates one using the current local value (so a local demo activation is preserved when the adult first links an account). On toggle: activateDemo() and deactivateDemo() apply the change locally and optimistically, then attempt to upsert the new value to Supabase. If the upsert fails, the local value is kept as a fallback and PremiumWriteOutcome.accountFailed is returned — the app continues to work normally. Source tracking: PremiumStore.source reports whether the current value came from localDemo (no session, or a network error) or account (successfully read from or written to Supabase).
If you set up VOZI on a new device and want to restore a child’s data, sign in to the adult account first and then tap Sync from the account screen. The pull phase will download all child profiles and their sound progress before you create any new local profiles, preventing duplicates.

Build docs developers (and LLMs) love