diff --git a/app.json b/app.json
index 70436504..5e877e4c 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "BaseAut",
"slug": "baseaut",
- "version": "1.1.2",
+ "version": "1.1.3",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "baseaut",
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 086403d6..e83d7f64 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -17,6 +17,7 @@ import {
import { SessionGlobalProvider } from "@/features/sessions/contexts/session-global-context";
import { GlobalToastProvider } from "@/components/global-toast";
import { GlobalSessionWidget } from "@/features/sessions/components/global-session-widget";
+import { AppVersionGate } from "@/features/settings/components/app-version-gate";
import { ThemeProvider } from "@/features/settings/contexts/theme-context";
import { I18nProvider } from "@/features/settings/contexts/i18n-context";
import { TutorialProvider } from "@/features/tutorial/contexts/tutorial-context";
@@ -44,6 +45,9 @@ SplashScreen.preventAutoHideAsync();
* UI (stale measurements survive until a full restart), so the key forces a
* clean remount when the user returns. Providers sit above the key, so global
* session state survives the remount.
+ *
+ * {@link AppVersionGate} is mounted alongside the stack so the update block and
+ * the release notes cover every route, the login screen included.
*/
export default function RootLayout() {
const [loaded, error] = useFonts({
@@ -86,12 +90,14 @@ export default function RootLayout() {
{stack}
+
) : (
{stack}
+
)}
diff --git a/features/settings/components/app-version-gate.tsx b/features/settings/components/app-version-gate.tsx
new file mode 100644
index 00000000..6b20b0c3
--- /dev/null
+++ b/features/settings/components/app-version-gate.tsx
@@ -0,0 +1,44 @@
+import { ReleaseNotesModal } from "@/features/settings/components/release-notes-modal";
+import { UpdateRequiredModal } from "@/features/settings/components/update-required-modal";
+import { useAppVersion } from "@/features/settings/hooks/use-app-version";
+import React from "react";
+
+/**
+ * Mounts the app-wide version dialogs: the blocking update notice when the
+ * installed build is behind the newest published release, and the release notes
+ * the first time the app opens on a version this device has not run before.
+ *
+ * @remarks
+ * Lives at the root layout, above the navigation stack, so the block applies on
+ * every route — including the login screen, since an outdated build must not be
+ * usable at all. Renders nothing while the app is up to date, and never blocks
+ * when the catalog cannot be read (see {@link useAppVersion}).
+ */
+export function AppVersionGate() {
+ const {
+ installedVersion,
+ requiredRelease,
+ pendingRelease,
+ acknowledgeRelease,
+ } = useAppVersion();
+
+ if (installedVersion && requiredRelease) {
+ return (
+
+ );
+ }
+
+ if (pendingRelease) {
+ return (
+
+ );
+ }
+
+ return null;
+}
diff --git a/features/settings/components/release-notes-modal.tsx b/features/settings/components/release-notes-modal.tsx
new file mode 100644
index 00000000..e3742382
--- /dev/null
+++ b/features/settings/components/release-notes-modal.tsx
@@ -0,0 +1,90 @@
+import { AppModal } from "@/components/app-modal";
+import { DefaultButton } from "@/components/default-button";
+import type { AppRelease } from "@/features/settings/hooks/use-app-version";
+import { useI18n } from "@/features/settings/contexts/i18n-context";
+import { useThemeColors } from "@/features/settings/contexts/theme-context";
+import { Sparkles } from "lucide-react-native";
+import React from "react";
+import { ScrollView, Text, View } from "react-native";
+
+/** Props for {@link ReleaseNotesModal}. */
+type ReleaseNotesModalProps = {
+ /** Release whose changes are being announced. */
+ release: AppRelease;
+ /** Marks the release as seen so it is not announced again. */
+ onAcknowledge: () => void;
+};
+
+/** A single change item rendered as a bullet. */
+function NoteItem({ text }: { text: string }) {
+ return (
+
+ {"•"}
+ {text}
+
+ );
+}
+
+/**
+ * Dialog announcing what changed, shown the first time the app opens on a
+ * version this device has not run before.
+ *
+ * @remarks
+ * The notes come straight from the `versoes_app` row and are shown as written,
+ * following the same rule as seeded form content: the stored value is authored
+ * once by the team and is never rewritten at display time.
+ *
+ * Dismissing is what marks the version as seen, so a user who closes the app
+ * before acknowledging still gets the notes on the next launch.
+ */
+export function ReleaseNotesModal({
+ release,
+ onAcknowledge,
+}: ReleaseNotesModalProps) {
+ const { t } = useI18n();
+ const colors = useThemeColors();
+
+ return (
+
+
+
+
+
+
+
+ {t("update.notes.title")}
+
+ {release.versao}
+
+
+
+
+ {t("update.notes.subtitle")}
+
+
+
+ {release.notas.map((nota, index) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/features/settings/components/update-required-modal.tsx b/features/settings/components/update-required-modal.tsx
new file mode 100644
index 00000000..2bcc3861
--- /dev/null
+++ b/features/settings/components/update-required-modal.tsx
@@ -0,0 +1,131 @@
+import { AppModal } from "@/components/app-modal";
+import { DefaultButton } from "@/components/default-button";
+import type { AppRelease } from "@/features/settings/hooks/use-app-version";
+import { useI18n } from "@/features/settings/contexts/i18n-context";
+import { useThemeColors } from "@/features/settings/contexts/theme-context";
+import * as Linking from "expo-linking";
+import { Download } from "lucide-react-native";
+import React, { useState } from "react";
+import { ScrollView, Text, View } from "react-native";
+
+/** Props for {@link UpdateRequiredModal}. */
+type UpdateRequiredModalProps = {
+ /** Version of the build currently running on the device. */
+ installedVersion: string;
+ /** Newest published release the user has to move to. */
+ release: AppRelease;
+};
+
+/** A single change item rendered as a bullet. */
+function NoteItem({ text }: { text: string }) {
+ return (
+
+ {"•"}
+ {text}
+
+ );
+}
+
+/**
+ * Blocking dialog shown when the installed build is older than the newest
+ * published release. It states both versions, lists what the new release
+ * brings, and opens the Google Drive folder the APK is distributed from.
+ *
+ * @remarks
+ * The dialog is deliberately inescapable: there is no close affordance, the
+ * backdrop ignores presses, and `onRequestClose` swallows the Android back
+ * button, so the app stays unusable until the user updates. That is the whole
+ * point — the app ships as an APK outside any store, so nothing else stops a
+ * clinic from recording sessions on a build whose bugs were already fixed.
+ *
+ * The download folder comes from the release row rather than a constant: a
+ * blocked user is by definition running an old build, so a link compiled into
+ * the app could never be corrected if the folder moved.
+ */
+export function UpdateRequiredModal({
+ installedVersion,
+ release,
+}: UpdateRequiredModalProps) {
+ const { t } = useI18n();
+ const colors = useThemeColors();
+ const [linkFailed, setLinkFailed] = useState(false);
+
+ const handleDownload = async () => {
+ if (!release.url_download) return;
+ try {
+ await Linking.openURL(release.url_download);
+ } catch {
+ setLinkFailed(true);
+ }
+ };
+
+ return (
+ {}}>
+
+
+
+
+
+ {t("update.required.title")}
+
+
+
+
+ {t("update.required.message")}
+
+
+
+
+
+ {t("update.required.installed")}
+
+ {installedVersion}
+
+
+
+ {t("update.required.latest")}
+
+ {release.versao}
+
+
+
+ {release.notas.length > 0 && (
+
+
+ {t("update.required.changes")}
+
+
+ {release.notas.map((nota, index) => (
+
+ ))}
+
+
+ )}
+
+ {release.url_download ? (
+
+ ) : (
+
+ {t("update.required.noLink")}
+
+ )}
+
+ {linkFailed && (
+
+ {t("update.required.linkError")}
+
+ )}
+
+
+
+ );
+}
diff --git a/features/settings/constants/translations.ts b/features/settings/constants/translations.ts
index 20a8872c..2461c9e2 100644
--- a/features/settings/constants/translations.ts
+++ b/features/settings/constants/translations.ts
@@ -1121,7 +1121,18 @@ export type TranslationKey =
| "mock.protocol"
| "mock.helpAutonomyText"
| "session.mabcAssessment"
- | "analysis.compareLoadError";
+ | "analysis.compareLoadError"
+ | "update.required.title"
+ | "update.required.message"
+ | "update.required.installed"
+ | "update.required.latest"
+ | "update.required.changes"
+ | "update.required.action"
+ | "update.required.noLink"
+ | "update.required.linkError"
+ | "update.notes.title"
+ | "update.notes.subtitle"
+ | "update.notes.action";
/** Translation tables keyed by locale then message key. */
export const translations: Record> = {
@@ -2244,6 +2255,19 @@ export const translations: Record> = {
"mock.helpAutonomyText": "A autonomia do aluno aumentou ao longo das sessões.",
"session.mabcAssessment": "Avaliação MABC-2",
"analysis.compareLoadError": "Erro ao carregar comparação de desempenho.",
+ "update.required.title": "Atualize o BaseAut",
+ "update.required.message":
+ "Esta versão ficou para trás. Baixe a versão mais recente para voltar a usar o app.",
+ "update.required.installed": "Sua versão",
+ "update.required.latest": "Versão disponível",
+ "update.required.changes": "O que muda",
+ "update.required.action": "Baixar atualização",
+ "update.required.noLink":
+ "A pasta de download ainda não foi publicada. Peça o link à equipe.",
+ "update.required.linkError": "Não foi possível abrir a pasta de download.",
+ "update.notes.title": "Novidades da versão",
+ "update.notes.subtitle": "Veja o que mudou nesta atualização.",
+ "update.notes.action": "Entendi",
},
en: {
"common.save": "Save",
@@ -3362,5 +3386,18 @@ export const translations: Record> = {
"mock.helpAutonomyText": "The student's autonomy increased across the sessions.",
"session.mabcAssessment": "MABC-2 Assessment",
"analysis.compareLoadError": "Failed to load performance comparison.",
+ "update.required.title": "Update BaseAut",
+ "update.required.message":
+ "This version is out of date. Download the latest one to keep using the app.",
+ "update.required.installed": "Your version",
+ "update.required.latest": "Available version",
+ "update.required.changes": "What changes",
+ "update.required.action": "Download update",
+ "update.required.noLink":
+ "The download folder has not been published yet. Ask the team for the link.",
+ "update.required.linkError": "Could not open the download folder.",
+ "update.notes.title": "What's new",
+ "update.notes.subtitle": "Here is what changed in this update.",
+ "update.notes.action": "Got it",
},
};
diff --git a/features/settings/hooks/use-app-version.ts b/features/settings/hooks/use-app-version.ts
new file mode 100644
index 00000000..a122adb0
--- /dev/null
+++ b/features/settings/hooks/use-app-version.ts
@@ -0,0 +1,138 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import Constants from "expo-constants";
+import { useCallback, useEffect, useState } from "react";
+import { supabase } from "@/lib/supabase";
+
+const STORAGE_KEY = "@baseaut/last-seen-version";
+
+const VERSION_PATTERN = /^\d+(\.\d+)*$/;
+
+/** A published release read from the `versoes_app` catalog. */
+export type AppRelease = {
+ /** Version string mirroring `version` in `app.json`. */
+ versao: string;
+ /** Changes introduced by this release, in display order. */
+ notas: string[];
+ /** Google Drive folder the APK is downloaded from, when already published. */
+ url_download: string | null;
+};
+
+/** State returned by {@link useAppVersion}. */
+type AppVersionState = {
+ /** Version of the running build, or `null` when it cannot be read. */
+ installedVersion: string | null;
+ /** Newest published release, set only while an update is required. */
+ requiredRelease: AppRelease | null;
+ /** Release whose notes have not been shown on this device yet. */
+ pendingRelease: AppRelease | null;
+ /** Marks the installed version as seen and dismisses the notes. */
+ acknowledgeRelease: () => void;
+};
+
+/**
+ * Compares two dot-separated version strings.
+ *
+ * @returns A negative number when `a` precedes `b`, zero when they match, and a
+ * positive number when `a` is newer.
+ * @remarks Each segment is compared numerically rather than lexicographically,
+ * so `1.1.10` correctly sorts after `1.1.9`.
+ */
+function compareVersions(a: string, b: string): number {
+ const left = a.split(".").map(Number);
+ const right = b.split(".").map(Number);
+ const length = Math.max(left.length, right.length);
+
+ for (let index = 0; index < length; index += 1) {
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
+ if (difference !== 0) return difference;
+ }
+ return 0;
+}
+
+/**
+ * Checks the running build against the `versoes_app` catalog on mount, telling
+ * the caller whether the app must be updated before it can be used and whether
+ * the notes for the installed version still need to be shown.
+ *
+ * @remarks
+ * Every failure path is deliberately permissive: an unreadable build version, a
+ * network error, an empty catalog or a malformed version string all leave the
+ * app unblocked. The check exists to nudge users off stale APKs — locking a
+ * clinic out of an ongoing session because a request failed would be far worse
+ * than letting an outdated build through.
+ *
+ * The catalog is read whole rather than filtered server-side: it holds one tiny
+ * row per release, and the same rows answer both questions (which release is
+ * newest, and what changed in the installed one).
+ *
+ * A required update takes precedence over the release notes, and the seen
+ * marker is only written once the user acknowledges the notes — so a user who
+ * is blocked still gets the notes after updating. A version that carries no
+ * notes is marked as seen straight away, so it is not re-checked on every
+ * launch for a dialog that would render empty.
+ */
+export function useAppVersion(): AppVersionState {
+ const installedVersion = Constants.expoConfig?.version ?? null;
+ const [requiredRelease, setRequiredRelease] = useState(null);
+ const [pendingRelease, setPendingRelease] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const check = async () => {
+ if (!installedVersion || !VERSION_PATTERN.test(installedVersion)) return;
+
+ const { data, error } = await supabase
+ .from("versoes_app")
+ .select("versao, notas, url_download")
+ .eq("ativo", true);
+
+ if (cancelled || error || !data || data.length === 0) return;
+
+ const releases = (data as AppRelease[])
+ .filter((release) => VERSION_PATTERN.test(release.versao))
+ .sort((a, b) => compareVersions(b.versao, a.versao));
+
+ const latest = releases[0];
+ if (!latest) return;
+
+ if (compareVersions(installedVersion, latest.versao) < 0) {
+ setRequiredRelease(latest);
+ return;
+ }
+
+ const seenVersion = await AsyncStorage.getItem(STORAGE_KEY);
+ if (cancelled || seenVersion === installedVersion) return;
+
+ const current = releases.find(
+ (release) => release.versao === installedVersion,
+ );
+
+ if (!current || current.notas.length === 0) {
+ AsyncStorage.setItem(STORAGE_KEY, installedVersion);
+ return;
+ }
+
+ setPendingRelease(current);
+ };
+
+ check();
+ return () => {
+ cancelled = true;
+ };
+ }, [installedVersion]);
+
+ const acknowledgeRelease = useCallback(() => {
+ setPendingRelease(null);
+ if (installedVersion) {
+ AsyncStorage.setItem(STORAGE_KEY, installedVersion);
+ }
+ }, [installedVersion]);
+
+ return {
+ installedVersion,
+ requiredRelease,
+ pendingRelease,
+ acknowledgeRelease,
+ };
+}
diff --git a/supabase/migrations/20260820120000_versoes_app.sql b/supabase/migrations/20260820120000_versoes_app.sql
new file mode 100644
index 00000000..23404641
--- /dev/null
+++ b/supabase/migrations/20260820120000_versoes_app.sql
@@ -0,0 +1,76 @@
+-- ════════════════════════════════════════════════════════════════════
+-- Versões do app: bloqueio de versão desatualizada e novidades da versão
+-- ════════════════════════════════════════════════════════════════════
+-- O app é distribuído por APK numa pasta do Google Drive, fora de qualquer
+-- loja de aplicativos. Não existe, portanto, atualização automática nem aviso
+-- do sistema: um usuário pode passar meses numa versão antiga sem perceber,
+-- reportando como bug algo que já foi corrigido (é o que a coluna
+-- feedbacks.app_version vinha revelando).
+--
+-- Esta tabela é o catálogo de versões publicadas. O app lê a mais recente ao
+-- abrir e, se a versão instalada for anterior, bloqueia o uso até que o
+-- usuário atualize. Também é daqui que sai a lista de alterações mostrada na
+-- primeira abertura de cada versão nova.
+--
+-- LEITURA PÚBLICA (inclusive anônima): o bloqueio precisa valer já na tela de
+-- login, antes de existir sessão. O conteúdo é só número de versão, notas de
+-- lançamento e um link público do Drive — nada sensível. Escrita é exclusiva
+-- da equipe, pelo painel do Supabase.
+-- ════════════════════════════════════════════════════════════════════
+
+CREATE TABLE public.versoes_app (
+ -- Espelha exatamente o "version" de app.json ("maior.menor.correção").
+ versao TEXT PRIMARY KEY CHECK (versao ~ '^\d+\.\d+\.\d+$'),
+ -- Alterações da versão, uma por item, na ordem em que devem ser exibidas.
+ notas TEXT[] NOT NULL DEFAULT '{}',
+ -- Pasta do Google Drive de onde o usuário baixa o APK. Fica no banco, e não
+ -- no código, porque quem está bloqueado roda uma versão antiga: um link
+ -- embutido no app não teria como ser corrigido depois que a pasta mudasse.
+ url_download TEXT,
+ data_lancamento TIMESTAMPTZ NOT NULL DEFAULT now(),
+ -- Convenção de soft delete do projeto: uma versão retirada de circulação
+ -- deixa de contar como a mais recente, sem sumir do histórico.
+ ativo BOOLEAN NOT NULL DEFAULT TRUE
+);
+
+COMMENT ON TABLE public.versoes_app IS
+ 'Catálogo de versões publicadas do app. Define a versão mínima exigida e as notas de lançamento exibidas na primeira abertura de cada versão.';
+
+CREATE INDEX idx_versoes_app_lancamento ON public.versoes_app (data_lancamento DESC);
+
+-- ── RLS: leitura para todos; escrita só pela equipe (service role) ───────────
+ALTER TABLE public.versoes_app ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "versoes_app: leitura pública"
+ ON public.versoes_app
+ FOR SELECT
+ USING (TRUE);
+
+-- O papel anônimo precisa do privilégio explícito porque a checagem roda antes
+-- do login. Sem SELECT, a policy acima nunca chega a ser avaliada.
+GRANT SELECT ON public.versoes_app TO anon, authenticated;
+
+-- ── Versão corrente ──────────────────────────────────────────────────────────
+-- url_download entra nula de propósito: o link da pasta é preenchido pela
+-- equipe no painel, junto com a publicação do APK. Enquanto estiver nulo o app
+-- ainda avisa que há versão nova, mas não oferece o botão de download.
+INSERT INTO public.versoes_app (versao, notas) VALUES (
+ '1.1.3',
+ ARRAY[
+ 'Aviso de atualização: o app passa a avisar quando existe uma versão mais nova e leva direto à pasta de download.',
+ 'Novidades da versão: na primeira vez que você abre uma versão nova, o app mostra o que mudou.',
+ 'O nível de suporte do TEA agora aceita "Indefinido", para criança ainda sem laudo.',
+ 'Correção no preenchimento do ATA: os indicadores marcados passam a ser gravados corretamente.'
+ ]
+);
+
+-- ── Ritual de release ────────────────────────────────────────────────────────
+-- A cada versão publicada, a equipe insere uma linha pela Table Editor:
+--
+-- INSERT INTO public.versoes_app (versao, notas, url_download)
+-- VALUES ('1.1.4', ARRAY['Primeira alteração.', 'Segunda alteração.'],
+-- 'https://drive.google.com/drive/folders/...');
+--
+-- A linha só deve ser inserida depois que o APK correspondente estiver na
+-- pasta do Drive: a partir dela, todo usuário em versão anterior fica
+-- bloqueado.