diff --git a/app/(onboarding)/services/ed/credentials.tsx b/app/(onboarding)/services/ed/credentials.tsx
index 495109a9..337c19e3 100644
--- a/app/(onboarding)/services/ed/credentials.tsx
+++ b/app/(onboarding)/services/ed/credentials.tsx
@@ -1,4 +1,4 @@
-
+import { Papicons } from "@getpapillon/papicons";
import { Client, DoubleAuthQuestions, DoubleAuthResult, Require2FA } from "@blockshub/blocksdirecte";
import { useTheme } from "@react-navigation/native";
import { router } from "expo-router";
@@ -8,47 +8,168 @@ import {
Alert,
Keyboard,
KeyboardAvoidingView,
- Modal,
Platform,
Pressable,
View,
} from "react-native";
-import Reanimated, {
- FadeInDown,
- FadeOutUp,
- useSharedValue,
- withTiming,
-} from "react-native-reanimated";
+import { useSharedValue, withTiming } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import OnboardingBackButton from "@/components/onboarding/OnboardingBackButton";
-import OnboardingInput from "@/components/onboarding/OnboardingInput";
-import OnboardingScrollingFlatList from "@/components/onboarding/OnboardingScrollingFlatList";
import { useAccountStore } from "@/stores/account";
import { Account, Services } from "@/stores/account/types";
-import { useAlert } from "@/ui/components/AlertProvider";
import AnimatedPressable from "@/ui/components/AnimatedPressable";
-import Button from "@/ui/components/Button";
+import { Dynamic } from "@/ui/components/Dynamic";
+import SheetModal from "@/ui/components/SheetModal";
import Stack from "@/ui/components/Stack";
-import Typography from "@/ui/components/Typography";
+import Button from "@/ui/new/Button";
+import Divider from "@/ui/new/Divider";
+import List from "@/ui/new/List";
+import Typography from "@/ui/new/Typography";
+import { PapillonZoomIn, PapillonZoomOut } from "@/ui/utils/Transition";
+import adjust from "@/utils/adjustColor";
import uuid from "@/utils/uuid/uuid";
import { ScrollView } from "react-native-gesture-handler";
import LoginView from "../../components/LoginView";
import { useHeaderHeight } from "@react-navigation/elements";
const ANIMATION_DURATION = 170;
+const CHALLENGE_COLOR = "#E50052";
export const PlatformPressable = Platform.OS === 'android' ? Pressable : AnimatedPressable;
-export default function EDLoginWithCredentials() {
- const insets = useSafeAreaInsets();
+function EDDoubleAuthModal({
+ question,
+ visible,
+ options,
+ selectedIndex,
+ submitting,
+ onClose,
+ onContinue,
+ onSelect,
+}: {
+ question: string;
+ visible: boolean;
+ options: string[];
+ selectedIndex: number | null;
+ submitting: boolean;
+ onClose: () => void;
+ onContinue: () => void;
+ onSelect: (index: number | null) => void;
+}) {
+ const { t } = useTranslation();
const theme = useTheme();
const { colors } = theme;
+ const insets = useSafeAreaInsets();
- const alert = useAlert();
+ const selectedBackground = adjust(CHALLENGE_COLOR, theme.dark ? -0.82 : 0.92);
+ const selectedBadgeBackground = CHALLENGE_COLOR + (theme.dark ? "22" : "18");
+
+ return (
+
+
+ (
+
+
+ {question}
+
+
+
+ {t("ONBOARDING_DOUBLE_AUTH_DESCRIPTION")}
+
+
+
+ )}
+ contentContainerStyle={{
+ padding: 16,
+ flexGrow: 1,
+ gap: 10,
+ paddingTop: insets.top + 20,
+ paddingBottom: 20,
+ }}
+ style={{ flex: 1 }}
+ >
+ {options.map((option, index) => {
+ const isSelected = selectedIndex === index;
+
+ return (
+ {
+ if (!submitting) {
+ onSelect(isSelected ? null : index);
+ }
+ }}
+ style={{
+ backgroundColor: isSelected ? selectedBackground : colors.card,
+ minHeight: 68,
+ }}
+ >
+
+
+
+ {isSelected ? (
+
+ ) : (
+
+ {index + 1}
+
+ )}
+
+
+
+
+
+ {option}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
+
+export default function EDLoginWithCredentials() {
+ const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [challengeModalVisible, setChallengeModalVisible] = useState(false);
const [doubleAuthChallenge, setDoubleAuthChallenge] = useState(null);
+ const [selectedChallengeIndex, setSelectedChallengeIndex] = useState(null);
+ const [isSubmittingChallenge, setIsSubmittingChallenge] = useState(false);
const [session, setSession] = useState(null);
const [token, setToken] = useState();
@@ -84,6 +205,13 @@ export default function EDLoginWithCredentials() {
};
}, [keyboardListeners]);
+ useEffect(() => {
+ if (!challengeModalVisible) {
+ setSelectedChallengeIndex(null);
+ setIsSubmittingChallenge(false);
+ }
+ }, [challengeModalVisible]);
+
const handleLogin = async (username: string, password: string, keys?: DoubleAuthResult) => {
const client = new Client();
const device = uuid();
@@ -152,62 +280,22 @@ export default function EDLoginWithCredentials() {
setIsLoggingIn(false);
};
- async function handleChallenge(index: number) {
- setChallengeModalVisible(false);
+ async function handleChallenge() {
+ if (selectedChallengeIndex === null || !session || !doubleAuthChallenge?.propositions?.[selectedChallengeIndex]) { return; }
+
+ setIsSubmittingChallenge(true);
- if (!session || !doubleAuthChallenge?.propositions?.[index]) { return }
try {
- const keys = await session.auth.send2FAQuestion(doubleAuthChallenge.propositions[index], token ?? "");
+ const keys = await session.auth.send2FAQuestion(doubleAuthChallenge.propositions[selectedChallengeIndex], token ?? "");
+ setChallengeModalVisible(false);
+ setIsLoggingIn(true);
queueMicrotask(() => void handleLogin(username, password, keys));
} catch {
- throw new Error("2FA challenge failed");
+ setIsSubmittingChallenge(false);
+ Alert.alert(t("Alert_Auth_Error"), t("ONBOARDING_ALERT_LOGIN_ABORTED"));
}
}
- function questionComponent({ item, index }: { item: unknown; index: number }) {
- return (
-
- {
- handleChallenge(index);
- }}
- style={{
- paddingHorizontal: 10,
- paddingVertical: 10,
- paddingRight: 18,
- borderColor: colors.border,
- borderWidth: 1.5,
- borderRadius: 80,
- flexDirection: "row",
- alignItems: "center",
- gap: 16,
- }}
- >
-
-
- {index + 1}
-
-
-
-
- {String(item)}
-
-
-
-
- );
- }
-
const headerHeight = useHeaderHeight();
const finalHeaderHeight = Platform.select({
android: headerHeight,
@@ -232,22 +320,16 @@ export default function EDLoginWithCredentials() {
/>
- setChallengeModalVisible(false)}
- >
-
-
+ question={doubleAuthChallenge?.question ?? t("ONBOARDING_DOUBLE_AUTH")}
+ options={(doubleAuthChallenge?.propositions ?? []).map((option) => String(option))}
+ selectedIndex={selectedChallengeIndex}
+ submitting={isSubmittingChallenge}
+ onSelect={setSelectedChallengeIndex}
+ onClose={() => setChallengeModalVisible(false)}
+ onContinue={handleChallenge}
+ />
);
}
diff --git a/app/(settings)/edit_subject.tsx b/app/(settings)/edit_subject.tsx
index 6daa6ac5..35401b52 100644
--- a/app/(settings)/edit_subject.tsx
+++ b/app/(settings)/edit_subject.tsx
@@ -1,5 +1,6 @@
import Stack from "@/ui/components/Stack";
import AnimatedPressable from "@/ui/components/AnimatedPressable";
+import SheetModal from "@/ui/components/SheetModal";
import { Papicons } from "@getpapillon/papicons";
import { useTheme } from "@react-navigation/native";
import Typography from "@/ui/components/Typography";
@@ -9,7 +10,6 @@ import {
ScrollView,
View,
Platform,
- Modal,
} from "react-native";
import OnboardingInput from "@/components/onboarding/OnboardingInput";
import { Colors } from "@/utils/subjects/colors";
@@ -112,7 +112,12 @@ function EmojiPicker({
setSelectedEmoji(item);
}, []);
- const [headerHeight, setHeaderHeight] = useState(0);
+ const estimatedHeaderHeight = useMemo(() => {
+ const usedInsets = Platform.OS === "ios" ? 16 : insets.top;
+ return usedInsets + 4 + 40 + 16 + (Platform.OS === "android" ? 6 : 0);
+ }, [insets.top]);
+ const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(0);
+ const headerHeight = Math.max(measuredHeaderHeight, estimatedHeaderHeight);
const [selectedEmoji, setSelectedEmoji] = useState("😀");
const emojiContainerStyle = {
@@ -145,7 +150,7 @@ function EmojiPicker({
>
-
-
+
);
};
diff --git a/app/(settings)/transport.tsx b/app/(settings)/transport.tsx
index 08ce5d96..0bccadd4 100644
--- a/app/(settings)/transport.tsx
+++ b/app/(settings)/transport.tsx
@@ -3,11 +3,12 @@ import { useTheme } from "@react-navigation/native";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
-import { Image, Modal } from "react-native";
+import { Image } from "react-native";
import SettingsHeader from "@/components/SettingsHeader";
import { AvailableTransportServices } from "@/constants/AvailableTransportServices";
import { useAccountStore } from "@/stores/account";
+import SheetModal from "@/ui/components/SheetModal";
import List from "@/ui/new/List";
import Typography from "@/ui/new/Typography";
import { AddressModal } from "@/app/(modals)/address";
@@ -178,7 +179,7 @@ export default function TransportView() {
))}
-
-
+
);
diff --git a/locales/en.json b/locales/en.json
index fda9317e..85a24b19 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -113,6 +113,7 @@
"ONBOARDING_UNKNOWN_STUDENT": "Student",
"ONBOARDING_DEFAULT_USER_FIRSTNAME": "User",
"ONBOARDING_DOUBLE_AUTH": "Double authentication",
+ "ONBOARDING_DOUBLE_AUTH_DESCRIPTION": "Select the right answer, then continue.",
"ONBOARDING_OTHER_UNIVERSITIES": "Other universities",
"ONBOARDING_SERVICE_PRONOTE": "PRONOTE",
"ONBOARDING_SERVICE_ED": "ÉcoleDirecte",
diff --git a/locales/fr.json b/locales/fr.json
index 0b68baa8..5c2605d8 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -116,6 +116,7 @@
"ONBOARDING_UNKNOWN_STUDENT": "Étudiant",
"ONBOARDING_DEFAULT_USER_FIRSTNAME": "Utilisateur",
"ONBOARDING_DOUBLE_AUTH": "Double authentification",
+ "ONBOARDING_DOUBLE_AUTH_DESCRIPTION": "Sélectionne la bonne réponse, puis continue.",
"ONBOARDING_OTHER_UNIVERSITIES": "Autres universités",
"ONBOARDING_SERVICE_PRONOTE": "PRONOTE",
"ONBOARDING_SERVICE_ED": "ÉcoleDirecte",
diff --git a/ui/components/SheetModal.tsx b/ui/components/SheetModal.tsx
new file mode 100644
index 00000000..87c6b58b
--- /dev/null
+++ b/ui/components/SheetModal.tsx
@@ -0,0 +1,101 @@
+import React, { PropsWithChildren, useEffect, useState } from "react";
+import { Modal, ModalProps, Platform, StyleSheet, useWindowDimensions } from "react-native";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import Animated, { Easing, runOnJS, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
+
+const OPEN_DURATION = 220;
+const CLOSE_DURATION = 180;
+
+type SheetModalProps = PropsWithChildren;
+
+export default function SheetModal({
+ animationType = "slide",
+ children,
+ visible = false,
+ ...props
+}: SheetModalProps) {
+ const { height } = useWindowDimensions();
+ const [isMounted, setIsMounted] = useState(visible);
+ const translateY = useSharedValue(visible ? 0 : height);
+
+ useEffect(() => {
+ if (Platform.OS !== "android") {
+ return;
+ }
+
+ if (visible) {
+ if (!isMounted) {
+ setIsMounted(true);
+ translateY.value = height;
+ }
+
+ translateY.value = withTiming(0, {
+ duration: OPEN_DURATION,
+ easing: Easing.out(Easing.cubic),
+ });
+ return;
+ }
+
+ if (!isMounted) {
+ translateY.value = height;
+ return;
+ }
+
+ // Android unmounts Modal children as soon as `visible` becomes false.
+ translateY.value = withTiming(
+ height,
+ {
+ duration: CLOSE_DURATION,
+ easing: Easing.in(Easing.cubic),
+ },
+ finished => {
+ if (finished) {
+ runOnJS(setIsMounted)(false);
+ }
+ }
+ );
+ }, [height, isMounted, translateY, visible]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: translateY.value }],
+ }));
+
+ if (Platform.OS !== "android") {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (!visible && !isMounted) {
+ return null;
+ }
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+});