diff --git a/package-lock.json b/package-lock.json index 1eb4638c..e378fb01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,11 @@ "@fortawesome/free-regular-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-native-fontawesome": "^0.3.0", - "@gorhom/bottom-sheet": "^4.5.1", - "@kyupss/native-swipeable": "^1.0.1", + "@gorhom/bottom-sheet": "^4.6.1", "@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.58", + "@polito/api-client": "^1.0.0-ALPHA.59", "@react-native-async-storage/async-storage": "^1.17.11", "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/blur": "^4.3.0", @@ -64,11 +63,13 @@ "react-native-file-viewer": "^2.1.5", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.13.1", + "react-native-gifted-charts": "^1.4.8", "react-native-html-to-pdf": "^0.12.0", "react-native-image-crop-picker": "^0.38.0", "react-native-keyboard-accessory": "^0.1.16", "react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-keychain": "^8.1.1", + "react-native-linear-gradient": "^2.8.3", "react-native-mime-types": "^2.3.0", "react-native-modal": "^13.0.1", "react-native-override-color-scheme": "^1.0.3", @@ -2801,8 +2802,9 @@ } }, "node_modules/@gorhom/bottom-sheet": { - "version": "4.5.1", - "license": "MIT", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-4.6.1.tgz", + "integrity": "sha512-sXqsYqX1/rAbmCC5fb9o6hwSF3KXriC0EGUGvLlhFvjaEEMBrRKFTNndiluRK1HmpUzazVaYdTm/lLkSiA2ooQ==", "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" @@ -3657,17 +3659,6 @@ "react-native": "*" } }, - "node_modules/@kyupss/native-swipeable": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "prop-types": "15.8.1" - }, - "peerDependencies": { - "deprecated-react-native-prop-types": ">=2.2.0", - "react-native": ">=0.69.0" - } - }, "node_modules/@miblanchard/react-native-slider": { "version": "2.3.1", "license": "MIT", @@ -3828,9 +3819,9 @@ } }, "node_modules/@polito/api-client": { - "version": "1.0.0-ALPHA.58", - "resolved": "https://npm.pkg.github.com/download/@polito/api-client/1.0.0-ALPHA.58/b4c2734ab51495a2c98a7e7640479448b8ab5e09", - "integrity": "sha512-DdQY3kIVOWzhyI3VvBAo30zx9TAa8lJ4yDByRnSDy72oopSKNb2V0XVLouC0dw9wYgd4fG9DLuMHrAG5g7FBnQ==" + "version": "1.0.0-ALPHA.59", + "resolved": "https://npm.pkg.github.com/download/@polito/api-client/1.0.0-ALPHA.59/c63de66a809aa4cabbeca2ee4f207d1f6f7022a2", + "integrity": "sha512-3/ozilxN709YLgtss+g9gQPNtQ126uSU3GkUVABABrTZEjnR7koCUcU4HJER05nqlBCSu2YGfvg9RnbvY7Ba2w==" }, "node_modules/@react-native-async-storage/async-storage": { "version": "1.19.8", @@ -5780,11 +5771,6 @@ "version": "2.1.0", "license": "MIT" }, - "node_modules/@react-native/normalize-colors": { - "version": "0.73.2", - "license": "MIT", - "peer": true - }, "node_modules/@react-native/polyfills": { "version": "2.0.0", "license": "MIT" @@ -9029,16 +9015,13 @@ } }, "node_modules/deprecated-react-native-prop-types": { - "version": "5.0.0", - "license": "MIT", - "peer": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-2.3.0.tgz", + "integrity": "sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==", "dependencies": { - "@react-native/normalize-colors": "^0.73.0", - "invariant": "^2.2.4", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=18" + "@react-native/normalize-color": "*", + "invariant": "*", + "prop-types": "*" } }, "node_modules/destroy": { @@ -10622,6 +10605,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gifted-charts-core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/gifted-charts-core/-/gifted-charts-core-0.1.1.tgz", + "integrity": "sha512-qI4iE/zf4KA6nLAvWIdY42Cn4hATtfoRm0uO7zKEyHH68wMC7BurAbXgqLfg8u9YUsz9X/vo0NjGkmCVDbNlxw==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/git-raw-commits": { "version": "2.0.11", "dev": true, @@ -16755,6 +16747,29 @@ "react-native": "*" } }, + "node_modules/react-native-gifted-charts": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/react-native-gifted-charts/-/react-native-gifted-charts-1.4.8.tgz", + "integrity": "sha512-c4ntaNRDy+w3nZQD/Gz1KEmlls5geeaqTZ+iR1jYLORV+//6T2JrdJGRPsiZp/tVdgug4UeTJLVwfx0xVMoIjQ==", + "dependencies": { + "gifted-charts-core": "^0.1.1" + }, + "peerDependencies": { + "expo-linear-gradient": "*", + "react": "*", + "react-native": "*", + "react-native-linear-gradient": "*", + "react-native-svg": "*" + }, + "peerDependenciesMeta": { + "expo-linear-gradient": { + "optional": true + }, + "react-native-linear-gradient": { + "optional": true + } + } + }, "node_modules/react-native-gradle-plugin": { "version": "0.71.19", "license": "MIT" @@ -16801,6 +16816,15 @@ "version": "8.1.2", "license": "MIT" }, + "node_modules/react-native-linear-gradient": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.3.tgz", + "integrity": "sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-mime-types": { "version": "2.4.0", "license": "MIT", @@ -16863,15 +16887,6 @@ "react-native-blob-util": ">=0.13.7" } }, - "node_modules/react-native-pdf/node_modules/deprecated-react-native-prop-types": { - "version": "2.3.0", - "license": "MIT", - "dependencies": { - "@react-native/normalize-color": "*", - "invariant": "*", - "prop-types": "*" - } - }, "node_modules/react-native-permissions": { "version": "3.10.1", "license": "MIT", @@ -16986,15 +17001,6 @@ "shaka-player": "^2.5.9" } }, - "node_modules/react-native-video/node_modules/deprecated-react-native-prop-types": { - "version": "2.3.0", - "license": "MIT", - "dependencies": { - "@react-native/normalize-color": "*", - "invariant": "*", - "prop-types": "*" - } - }, "node_modules/react-native/node_modules/@jest/types": { "version": "26.6.2", "license": "MIT", diff --git a/package.json b/package.json index 93134d06..fe321f77 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,8 @@ "prepare": "husky install", "commit": "commit", "bump": "standard-version", - "lint:check": "eslint '**/*.{ts,tsx}'", "lint": "npm run lint:check -- --fix", - "format:check": "prettier --check '**/*.{ts,tsx,md}'", "format": "prettier --write '**/*.{ts,tsx,md}'", - "types:check": "tsc --noEmit", "check": "npm run lint:check && npm run format:check && npm run types:check", "postinstall": "react-native setup-ios-permissions && pod-install" }, @@ -24,12 +21,11 @@ "@fortawesome/free-regular-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-native-fontawesome": "^0.3.0", - "@gorhom/bottom-sheet": "^4.5.1", - "@kyupss/native-swipeable": "^1.0.1", + "@gorhom/bottom-sheet": "^4.6.1", "@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.58", + "@polito/api-client": "^1.0.0-ALPHA.59", "@react-native-async-storage/async-storage": "^1.17.11", "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/blur": "^4.3.0", @@ -74,11 +70,13 @@ "react-native-file-viewer": "^2.1.5", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.13.1", + "react-native-gifted-charts": "^1.4.8", "react-native-html-to-pdf": "^0.12.0", "react-native-image-crop-picker": "^0.38.0", "react-native-keyboard-accessory": "^0.1.16", "react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-keychain": "^8.1.1", + "react-native-linear-gradient": "^2.8.3", "react-native-mime-types": "^2.3.0", "react-native-modal": "^13.0.1", "react-native-override-color-scheme": "^1.0.3", diff --git a/src/core/queries/offeringHooks.ts b/src/core/queries/offeringHooks.ts index ee5fb4e1..53e35576 100644 --- a/src/core/queries/offeringHooks.ts +++ b/src/core/queries/offeringHooks.ts @@ -1,4 +1,9 @@ -import { Degree as ApiDegree, OfferingApi } from '@polito/api-client'; +import { + Degree as ApiDegree, + CourseStatistics, + OfferingApi, +} from '@polito/api-client'; +import { GetCourseStatisticsRequest } from '@polito/api-client/apis/OfferingApi'; import { MenuAction } from '@react-native-menu/menu'; import { useQuery } from '@tanstack/react-query'; @@ -84,3 +89,25 @@ export const useGetOfferingCourse = ({ }), ); }; + +export const useGetCourseStatistics = ({ + courseShortcode, + teacherId, + year, +}: GetCourseStatisticsRequest) => { + const offeringClient = useOfferingClient(); + + return useQuery( + compact([ + DEGREES_QUERY_PREFIX, + COURSES_QUERY_PREFIX, + courseShortcode, + year, + teacherId, + ]), + () => + offeringClient + .getCourseStatistics({ courseShortcode, teacherId, year }) + .then(pluckData), + ); +}; diff --git a/src/features/courses/components/CourseChart.tsx b/src/features/courses/components/CourseChart.tsx new file mode 100644 index 00000000..d2070f4d --- /dev/null +++ b/src/features/courses/components/CourseChart.tsx @@ -0,0 +1,625 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; +import { BarChart, LineChart } from 'react-native-gifted-charts'; + +import { Col } from '@lib/ui/components/Col'; +import { Row } from '@lib/ui/components/Row'; +import { Text } from '@lib/ui/components/Text'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { useTheme } from '@lib/ui/hooks/useTheme'; +import type { Theme } from '@lib/ui/types/Theme'; +import type { CourseStatistics } from '@polito/api-client'; +import { + GradeCount, + GradeCountGradeEnum, +} from '@polito/api-client/models/GradeCount'; + +import { barDataItem } from 'gifted-charts-core/src/BarChart/types'; + +import { useFeedbackContext } from '../../../core/contexts/FeedbackContext'; +import { NoChartDataContainer } from './NoChartDataContainer'; + +const kChartAnimationDuration = 200; // ms + +const emptyChartData = [ + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, + { value: 0 }, +]; + +const LegendItem = ({ + bulletColor, + text, + trailingText, +}: { + bulletColor: string; + text: string; + trailingText?: string; +}) => { + const styles = useStylesheet(createStyles); + return ( + + + + {text} + + {trailingText && ( + + {trailingText} + + )} + + ); +}; + +// groups "grade counts" by "grade value" and returns the list of the sums sorted by grade +// E.g. +// Think of our grades as a list: [18, 19, 20, ...] +// This helper will return [howMany18, howMany19, ...] +const gradesToChartValues = (gradeCounts: GradeCount[]) => { + const groupedCounts: { + [key in string]: number; + } = {}; + for (const value of Object.values(GradeCountGradeEnum)) { + groupedCounts[value] = 0; + } + + for (const gradeCount of gradeCounts) { + groupedCounts[gradeCount.grade] += gradeCount.count; + } + return Object.values(groupedCounts).map(it => ({ value: it })); +}; + +export const CourseGradesChart = ({ + statistics, + width, +}: { + width: number; + statistics: undefined | CourseStatistics; +}) => { + const { palettes, colors } = useTheme(); + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + const chartColors = [ + palettes.secondary[600], + palettes.lightBlue[600], + palettes.lightBlue[200], + ]; + + const data = gradesToChartValues(statistics?.firstYear?.grades ?? []); + const data1 = gradesToChartValues(statistics?.secondYear?.grades ?? []); + const data2 = gradesToChartValues(statistics?.otherYears?.grades ?? []); + + const hasData = [data, data1, data2].some(e => e.length > 0); + + return ( + + + + + + + + + {t('courseStatisticsScreen.gradesDetailLegend.averageGrade')} + + + + + + + ); +}; + +export const EnrolledExamDetailChart = ({ + statistics, + width, +}: { + width: number; + statistics: undefined | CourseStatistics; +}) => { + const { palettes, colors, fontSizes, spacing } = useTheme(); + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + const chartColors = [palettes.green[400], palettes.red[500]]; + const topLabelSpacing = spacing['1']; + const barRadius = spacing['0.5']; + + const barData: barDataItem[] = [ + { + value: statistics?.firstYear?.succeeded ?? 0, + label: t('courseStatisticsScreen.enrolledExamChartLabel.firstYear'), + spacing: 2, + labelWidth: 40, + labelTextStyle: { + fontSize: fontSizes['2xs'], + }, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.firstYear?.succeeded ?? 0} + + ), + }, + { + value: statistics?.firstYear?.failed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.firstYear?.failed ?? 0} + + ), + }, + { + value: statistics?.secondYear?.succeeded ?? 0, + label: t('courseStatisticsScreen.enrolledExamChartLabel.secondYear'), + spacing: 2, + labelWidth: 40, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: '-5%', + }, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.secondYear?.succeeded ?? 0} + + ), + }, + { + value: statistics?.secondYear?.failed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.secondYear?.failed ?? 0} + + ), + }, + { + value: statistics?.otherYears?.succeeded ?? 0, + label: t('courseStatisticsScreen.enrolledExamChartLabel.otherYears'), + spacing: 2, + labelWidth: 80, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: '-25%', + }, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.otherYears?.succeeded ?? 0} + + ), + }, + { + value: statistics?.otherYears?.failed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.otherYears?.failed ?? 0} + + ), + }, + ]; + + const hasData = barData.length > 0; + + return ( + + + + + + + + + + + ); +}; + +type VisualizationMode = 'single' | 'compare'; +export const EnrolledExamChart = ({ + statistics, + width, +}: { + width: number; + statistics: undefined | CourseStatistics; +}) => { + const { dark, palettes, colors, fontSizes, spacing } = useTheme(); + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + + const selectedSwitchColor = dark ? palettes.primary[600] : colors.surface; + const chartColors = [palettes.green[400], palettes.red[500]]; + const topLabelSpacing = spacing['1']; + const barRadius = spacing['0.5']; + const [mode, setMode] = useState('single'); + + const { setFeedback } = useFeedbackContext(); + let initialSpacing = spacing['4']; + let barWidth = 14; + let graphSpacing = Math.max( + width / ((statistics?.previousYearsToCompare?.length ?? 0) + 1) - + initialSpacing - + ((statistics?.previousYearsToCompare?.length ?? 0) * barWidth) / 2, + 35, + ); + + let barData: barDataItem[] = []; + + if (mode === 'single') { + initialSpacing = width / 6; + barWidth = width / 3; + graphSpacing = width / 8; + barData = [ + { + value: statistics?.totalSucceeded ?? 0, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.totalSucceeded ?? 0} + + ), + }, + { + value: statistics?.totalFailed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.totalFailed ?? 0} + + ), + }, + ]; + } + + if (mode === 'compare') { + const prevYears = + statistics?.previousYearsToCompare?.flatMap((prevYear, index) => { + return [ + { + value: prevYear.succeeded, + label: `${prevYear.year - 1} - ${prevYear.year}`, + spacing: 2, + labelWidth: index === 0 ? 100 : 80, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: index === 0 ? '-40%' : '-30%', // Warning: you will get different behaviours while changing these values if hot reload is enabled. To ensure your positioning is ok, set your value, navigate away and come back to see the actual result + color: colors.title, + }, + frontColor: `${palettes.green[400]}bb`, + topLabelComponent: () => ( + + {prevYear.succeeded} + + ), + }, + { + value: prevYear.failed, + frontColor: `${palettes.red[500]}bb`, + topLabelComponent: () => ( + + {prevYear.failed} + + ), + }, + ]; + }) ?? []; + + barData = [ + ...prevYears, + { + value: statistics?.totalSucceeded ?? 0, + label: `${statistics ? statistics?.year - 1 : 0} - ${ + statistics?.year ?? 0 + }`, + spacing: 2, + labelWidth: 80, + labelTextStyle: { + fontSize: fontSizes['2xs'], + position: 'relative', + left: '-30%', + }, + frontColor: palettes.green[400], + topLabelComponent: () => ( + + {statistics?.totalSucceeded ?? 0} + + ), + }, + { + value: statistics?.totalFailed ?? 0, + frontColor: palettes.red[500], + topLabelComponent: () => ( + + {statistics?.totalFailed ?? 0} + + ), + }, + ]; + } + + const onChangeVisualizationMode = (nextMode: VisualizationMode) => { + if (nextMode === mode) { + return; + } + + if ( + nextMode === 'compare' && + (statistics?.previousYearsToCompare === undefined || + statistics?.previousYearsToCompare?.length === 0) + ) { + setFeedback({ + text: t('courseStatisticsScreen.enrolledExamVisualization.error'), + }); + return; + } + + setMode(nextMode); + }; + + const hasData = barData.length > 0; + + return ( + + + + {t('courseStatisticsScreen.enrolledExamVisualization.title')} + + + onChangeVisualizationMode('single')} + accessibilityLabel={t( + 'courseStatisticsScreen.enrolledExamVisualization.single', + )} + style={{ + ...styles.buttonSwitchLabel, + fontWeight: mode === 'single' ? '500' : undefined, + backgroundColor: + mode === 'single' ? selectedSwitchColor : undefined, + }} + > + {t('courseStatisticsScreen.enrolledExamVisualization.single')} + + onChangeVisualizationMode('compare')} + accessibilityLabel={t( + 'courseStatisticsScreen.enrolledExamVisualization.compare', + )} + style={{ + ...styles.buttonSwitchLabel, + fontWeight: mode === 'compare' ? '500' : undefined, + backgroundColor: + mode === 'compare' ? selectedSwitchColor : undefined, + }} + > + {t('courseStatisticsScreen.enrolledExamVisualization.compare')} + + + + + + + + + + + + + + ); +}; + +const createStyles = ({ dark, spacing, colors, fontSizes }: Theme) => + StyleSheet.create({ + buttonSwitchLabel: { + fontSize: fontSizes.sm, + paddingVertical: spacing[1], + paddingHorizontal: spacing[2], + borderRadius: spacing[1], + }, + buttonSwitchTitle: { + alignSelf: 'flex-end', + fontSize: fontSizes.sm, + marginBottom: spacing[2], + }, + buttonSwitch: { + flexDirection: 'row', + alignSelf: 'flex-end', + backgroundColor: dark ? colors.surfaceDark : colors.background, + padding: spacing[1], + borderRadius: spacing[1], + gap: spacing[1], + }, + graphCard: { + padding: spacing[4], + }, + gradesChartLegendTitle: { + textAlign: 'right', + fontSize: fontSizes.xs, + marginVertical: spacing['4'], + }, + chartAxisLabel: { + fontSize: fontSizes['2xs'], + color: colors.title, + }, + chartLegendBullet: { + height: 8, + width: 8, + borderRadius: 8, + }, + chartLegendText: { + fontSize: fontSizes['2xs'], + }, + chartLegendTrailingText: { + marginLeft: 'auto', + fontSize: fontSizes.sm, + }, + }); diff --git a/src/features/courses/components/CourseStatisticsBottomSheetContent.tsx b/src/features/courses/components/CourseStatisticsBottomSheetContent.tsx new file mode 100644 index 00000000..e7300103 --- /dev/null +++ b/src/features/courses/components/CourseStatisticsBottomSheetContent.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { faClose } from '@fortawesome/free-solid-svg-icons'; +import { BottomSheetView } from '@gorhom/bottom-sheet'; +import { IconButton } from '@lib/ui/components/IconButton'; +import { Text } from '@lib/ui/components/Text'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { Theme } from '@lib/ui/types/Theme'; + +export const CourseStatisticsBottomSheetContent = ({ + title, + content, + itemList, + onDismiss, +}: { + title: string; + content: string; + itemList: { title: string; content: string }[]; + onDismiss: () => void; +}) => { + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + return ( + + + + {title} + + + + + {content} + {itemList.map((item, index) => { + return ( + + {`\u2022`} + + + {`${item.title}:`}{' '} + {item.content} + + + + ); + })} + + + ); +}; + +const createStyles = ({ dark, fontSizes, colors, spacing }: Theme) => + StyleSheet.create({ + container: { + backgroundColor: colors.surface, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: dark ? colors.surfaceDark : colors.background, + paddingVertical: spacing[2], + }, + headerTitle: { + marginLeft: 'auto', + marginRight: 'auto', + fontSize: fontSizes.lg, + textAlign: 'center', + }, + content: { + padding: spacing[4], + gap: spacing[4], + }, + listItem: { + flexDirection: 'row', + }, + listItemTitle: { + fontWeight: 'bold', + }, + }); diff --git a/src/features/courses/components/CourseStatisticsBottomSheets.tsx b/src/features/courses/components/CourseStatisticsBottomSheets.tsx new file mode 100644 index 00000000..d5a27022 --- /dev/null +++ b/src/features/courses/components/CourseStatisticsBottomSheets.tsx @@ -0,0 +1,183 @@ +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +import React, { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + BottomSheetModal, + BottomSheetModalProvider, +} from '@gorhom/bottom-sheet'; +import { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; +import { BottomSheet } from '@lib/ui/components/BottomSheet'; + +import { GlobalStyles } from '../../../core/styles/GlobalStyles'; +import { CourseStatisticsBottomSheetContent } from './CourseStatisticsBottomSheetContent'; + +type ChildrenParams = { + onPresentEnrolledExamModalPress: () => void; + onPresentEnrolledExamDetailModalPress: () => void; + onPresentGradesDetailModalPress: () => void; +}; +type Props = { + children: (params: ChildrenParams) => React.ReactElement; +}; +export const CourseStatisticsBottomSheets = ({ children }: Props) => { + const { t } = useTranslation(); + const enrolledExamBottomSheetModalRef = useRef(null); + const enrolledExamDetailBottomSheetModalRef = useRef(null); + const gradesDetailBottomSheetModalRef = useRef(null); + + const onPresentEnrolledExamModalPress = useCallback(() => { + enrolledExamBottomSheetModalRef.current?.snapToIndex(1); + }, []); + + const onDismissEnrolledExamModal = useCallback(() => { + enrolledExamBottomSheetModalRef.current?.close(); + }, []); + const onPresentEnrolledExamDetailModalPress = useCallback(() => { + enrolledExamDetailBottomSheetModalRef.current?.snapToIndex(1); + }, []); + const onDismissEnrolledExamDetailModal = useCallback(() => { + enrolledExamDetailBottomSheetModalRef.current?.close(); + }, []); + const onPresentGradesDetailModalPress = useCallback(() => { + gradesDetailBottomSheetModalRef.current?.snapToIndex(1); + }, []); + const onDismissGradesDetailModal = useCallback(() => { + gradesDetailBottomSheetModalRef.current?.close(); + }, []); + + const snapPoints = [1, '60%', '100%']; + + return ( + + + {children({ + onPresentEnrolledExamModalPress, + onPresentEnrolledExamDetailModalPress, + onPresentGradesDetailModalPress, + })} + + + + + + + + + + + + ); +}; diff --git a/src/features/courses/components/CourseStatisticsFilters.tsx b/src/features/courses/components/CourseStatisticsFilters.tsx new file mode 100644 index 00000000..3b5ce9eb --- /dev/null +++ b/src/features/courses/components/CourseStatisticsFilters.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import { Card } from '@lib/ui/components/Card'; +import { Grid } from '@lib/ui/components/Grid'; +import { Icon } from '@lib/ui/components/Icon'; +import { Row } from '@lib/ui/components/Row'; +import { StatefulMenuView } from '@lib/ui/components/StatefulMenuView'; +import { Text } from '@lib/ui/components/Text'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { Theme } from '@lib/ui/types/Theme'; + +import { GlobalStyles } from '../../../core/styles/GlobalStyles'; +import { StatisticsFilters } from '../utils/computeStatisticsFilters'; + +export const CourseStatisticsFilters = ({ + teachers, + years, + onTeacherChanged, + onYearChanged, + currentYear, + currentTeacher, +}: StatisticsFilters & { + onTeacherChanged: (teacherId: string) => void; + onYearChanged: (year: string) => void; +}) => { + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + return ( + + + + + {t('courseStatisticsScreen.period')} + + { + onYearChanged(nativeEvent.event); + }} + actions={years} + > + + + {currentYear?.title ?? '--'} + + + + + + + + {t('courseStatisticsScreen.teacher')} + + { + onTeacherChanged(nativeEvent.event); + }} + actions={teachers} + > + + + {currentTeacher?.title ?? '--'} + + + + + + + + ); +}; + +const createStyles = ({ spacing, fontSizes, colors }: Theme) => + StyleSheet.create({ + label: { + fontSize: fontSizes.xs, + marginBottom: spacing[0.5], + }, + dropdownText: { + flex: 1, + color: colors.heading, + fontSize: fontSizes.md, + }, + }); diff --git a/src/features/courses/components/NoChartDataContainer.tsx b/src/features/courses/components/NoChartDataContainer.tsx new file mode 100644 index 00000000..bcc30834 --- /dev/null +++ b/src/features/courses/components/NoChartDataContainer.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, Text, View } from 'react-native'; + +import { faChartSimple } from '@fortawesome/free-solid-svg-icons'; +import { Icon } from '@lib/ui/components/Icon'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { useTheme } from '@lib/ui/hooks/useTheme'; +import type { Theme } from '@lib/ui/types/Theme'; + +export const NoChartDataContainer = ({ + hasData, + children, +}: { + hasData: boolean; + children: React.ReactNode; +}) => { + const { colors } = useTheme(); + const { t } = useTranslation(); + const styles = useStylesheet(createStyles); + + if (hasData) return {children}; + + return ( + + + + {t('courseStatisticsScreen.noData')} + + + {children} + + ); +}; + +const createStyles = ({ colors, fontSizes, fontWeights }: Theme) => + StyleSheet.create({ + view: { + position: 'relative', + }, + title: { + color: colors.tabBarInactive, + fontSize: fontSizes.md, + fontWeight: fontWeights.semibold, + }, + container: { + position: 'absolute', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + top: 0, + zIndex: 1, + alignSelf: 'center', + height: '100%', + }, + }); diff --git a/src/features/courses/navigation/CourseSharedScreens.tsx b/src/features/courses/navigation/CourseSharedScreens.tsx index 2223303d..0bdc09be 100644 --- a/src/features/courses/navigation/CourseSharedScreens.tsx +++ b/src/features/courses/navigation/CourseSharedScreens.tsx @@ -130,6 +130,7 @@ export const CourseSharedScreens = ( headerBackTitle: t('common.course'), }} /> + { ); const isGuideDisabled = useOfflineDisabled(isGuideDataMissing); + const isStatisticsDisabled = !courseQuery.data?.shortcode; return ( { /> - {/*
- - -
*/}
{ linkTo={{ screen: 'CourseGuide', params: { courseId } }} disabled={isGuideDisabled} /> +
diff --git a/src/features/courses/screens/CourseStatisticsScreen.tsx b/src/features/courses/screens/CourseStatisticsScreen.tsx new file mode 100644 index 00000000..ef6b05ab --- /dev/null +++ b/src/features/courses/screens/CourseStatisticsScreen.tsx @@ -0,0 +1,210 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dimensions, + Platform, + SafeAreaView, + ScrollView, + StyleSheet, + View, +} from 'react-native'; + +import { faQuestionCircle } from '@fortawesome/free-regular-svg-icons'; +import { Card } from '@lib/ui/components/Card'; +import { IconButton } from '@lib/ui/components/IconButton'; +import { LoadingContainer } from '@lib/ui/components/LoadingContainer'; +import { SectionHeader } from '@lib/ui/components/SectionHeader'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { useTheme } from '@lib/ui/hooks/useTheme'; +import { Theme } from '@lib/ui/types/Theme'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; + +import { useGetCourseStatistics } from '../../../core/queries/offeringHooks'; +import { SharedScreensParamList } from '../../../shared/navigation/SharedScreens'; +import { + CourseGradesChart, + EnrolledExamChart, + EnrolledExamDetailChart, +} from '../components/CourseChart'; +import { CourseStatisticsBottomSheets } from '../components/CourseStatisticsBottomSheets'; +import { CourseStatisticsFilters } from '../components/CourseStatisticsFilters'; +import { computeStatisticsFilters } from '../utils/computeStatisticsFilters'; + +type Props = NativeStackScreenProps; +export const CourseStatisticsScreen = ({ route }: Props) => { + const { t } = useTranslation(); + const { courseShortcode: shortCode, year, teacherId } = route.params; + + const { spacing, colors } = useTheme(); + const [currentFilters, setCurrentFilters] = useState<{ + currentYear?: string; + currentTeacherId?: string; + }>({ + currentYear: year ? String(year) : undefined, + currentTeacherId: teacherId ? String(teacherId) : undefined, + }); + + const { data: statistics, isLoading } = useGetCourseStatistics({ + courseShortcode: shortCode, + teacherId: currentFilters.currentTeacherId, + year: currentFilters.currentYear, + }); + + const filters = useMemo(() => { + return computeStatisticsFilters( + statistics, + currentFilters.currentYear, + currentFilters.currentTeacherId, + ); + }, [currentFilters.currentYear, statistics, currentFilters.currentTeacherId]); + + useEffect(() => { + const shouldSetYearFromStatistics = + currentFilters.currentYear === undefined && + statistics?.year !== undefined; + const shouldSetTeacherFromStatistics = + currentFilters.currentTeacherId === undefined && + statistics?.teacher !== undefined; + + if (shouldSetYearFromStatistics || shouldSetTeacherFromStatistics) { + setCurrentFilters(prev => ({ + ...prev, + currentYear: statistics?.year ? String(statistics.year) : undefined, + currentTeacherId: statistics?.teacher + ? String(statistics.teacher.id) + : undefined, + })); + } + }, [statistics, currentFilters]); + + const styles = useStylesheet(createStyles); + + const graphWidth = + Dimensions.get('window').width - + Platform.select({ ios: 128, android: 100 })!; + + return ( + + {({ + onPresentEnrolledExamModalPress, + onPresentEnrolledExamDetailModalPress, + onPresentGradesDetailModalPress, + }) => { + return ( + + + { + setCurrentFilters(prev => ({ + ...prev, + currentTeacherId: nextTeacherId, + })); + }} + onYearChanged={nextYear => { + setCurrentFilters(prev => ({ + ...prev, + currentYear: nextYear, + })); + }} + /> + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + + + ); + }} + + ); +}; + +const createStyles = ({ spacing }: Theme) => + StyleSheet.create({ + container: { + gap: spacing[2], + }, + }); diff --git a/src/features/courses/utils/computeStatisticsFilters.ts b/src/features/courses/utils/computeStatisticsFilters.ts new file mode 100644 index 00000000..8f0f8b01 --- /dev/null +++ b/src/features/courses/utils/computeStatisticsFilters.ts @@ -0,0 +1,56 @@ +import { CourseStatistics, Teacher } from '@polito/api-client'; +import { MenuAction } from '@react-native-menu/menu'; + +const teachersToMenuAction = ( + teachers: Teacher[], + currentTeacherId?: string, +): MenuAction[] => + teachers.map(t => { + return { + id: String(t.id), + title: `${t.firstName} ${t.lastName}`, + state: currentTeacherId === t.id.toString() ? 'on' : 'off', + }; + }); + +export const formatYearPeriod = (year: number) => { + const formattedYear = String(year).slice(-2); + return `${year - 1}/${formattedYear}`; +}; + +const yearsToMenuAction = ( + years: number[], + selectedYear?: string, +): MenuAction[] => + years.map(y => { + return { + id: String(y), + title: formatYearPeriod(y), + state: selectedYear && parseInt(selectedYear, 10) === y ? 'on' : 'off', + }; + }); + +export type StatisticsFilters = { + currentTeacher?: MenuAction; + teachers: MenuAction[]; + currentYear?: MenuAction; + years: MenuAction[]; +}; + +export const computeStatisticsFilters = ( + statistics: undefined | CourseStatistics, + year: undefined | string, + teacherId: undefined | string, +): StatisticsFilters => { + const teachers = teachersToMenuAction(statistics?.teachers ?? [], teacherId); + const years = yearsToMenuAction(statistics?.years ?? [], year); + const currentTeacher = teachers.find(it => it.state === 'on'); + const currentYear = years.find(it => it.state === 'on'); + + return { + currentTeacher, + teachers, + currentYear, + years, + }; +}; diff --git a/src/features/offering/screens/DegreeCourseScreen.tsx b/src/features/offering/screens/DegreeCourseScreen.tsx index 18c2c9de..4d6900bd 100644 --- a/src/features/offering/screens/DegreeCourseScreen.tsx +++ b/src/features/offering/screens/DegreeCourseScreen.tsx @@ -11,6 +11,7 @@ import { import { faAngleDown, faBriefcase, + faChartLine, faFlaskVial, faMicroscope, faPersonChalkboard, @@ -159,63 +160,73 @@ export const DegreeCourseScreen = ({ route }: Props) => { - {offeringCourse?.hours && - offeringCourse.hours?.lecture && - offeringCourse.hours?.classroomExercise && - offeringCourse.hours?.labExercise && - offeringCourse.hours?.tutoring && ( - - {!!offeringCourse?.hours?.lecture && ( - } - /> - )} - {!!offeringCourse?.hours?.classroomExercise && ( - } - /> - )} - {!!offeringCourse?.hours?.labExercise && ( - } - /> - )} - {!!offeringCourse?.hours?.tutoring && ( - } - /> - )} - + + + {!!offeringCourse?.hours?.lecture && ( + } + /> + )} + {!!offeringCourse?.hours?.classroomExercise && ( + } + /> + )} + {!!offeringCourse?.hours?.labExercise && ( + } + /> + )} + {!!offeringCourse?.hours?.tutoring && ( + } + /> + )} + {offeringCourse && ( + } + /> )} + +
+ `${params.courseId}${params.courseShortcode}`} + options={{ + headerTitle: t('courseStatisticsScreen.title'), + headerBackTitle: t('common.course'), + }} + />