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.