diff --git a/assets/translations/en.json b/assets/translations/en.json index 87bab7d6..12758c7b 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -504,8 +504,10 @@ "location": "Location", "noClassroom": "No place specified for this exam call", "noLocation": "Room to be defined", + "notAvailable": "Booking not available", "notes": "Notes", - "title": "Exam call" + "title": "Exam call", + "noBookedCount": "Data not available" }, "examsScreen": { "emptyState": "There are no open exam calls", @@ -569,7 +571,8 @@ "title": "Log in with your polito.it credentials", "unsupportedUserType": "User type not supported: only students can log in to PoliTO Students.", "usernameLabel": "Student ID", - "usernameLabelAccessibility": "Enter your username here" + "usernameLabelAccessibility": "Enter your username here", + "fcmUnsupported": "An error occurred during push notifications setup. This problem can be caused by the lack of Google Play Services on this device. It is possible to keep using this app, however push notifications won't be received." }, "messageScreen": { "backTitle": "Archive", @@ -697,12 +700,12 @@ "trainingOffer": "Training offer" }, "provisionalGradeScreen": { - "acceptGradeConfirmMessage": "By accepting this evaluation you will no longer be able to change your decision", + "acceptGradeConfirmMessage": "By requesting immediate registration, the evaluation will be recorded in your transcript and you will no longer be able to change your decision", "acceptGradeCta": "Request immediate registration", "acceptGradeFeedback": "The evaluation has been recorded, it will appear in the transcript", "contactProfessorCta": "Contact the teacher", "rejectGradeConfirmMessage": "By rejecting this evaluation you will no longer be able to change your decision", - "rejectGradeCta": "Reject the evaluation", + "rejectGradeCta": "Reject the evaluation by:
{{hours}}", "rejectGradeFeedback": "Evaluation rejected, it will be recorded in the next few hours", "title": "Evaluation" }, @@ -787,13 +790,14 @@ "title": "Evaluation" }, "transcriptGradesScreen": { + "autoRegistration": "Automatic registration in:", "emptyState": "You haven't taken any exams", "expiredCountdown": "Expired", "provisionalEmptyState": "There are no provisional grades", "provisionalTitle": "Provisional", "recordedTitle": "Recorded", "rejectedSubtitle": "Rejected on {{-date}} at {{-time}}", - "rejectionCountdown": "Rejectable by:", + "rejectionCountdown": "Rejectable by: {{hours}}", "title": "Grades", "total": "There are {{total}} grades" }, diff --git a/assets/translations/it.json b/assets/translations/it.json index 41e392d0..90208462 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -504,8 +504,10 @@ "location": "Luogo", "noClassroom": "Nessun luogo specificato per questo appello", "noLocation": "Aula da definire", + "notAvailable": "Prenotazione non disponibile", "notes": "Note", - "title": "Appello" + "title": "Appello", + "noBookedCount": "Dato non disponibile" }, "examsScreen": { "emptyState": "Non ci sono appelli disponibili", @@ -569,7 +571,8 @@ "title": "Accedi con le tue credenziali polito.it", "unsupportedUserType": "Tipo utente non supportato: PoliTO Students è accessibile solamente agli studenti.", "usernameLabel": "Matricola", - "usernameLabelAccessibility": "Inserisci qui il tuo username" + "usernameLabelAccessibility": "Inserisci qui il tuo username", + "fcmUnsupported": "Si è verificato un errore durante l'attivazione delle notifiche push. Questo problema può essere causato dall'assenza dei servizi Google su questo dispositivo. È possibile continuare ad utilizzare l'app ma non si riceveranno notifiche push." }, "messageScreen": { "backTitle": "Archivio", @@ -697,12 +700,12 @@ "trainingOffer": "Offerta formativa" }, "provisionalGradeScreen": { - "acceptGradeConfirmMessage": "Accettando il voto non potrai più cambiare la tua decisione", + "acceptGradeConfirmMessage": "Richiedendo la registrazione immediata la valutazione verrà inserita in libretto e non potrai più cambiare la tua decisione", "acceptGradeCta": "Richiedi la registrazione immediata", "acceptGradeFeedback": "Valutazione registrata, verrà visualizzata nel libretto", "contactProfessorCta": "Contatta il docente", - "rejectGradeConfirmMessage": "Rifiutando il voto non potrai più cambiare la tua decisione", - "rejectGradeCta": "Rifiuta la valutazione", + "rejectGradeConfirmMessage": "Rifiutando la valutazione non potrai più cambiare la tua decisione", + "rejectGradeCta": "Rifiuta la valutazione entro:
{{hours}}", "rejectGradeFeedback": "Valutazione rifiutata, verrà registrata nelle prossime ore", "title": "Valutazione" }, @@ -787,13 +790,14 @@ "title": "Valutazione" }, "transcriptGradesScreen": { + "autoRegistration": "Registrazione automatica tra:", "emptyState": "Non hai sostenuto nessun esame", "expiredCountdown": "Scaduto", "provisionalEmptyState": "Non ci sono valutazioni provvisorie", "provisionalTitle": "Provvisorie", "recordedTitle": "Registrate", "rejectedSubtitle": "Rifiutato il {{-date}} alle {{-time}}", - "rejectionCountdown": "Rifiutabile entro:", + "rejectionCountdown": "Rifiutabile entro: {{hours}}", "title": "Valutazioni", "total": "Sono presenti {{total}} valutazioni" }, diff --git a/lib/ui/components/CtaButton.tsx b/lib/ui/components/CtaButton.tsx index a9020467..938997b8 100644 --- a/lib/ui/components/CtaButton.tsx +++ b/lib/ui/components/CtaButton.tsx @@ -18,6 +18,7 @@ import { useTheme } from '@lib/ui/hooks/useTheme'; import { Theme } from '@lib/ui/types/Theme'; import { shadeColor } from '@lib/ui/utils/colors'; +import { TextWithLinks } from '../../../src/core/components/TextWithLinks'; import { useFeedbackContext } from '../../../src/core/contexts/FeedbackContext'; import { useSafeBottomBarHeight } from '../../../src/core/hooks/useSafeBottomBarHeight'; @@ -54,7 +55,8 @@ export const CtaButton = ({ variant = 'filled', ...rest }: Props) => { - const { palettes, colors, fontSizes, spacing, dark } = useTheme(); + const { palettes, colors, fontSizes, spacing, dark, fontWeights } = + useTheme(); const styles = useStylesheet(createStyles); const { left, right } = useSafeAreaInsets(); const bottomBarHeight = useSafeBottomBarHeight(); @@ -159,7 +161,7 @@ export const CtaButton = ({ style={{ marginRight: spacing[2] }} /> )} - {title} - + {rightExtra && rightExtra} @@ -190,13 +194,7 @@ export const CtaButtonSpacer = () => { return ; }; -const createStyles = ({ - colors, - shapes, - spacing, - fontSizes, - fontWeights, -}: Theme) => +const createStyles = ({ colors, shapes, spacing, fontSizes }: Theme) => StyleSheet.create({ container: { padding: spacing[4], @@ -222,7 +220,6 @@ const createStyles = ({ }, textStyle: { fontSize: fontSizes.md, - fontWeight: fontWeights.medium, textAlign: 'center', color: colors.white, }, diff --git a/lib/ui/components/CtaButtonContainer.tsx b/lib/ui/components/CtaButtonContainer.tsx index 76dd28c8..12b531cd 100644 --- a/lib/ui/components/CtaButtonContainer.tsx +++ b/lib/ui/components/CtaButtonContainer.tsx @@ -35,6 +35,7 @@ export const CtaButtonContainer = ({ }, absolute && { position: 'absolute', + width: Platform.select({ android: '100%' }), left: Platform.select({ ios: left }), right, bottom: diff --git a/lib/ui/components/ErrorCard.tsx b/lib/ui/components/ErrorCard.tsx new file mode 100644 index 00000000..b0f0c654 --- /dev/null +++ b/lib/ui/components/ErrorCard.tsx @@ -0,0 +1,41 @@ +import { PropsWithChildren } from 'react'; +import { Platform, ViewProps } from 'react-native'; + +import { Card } from '@lib/ui/components/Card'; +import { Text } from '@lib/ui/components/Text'; +import { useTheme } from '@lib/ui/hooks/useTheme'; + +type Props = PropsWithChildren< + ViewProps & { + text: string; + } +>; + +export const ErrorCard = ({ text, ...rest }: Props) => { + const { spacing, fontSizes, colors } = useTheme(); + return ( + + + {text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()} + + + ); +}; diff --git a/lib/ui/types/Theme.ts b/lib/ui/types/Theme.ts index 9caf8001..7e984a47 100644 --- a/lib/ui/types/Theme.ts +++ b/lib/ui/types/Theme.ts @@ -83,6 +83,7 @@ export interface Colors { heading: string; subHeading: string; prose: string; + disableTitle: string; longProse: string; secondaryText: string; caption: string; @@ -96,6 +97,8 @@ export interface Colors { deadlineCardBorder: string; examCardBorder: string; lectureCardSecondary: string; + errorCardText: string; + errorCardBorder: string; translucentSurface: string; white: string; } diff --git a/package-lock.json b/package-lock.json index 5732d520..5d1d9c26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@polito/students-app", - "version": "1.6.5", + "version": "1.6.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@polito/students-app", - "version": "1.6.5", + "version": "1.6.6", "hasInstallScript": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.2.1", @@ -18,7 +18,7 @@ "@miblanchard/react-native-slider": "^2.2.0", "@openspacelabs/react-native-zoomable-view": "^2.1.5", "@orama/orama": "^2.0.0-beta.8", - "@polito/api-client": "^1.0.0-ALPHA.63", + "@polito/api-client": "^1.0.0-ALPHA.64", "@react-native-async-storage/async-storage": "^1.18.2", "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/blur": "^4.3.0", @@ -127,7 +127,7 @@ "jest": "^29.2.1", "lint-staged": "^13.0.3", "metro-react-native-babel-preset": "0.76.9", - "pod-install": "0.1.38", + "pod-install": "^0.2.2", "prettier": "^2.7.1", "react-test-renderer": "18.2.0", "standard-version": "^9.5.0", @@ -4019,9 +4019,9 @@ } }, "node_modules/@polito/api-client": { - "version": "1.0.0-ALPHA.63", - "resolved": "https://npm.pkg.github.com/download/@polito/api-client/1.0.0-ALPHA.63/c061fbc916fd4dfdca7edec5a2ef838593cbc22a", - "integrity": "sha512-AGnKq41yX1MBQOqztG+1cQu2OhfTZ0tKGMr9b8ez8tbual1axW94QLwu/ECmQM/7258sqcA/H8GuTgI+6UEPCw==" + "version": "1.0.0-ALPHA.64", + "resolved": "https://npm.pkg.github.com/download/@polito/api-client/1.0.0-ALPHA.64/3f3da2fd8e53974247d9116582d442709ed0332b", + "integrity": "sha512-DUywgHp7R10jYFukfuzu7HvJvnWNFzAgBM+VxIIfz6Dto3YfrjknSEpNF3EjL5xSLlpBivKUAgxLiKiJn7G4Zw==" }, "node_modules/@react-native-async-storage/async-storage": { "version": "1.19.8", @@ -15851,9 +15851,10 @@ } }, "node_modules/pod-install": { - "version": "0.1.38", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/pod-install/-/pod-install-0.2.2.tgz", + "integrity": "sha512-NgQpKiuWZo8mWU+SVxmrn+ARy9+fFYzW53ze6CDTo70u5Ie8AVSn7FqolDC/c7+N4/kQ1BldAnXEab6SNYA8xw==", "dev": true, - "license": "MIT", "bin": { "pod-install": "build/index.js" } diff --git a/package.json b/package.json index bb0823dd..3ed23152 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@polito/students-app", - "version": "1.6.5", + "version": "1.6.6", "private": true, "scripts": { "android": "react-native run-android --active-arch-only --appIdSuffix=dev", @@ -28,7 +28,7 @@ "@miblanchard/react-native-slider": "^2.2.0", "@openspacelabs/react-native-zoomable-view": "^2.1.5", "@orama/orama": "^2.0.0-beta.8", - "@polito/api-client": "^1.0.0-ALPHA.63", + "@polito/api-client": "^1.0.0-ALPHA.64", "@react-native-async-storage/async-storage": "^1.18.2", "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/blur": "^4.3.0", @@ -137,7 +137,7 @@ "jest": "^29.2.1", "lint-staged": "^13.0.3", "metro-react-native-babel-preset": "0.76.9", - "pod-install": "0.1.38", + "pod-install": "^0.2.2", "prettier": "^2.7.1", "react-test-renderer": "18.2.0", "standard-version": "^9.5.0", diff --git a/src/core/components/TextWithLinks.tsx b/src/core/components/TextWithLinks.tsx index eb32908c..91f227a0 100644 --- a/src/core/components/TextWithLinks.tsx +++ b/src/core/components/TextWithLinks.tsx @@ -1,20 +1,21 @@ import { PropsWithChildren } from 'react'; import { TextProps } from 'react-native'; +import { MixedStyleDeclaration } from 'react-native-render-html'; import { linkUrls } from '../../utils/html'; import { HtmlView } from './HtmlView'; -export const TextWithLinks = ({ - children, - style, -}: PropsWithChildren) => { - if (!children || typeof children !== 'string') return null; +type Props = { + baseStyle?: MixedStyleDeclaration; +} & PropsWithChildren; +export const TextWithLinks = ({ baseStyle, children, style }: Props) => { + if (!children || typeof children !== 'string') return null; const html = linkUrls(children); return ( ); diff --git a/src/core/hooks/useDownloadCourseFile.ts b/src/core/hooks/useDownloadCourseFile.ts index 73319455..be4fda6f 100644 --- a/src/core/hooks/useDownloadCourseFile.ts +++ b/src/core/hooks/useDownloadCourseFile.ts @@ -68,10 +68,14 @@ export const useDownloadCourseFile = ( updateDownload({ isDownloaded: true }); } else { // Update the name when changed - await mkdir(dirname(toFile)); - await moveFile(cachedFilePath, toFile); - await cleanupEmptyFolders(coursesFilesCachePath); - refresh(); + try { + await mkdir(dirname(toFile)); + await moveFile(cachedFilePath, toFile); + await cleanupEmptyFolders(coursesFilesCachePath); + refresh(); + } catch (_) { + // File rename was already scheduled + } } } else { updateDownload({ isDownloaded: false }); diff --git a/src/core/queries/authHooks.ts b/src/core/queries/authHooks.ts index fe14d4db..5c8627fc 100644 --- a/src/core/queries/authHooks.ts +++ b/src/core/queries/authHooks.ts @@ -1,4 +1,4 @@ -import { Platform } from 'react-native'; +import { Alert, Platform } from 'react-native'; import DeviceInfo from 'react-native-device-info'; import Keychain from 'react-native-keychain'; @@ -6,6 +6,8 @@ import { AuthApi, LoginRequest, SwitchCareerRequest } from '@polito/api-client'; import messaging from '@react-native-firebase/messaging'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { t } from 'i18next'; + import { isEnvProduction } from '../../utils/env'; import { pluckData } from '../../utils/queries'; import { useApiContext } from '../contexts/ApiContext'; @@ -17,6 +19,18 @@ const useAuthClient = (): AuthApi => { return new AuthApi(); }; +async function getFcmToken(): Promise { + if (!isEnvProduction) return undefined; + + try { + return await messaging().getToken(); + } catch (_) { + Alert.alert(t('common.error'), t('loginScreen.fcmUnsupported')); + } + + return undefined; +} + export const useLogin = () => { const authClient = useAuthClient(); const { refreshContext } = useApiContext(); @@ -24,7 +38,7 @@ export const useLogin = () => { return useMutation({ mutationFn: (dto: LoginRequest) => { - const client = { name: 'Students app' }; + const client = { name: 'Students app', id: 'students-app' }; return Promise.all([ DeviceInfo.getDeviceName(), @@ -32,7 +46,7 @@ export const useLogin = () => { DeviceInfo.getManufacturer(), DeviceInfo.getBuildNumber(), DeviceInfo.getVersion(), - isEnvProduction ? messaging().getToken() : undefined, + getFcmToken(), ]) .then( ([ diff --git a/src/core/themes/dark.ts b/src/core/themes/dark.ts index 5555ac4e..d7519cb7 100644 --- a/src/core/themes/dark.ts +++ b/src/core/themes/dark.ts @@ -19,6 +19,7 @@ export const darkTheme: Theme = { subHeading: lightTheme.palettes.info[400], title: 'white', prose: lightTheme.palettes.text[50], + disableTitle: lightTheme.palettes.gray[700], longProse: lightTheme.palettes.text[50], secondaryText: lightTheme.palettes.text[400], caption: lightTheme.palettes.text[500], @@ -31,5 +32,7 @@ export const darkTheme: Theme = { touchableHighlight: 'rgba(255, 255, 255, .08)', lectureCardSecondary: lightTheme.palettes.gray[300], tabBarInactive: lightTheme.palettes.gray[400], + errorCardText: lightTheme.palettes.rose[200], + errorCardBorder: lightTheme.palettes.rose[500], }, }; diff --git a/src/core/themes/light.ts b/src/core/themes/light.ts index 9836421d..b2cc1ed2 100644 --- a/src/core/themes/light.ts +++ b/src/core/themes/light.ts @@ -150,6 +150,7 @@ export const lightTheme: Theme = { subHeading: lightBlue[700], title: navy[700], prose: gray[800], + disableTitle: '#FFFFFF', longProse: gray[800], secondaryText: gray[500], caption: gray[500], @@ -162,6 +163,8 @@ export const lightTheme: Theme = { deadlineCardBorder: red[700], examCardBorder: orange[600], lectureCardSecondary: gray[600], + errorCardText: rose[700], + errorCardBorder: rose[500], }, palettes: { navy, diff --git a/src/features/teaching/components/ExamCTA.tsx b/src/features/teaching/components/ExamCTA.tsx index 548e5e55..597a6a39 100644 --- a/src/features/teaching/components/ExamCTA.tsx +++ b/src/features/teaching/components/ExamCTA.tsx @@ -32,13 +32,13 @@ export const ExamCTA = ({ exam }: Props) => { const examRequestable = exam?.status === ExamStatusEnum.Requestable; const examAvailable = exam?.status === ExamStatusEnum.Available; + const examUnavailable = exam?.status === ExamStatusEnum.Unavailable; const confirm = useConfirmationDialog(); const disabledStatuses = [ ExamStatusEnum.RequestAccepted, ExamStatusEnum.RequestRejected, - ExamStatusEnum.Unavailable, ] as ExamStatusEnum[]; const action = async () => { if (examRequestable) { @@ -80,7 +80,9 @@ export const ExamCTA = ({ exam }: Props) => { { } action={action} loading={mutationsLoading} - disabled={!onlineManager.isOnline()} + disabled={!onlineManager.isOnline() || examUnavailable} + variant="filled" /> ); }; diff --git a/src/features/teaching/screens/ExamScreen.tsx b/src/features/teaching/screens/ExamScreen.tsx index adf3ed5c..37795dc9 100644 --- a/src/features/teaching/screens/ExamScreen.tsx +++ b/src/features/teaching/screens/ExamScreen.tsx @@ -3,9 +3,14 @@ import { useTranslation } from 'react-i18next'; import { SafeAreaView, ScrollView, View } from 'react-native'; import { faNoteSticky } from '@fortawesome/free-regular-svg-icons'; -import { faHourglassEnd, faUsers } from '@fortawesome/free-solid-svg-icons'; +import { + faHourglassEnd, + faTriangleExclamation, + faUsers, +} from '@fortawesome/free-solid-svg-icons'; import { Col } from '@lib/ui/components/Col'; import { CtaButtonSpacer } from '@lib/ui/components/CtaButton'; +import { ErrorCard } from '@lib/ui/components/ErrorCard'; import { Icon } from '@lib/ui/components/Icon'; import { ListItem } from '@lib/ui/components/ListItem'; import { OverviewList } from '@lib/ui/components/OverviewList'; @@ -16,6 +21,7 @@ import { ScreenDateTime } from '@lib/ui/components/ScreenDateTime'; import { ScreenTitle } from '@lib/ui/components/ScreenTitle'; import { Text } from '@lib/ui/components/Text'; import { useTheme } from '@lib/ui/hooks/useTheme'; +import { ExamStatusEnum } from '@polito/api-client'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { BottomBarSpacer } from '../../../core/components/BottomBarSpacer'; @@ -36,6 +42,7 @@ import { ExamCpdModalContent } from '../../surveys/components/ExamCpdModalConten import { ExamCTA } from '../components/ExamCTA'; import { ExamStatusBadge } from '../components/ExamStatusBadge'; import { TeachingStackParamList } from '../components/TeachingNavigator'; +import { getExam, isExamPassed } from '../utils/exam'; type Props = NativeStackScreenProps; @@ -187,14 +194,44 @@ export const ExamScreen = ({ route, navigation }: Props) => { : t('common.dateToBeDefined') } subtitle={t('examScreen.bookingEndsAt')} + trailingItem={ + exam?.status === ExamStatusEnum.Unavailable && + exam?.bookingEndsAt && + isExamPassed(exam.bookingEndsAt) ? ( + + ) : undefined + } /> + } inverted - title={`${exam?.bookedCount}`} + /* check using undefined since the fields can be 0 */ + title={ + exam?.bookedCount !== undefined + ? getExam(exam.bookedCount, exam.availableCount) + : t('examScreen.noBookedCount') + } subtitle={t('examScreen.bookedCount')} + trailingItem={ + exam?.status === ExamStatusEnum.Unavailable && + exam.availableCount === 0 ? ( + + ) : undefined + } /> + {exam?.feedback && exam?.status === ExamStatusEnum.Unavailable && ( + + )} diff --git a/src/features/teaching/screens/TeachingScreen.tsx b/src/features/teaching/screens/TeachingScreen.tsx index ea65ffb1..8fc3f17c 100644 --- a/src/features/teaching/screens/TeachingScreen.tsx +++ b/src/features/teaching/screens/TeachingScreen.tsx @@ -28,6 +28,8 @@ import { Theme } from '@lib/ui/types/Theme'; import { ExamStatusEnum } from '@polito/api-client'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { DateTime } from 'luxon'; + import { BottomBarSpacer } from '../../../core/components/BottomBarSpacer'; import { usePreferencesContext } from '../../../core/contexts/PreferencesContext'; import { useNotifications } from '../../../core/hooks/useNotifications'; @@ -83,8 +85,19 @@ export const TeachingScreen = ({ navigation }: Props) => { return ( examsQuery.data - .filter(e => !hiddenCourses.includes(e.uniqueShortcode)) - .sort(e => (e.status === ExamStatusEnum.Booked ? -1 : 1)) + .filter( + e => + !hiddenCourses.includes(e.uniqueShortcode) && + e.examEndsAt!.valueOf() > DateTime.now().toJSDate().valueOf(), + ) + .sort((a, b) => { + const status = + (a.status === ExamStatusEnum.Booked ? -1 : 0) + + (b.status === ExamStatusEnum.Booked ? 1 : 0); + return status !== 0 + ? status + : a.examStartsAt!.valueOf() - b.examStartsAt!.valueOf(); + }) .slice(0, 4) ?? [] ); }, [coursePreferences, coursesQuery.data, examsQuery.data]); diff --git a/src/features/teaching/utils/exam.ts b/src/features/teaching/utils/exam.ts new file mode 100644 index 00000000..6b517bff --- /dev/null +++ b/src/features/teaching/utils/exam.ts @@ -0,0 +1,12 @@ +import { DateTime } from 'luxon'; + +export const isExamPassed = (bookingEndsAt: Date) => { + return DateTime.now().setZone('Europe/Rome').toJSDate() > bookingEndsAt; +}; + +export const getExam = (bookedCount: number, availableCount: number) => { + if (availableCount === undefined || availableCount === 999) { + return `${bookedCount}`; + } + return `${bookedCount}/${availableCount + bookedCount}`; +}; diff --git a/src/features/transcript/components/ProvisionalGradeListItem.tsx b/src/features/transcript/components/ProvisionalGradeListItem.tsx index 812244fd..fb88e922 100644 --- a/src/features/transcript/components/ProvisionalGradeListItem.tsx +++ b/src/features/transcript/components/ProvisionalGradeListItem.tsx @@ -11,6 +11,7 @@ import { ProvisionalGradeStateEnum, } from '@polito/api-client/models/ProvisionalGrade'; +import { TextWithLinks } from '../../../core/components/TextWithLinks'; import { IS_IOS } from '../../../core/constants'; import { formatDate, formatTime } from '../../../utils/dates'; import { useGetRejectionTime } from '../hooks/useGetRejectionTime'; @@ -34,7 +35,16 @@ export const ProvisionalGradeListItem = ({ grade }: Props) => { const subtitle = useMemo(() => { switch (grade.state) { case ProvisionalGradeStateEnum.Confirmed: - return rejectionTime; + if (grade.canBeRejected) { + return ( + + {t('transcriptGradesScreen.rejectionCountdown', { + hours: rejectionTime, + })} + + ); + } + break; case ProvisionalGradeStateEnum.Rejected: return t('transcriptGradesScreen.rejectedSubtitle', { date: formatDate(grade.rejectedAt!), @@ -73,11 +83,12 @@ export const ProvisionalGradeListItem = ({ grade }: Props) => { ); }; -const createStyles = ({ colors, dark, palettes }: Theme) => ({ +const createStyles = ({ colors, dark, palettes, fontSizes }: Theme) => ({ subtitle: { color: colors.title, }, rejectableSubtitle: { + fontSize: fontSizes.sm, color: dark ? palettes.danger[300] : palettes.danger[700], }, }); diff --git a/src/features/transcript/hooks/useGetRejectionTime.tsx b/src/features/transcript/hooks/useGetRejectionTime.tsx index c421762a..bc25d7c4 100644 --- a/src/features/transcript/hooks/useGetRejectionTime.tsx +++ b/src/features/transcript/hooks/useGetRejectionTime.tsx @@ -21,18 +21,18 @@ export const useGetRejectionTime = ({ return t('transcriptGradesScreen.expiredCountdown'); } - let time = t('transcriptGradesScreen.rejectionCountdown'); + let time = ''; const hours = Math.floor(diff / (1000 * 60 * 60)); // count hours if (hours > 0) { - time += ` ${hours} ${t('common.hours').toLowerCase()}`; + time += ` ${hours}h`; } if (hours === 0 || !isCompact) { const minutes = Math.floor((diff / (1000 * 60)) % 60); - time += ` ${minutes} ${t('common.minutes').toLowerCase()}`; + time += ` ${minutes}m`; } return time; diff --git a/src/features/transcript/screens/ProvisionalGradeScreen.tsx b/src/features/transcript/screens/ProvisionalGradeScreen.tsx index 3c7f137b..de5df371 100644 --- a/src/features/transcript/screens/ProvisionalGradeScreen.tsx +++ b/src/features/transcript/screens/ProvisionalGradeScreen.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { SafeAreaView, ScrollView, StyleSheet } from 'react-native'; +import { Platform, SafeAreaView, ScrollView, StyleSheet } from 'react-native'; import { ActivityIndicator } from '@lib/ui/components/ActivityIndicator'; import { Col } from '@lib/ui/components/Col'; @@ -11,6 +11,7 @@ import { Row } from '@lib/ui/components/Row'; import { ScreenTitle } from '@lib/ui/components/ScreenTitle'; import { Text } from '@lib/ui/components/Text'; import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { useTheme } from '@lib/ui/hooks/useTheme'; import { Theme } from '@lib/ui/types/Theme'; import { ProvisionalGradeStateEnum } from '@polito/api-client/models/ProvisionalGrade'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; @@ -35,6 +36,7 @@ export const ProvisionalGradeScreen = ({ navigation, route }: Props) => { const { t } = useTranslation(); const styles = useStylesheet(createStyles); const { setFeedback } = useFeedbackContext(); + const { fontWeights } = useTheme(); const confirmAcceptance = useConfirmationDialog({ title: t('common.areYouSure?'), @@ -91,7 +93,18 @@ export const ProvisionalGradeScreen = ({ navigation, route }: Props) => { ) : ( - + {`${formatDate(grade.date)} - ${t( @@ -100,10 +113,6 @@ export const ProvisionalGradeScreen = ({ navigation, route }: Props) => { credits: grade.credits, }, )}`} - {grade.state === ProvisionalGradeStateEnum.Confirmed && - rejectionTime && ( - {rejectionTime} - )} { style={styles.grade} > - {grade.grade}{' '} + {grade.isFailure || grade.isWithdrawn + ? grade.grade.charAt(0).toUpperCase() + + grade.grade.slice(1).toLowerCase() + : grade.grade} + {grade.state === ProvisionalGradeStateEnum.Confirmed && + grade.canBeAccepted && + rejectionTime && ( + + + {t('transcriptGradesScreen.autoRegistration')} + + {rejectionTime} + + + + )} + {grade?.state === ProvisionalGradeStateEnum.Confirmed && ( @@ -140,54 +173,63 @@ export const ProvisionalGradeScreen = ({ navigation, route }: Props) => { /> )} {grade?.state === ProvisionalGradeStateEnum.Confirmed && ( - - - confirmAcceptance().then(ok => { - if (ok) { - acceptGradeQuery - .mutateAsync(grade.id) - .then(() => provideFeedback(true)); - } - }) - } - variant="outlined" - absolute={false} - loading={acceptGradeQuery.isLoading} - disabled={ - isOffline || - acceptGradeQuery.isLoading || - rejectGradeQuery.isLoading - } - containerStyle={{ paddingVertical: 0 }} - /> - - confirmRejection().then(ok => { - if (ok) { - rejectGradeQuery - .mutateAsync(grade.id) - .then(() => provideFeedback(false)); - } - }) - } - absolute={false} - loading={rejectGradeQuery.isLoading} - disabled={ - isOffline || - acceptGradeQuery.isLoading || - rejectGradeQuery.isLoading - } - containerStyle={{ paddingVertical: 0 }} - /> + + {grade?.canBeAccepted && ( + + confirmAcceptance().then(ok => { + if (ok) { + acceptGradeQuery + .mutateAsync(grade.id) + .then(() => provideFeedback(true)); + } + }) + } + absolute={false} + loading={acceptGradeQuery.isLoading} + disabled={ + isOffline || + acceptGradeQuery.isLoading || + rejectGradeQuery.isLoading + } + containerStyle={{ paddingVertical: 0 }} + /> + )} + {grade?.canBeRejected && ( + + confirmRejection().then(ok => { + if (ok) { + rejectGradeQuery + .mutateAsync(grade.id) + .then(() => provideFeedback(false)); + } + }) + } + absolute={false} + loading={rejectGradeQuery.isLoading} + variant="outlined" + disabled={ + isOffline || + acceptGradeQuery.isLoading || + rejectGradeQuery.isLoading + } + containerStyle={{ paddingVertical: 0 }} + destructive + /> + )} )} ); }; - const createStyles = ({ colors, dark, @@ -228,10 +270,17 @@ const createStyles = ({ fontWeight: fontWeights.semibold, }, longGradeText: { - fontSize: fontSizes.md, + fontSize: fontSizes.lg, fontWeight: fontWeights.semibold, }, + failureGradeText: { + color: palettes.rose[600], + }, rejectionTime: { color: dark ? palettes.danger[300] : palettes.danger[700], }, + autoRegistration: { + fontSize: fontSizes.md, + color: dark ? palettes.primary[300] : palettes.primary[600], + }, });