Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -86,12 +90,14 @@ export default function RootLayout() {
<View key={`metrics-${fontScale}-${scale}`} style={{ flex: 1 }}>
<TutorialTapGuard>{stack}</TutorialTapGuard>
<GlobalSessionWidget />
<AppVersionGate />
</View>
</GestureHandlerRootView>
) : (
<View key={`metrics-${fontScale}-${scale}`} style={{ flex: 1 }}>
{stack}
<GlobalSessionWidget />
<AppVersionGate />
</View>
)}
</SessionSimulationProvider>
Expand Down
44 changes: 44 additions & 0 deletions features/settings/components/app-version-gate.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<UpdateRequiredModal
installedVersion={installedVersion}
release={requiredRelease}
/>
);
}

if (pendingRelease) {
return (
<ReleaseNotesModal
release={pendingRelease}
onAcknowledge={acknowledgeRelease}
/>
);
}

return null;
}
90 changes: 90 additions & 0 deletions features/settings/components/release-notes-modal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className="flex-row gap-2">
<Text className="text-default-2 text-muted">{"•"}</Text>
<Text className="flex-1 text-default-2 text-muted">{text}</Text>
</View>
);
}

/**
* 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 (
<AppModal
visible
transparent
animationType="fade"
onRequestClose={onAcknowledge}
>
<View className="flex-1 items-center justify-center bg-black/60 px-4">
<View className="w-[92%] max-w-[400px] gap-5 rounded-xl border border-outline bg-level2 p-6">
<View className="flex-row items-center gap-4">
<Sparkles size={30} color={colors.secondary} />
<View className="flex-1">
<Text className="text-header-2 text-content">
{t("update.notes.title")}
</Text>
<Text className="text-default-3 text-muted">{release.versao}</Text>
</View>
</View>

<Text className="text-default-1 leading-5 text-muted">
{t("update.notes.subtitle")}
</Text>

<ScrollView
className="max-h-64"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ gap: 6 }}
>
{release.notas.map((nota, index) => (
<NoteItem key={`${release.versao}-${index}`} text={nota} />
))}
</ScrollView>

<DefaultButton
label={t("update.notes.action")}
onPress={onAcknowledge}
sizeClass="w-full h-11"
className="rounded-[12px]"
/>
</View>
</View>
</AppModal>
);
}
131 changes: 131 additions & 0 deletions features/settings/components/update-required-modal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className="flex-row gap-2">
<Text className="text-default-2 text-muted">{"•"}</Text>
<Text className="flex-1 text-default-2 text-muted">{text}</Text>
</View>
);
}

/**
* 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 (
<AppModal visible transparent animationType="fade" onRequestClose={() => {}}>
<View className="flex-1 items-center justify-center bg-black/70 px-4">
<View className="w-[92%] max-w-[400px] gap-5 rounded-xl border border-outline bg-level2 p-6">
<View className="flex-row items-center gap-4">
<Download size={30} color={colors.primary} />
<Text className="flex-1 text-header-2 text-content">
{t("update.required.title")}
</Text>
</View>

<Text className="text-default-1 leading-5 text-muted">
{t("update.required.message")}
</Text>

<View className="gap-1 rounded-2xl border border-outline bg-level1 p-4">
<View className="flex-row justify-between gap-3">
<Text className="text-default-2 text-muted">
{t("update.required.installed")}
</Text>
<Text className="text-default-2 text-content">{installedVersion}</Text>
</View>
<View className="flex-row justify-between gap-3">
<Text className="text-default-2 text-muted">
{t("update.required.latest")}
</Text>
<Text className="text-default-2 text-primary">{release.versao}</Text>
</View>
</View>

{release.notas.length > 0 && (
<View className="gap-2">
<Text className="text-header-3 text-content">
{t("update.required.changes")}
</Text>
<ScrollView
className="max-h-40"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ gap: 6 }}
>
{release.notas.map((nota, index) => (
<NoteItem key={`${release.versao}-${index}`} text={nota} />
))}
</ScrollView>
</View>
)}

{release.url_download ? (
<DefaultButton
label={t("update.required.action")}
onPress={handleDownload}
sizeClass="w-full h-11"
className="rounded-[12px]"
/>
) : (
<Text className="text-center text-default-2 text-extra">
{t("update.required.noLink")}
</Text>
)}

{linkFailed && (
<Text className="text-center text-default-3 text-error">
{t("update.required.linkError")}
</Text>
)}
</View>
</View>
</AppModal>
);
}
39 changes: 38 additions & 1 deletion features/settings/constants/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Locale, Record<TranslationKey, string>> = {
Expand Down Expand Up @@ -2244,6 +2255,19 @@ export const translations: Record<Locale, Record<TranslationKey, string>> = {
"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",
Expand Down Expand Up @@ -3362,5 +3386,18 @@ export const translations: Record<Locale, Record<TranslationKey, string>> = {
"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",
},
};
Loading
Loading