diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2486d124..945f689b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 2AA2AD45DF43DBD6C86F260F /* [CP] Copy Pods Resources */ = { + 16B2240B2DE5AC5E5EF1373D /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-resources-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-resources-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-resources-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 5BC659194DA1E98530C941C5 /* [CP] Copy Pods Resources */ = { + 2357FBBD3292AB1C54FDC13B /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -453,7 +448,7 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students-students dev/Pods-students-students dev-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 9131B957801020485B33827F /* [CP] Embed Pods Frameworks */ = { + 2DABAC0BA53430A70EF495EF /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -470,7 +465,7 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students-students dev/Pods-students-students dev-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - B44D6C0829F17BF2001BA0DC /* Start Packager */ = { + 381631C31357DD0319BEE2F7 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -478,34 +473,38 @@ inputFileListPaths = ( ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); - name = "Start Packager"; + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( ); outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-students-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - B44D6C1C29F17BF2001BA0DC /* Bundle React Native code and images */ = { + 4FADE446B3CDE0A64F7C222A /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputPaths = ( - "$(SRCROOT)/.xcode.env.local", - "$(SRCROOT)/.xcode.env", + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "Bundle React Native code and images"; - outputPaths = ( + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export SOURCEMAP_FILE=\"../main.jsbundle.map\";\n\nset -e\n\nWITH_ENVIRONMENT=\"../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"../node_modules/react-native/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; - B5545B0A242D45FDA2271EBA /* [CP] Check Pods Manifest.lock */ = { + 5B44C323B8E9DBEC9D4CD59A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -520,31 +519,14 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-students-studentsTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-students-students dev-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - D5478D7039E563F3001F096F /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - DCB045EA8B12F2B47D2E4386 /* [CP] Check Pods Manifest.lock */ = { + 67D97E4AE81B265C2F8EB10F /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -559,47 +541,65 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-students-students dev-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-students-studentsTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - E7043618403C2A9926287F0D /* [CP] Embed Pods Frameworks */ = { + B37C2CF46457D5CFA96ED4B9 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-frameworks-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-frameworks-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students/Pods-students-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - E70E13E9B9D96B834B9475A9 /* [CP] Embed Pods Frameworks */ = { + B44D6C0829F17BF2001BA0DC /* Start Packager */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + ); + name = "Start Packager"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-students-studentsTests/Pods-students-studentsTests-frameworks.sh\"\n"; + shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n"; showEnvVarsInLog = 0; }; + B44D6C1C29F17BF2001BA0DC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/.xcode.env", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export SOURCEMAP_FILE=\"../main.jsbundle.map\";\n\nset -e\n\nWITH_ENVIRONMENT=\"../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"../node_modules/react-native/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; + }; FD10A7F022414F080027D42C /* Start Packager */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -661,7 +661,7 @@ /* Begin XCBuildConfiguration section */ 00E356F61AD99517003FC87E /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1F5CAD9CCE144ABBC4A1E2AD /* Pods-students-studentsTests.debug.xcconfig */; + baseConfigurationReference = 1AC5E9FB62F89FD847F90A22 /* Pods-students-studentsTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -689,7 +689,7 @@ }; 00E356F71AD99517003FC87E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A89BF9271527FE05E0DAD5C3 /* Pods-students-studentsTests.release.xcconfig */; + baseConfigurationReference = AEBF1D407B9C30DA0211A468 /* Pods-students-studentsTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; COPY_PHASE_STRIP = NO; @@ -714,12 +714,12 @@ }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 74500F0E62F28A2E3978DD2B /* Pods-students.debug.xcconfig */; + baseConfigurationReference = AF5AADD6B1730D5CC1C6F305 /* Pods-students.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; @@ -749,14 +749,16 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8C018703C9B652EB5516599E /* Pods-students.release.xcconfig */; + baseConfigurationReference = A3574EB3869C9AE2FD9652E1 /* Pods-students.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = RS2Y9A4G25; INFOPLIST_FILE = students/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "PoliTO Students"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; @@ -774,6 +776,7 @@ "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = "it.polito.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = students; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore it.polito.students"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -922,7 +925,7 @@ }; B44D6C2029F17BF2001BA0DC /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AA1FEA064BA1471C12F6F460 /* Pods-students-students dev.debug.xcconfig */; + baseConfigurationReference = E8CEE59A7533C9A24FEC8694 /* Pods-students-students dev.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDev; CLANG_ENABLE_MODULES = YES; @@ -957,7 +960,7 @@ }; B44D6C2129F17BF2001BA0DC /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D9C0C34BC3484398D8B75445 /* Pods-students-students dev.release.xcconfig */; + baseConfigurationReference = B07BE0BF3F2E37AC10DCF152 /* Pods-students-students dev.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDev; CLANG_ENABLE_MODULES = YES; diff --git a/ios/students/Info.plist b/ios/students/Info.plist index 61eaf7c0..d75e47b1 100644 --- a/ios/students/Info.plist +++ b/ios/students/Info.plist @@ -30,8 +30,10 @@ NSCameraUsageDescription Scan documents + NSLocationAlwaysAndWhenInUseUsageDescription + Access to current position to check-in bookings NSLocationWhenInUseUsageDescription - + Check in bookings NSPhotoLibraryUsageDescription Scan documents and pick images for assignments and ticket attachments UIAppFonts diff --git a/lib/ui/components/CtaButton.tsx b/lib/ui/components/CtaButton.tsx index c44ad92c..04b653bd 100644 --- a/lib/ui/components/CtaButton.tsx +++ b/lib/ui/components/CtaButton.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { Platform, StyleSheet, @@ -15,6 +16,7 @@ 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 { shadeColor } from '@lib/ui/utils/colors'; import { useFeedbackContext } from '../../../src/core/contexts/FeedbackContext'; import { useSafeBottomBarHeight } from '../../../src/core/hooks/useSafeBottomBarHeight'; @@ -27,7 +29,9 @@ interface Props extends TouchableHighlightProps { rightExtra?: JSX.Element; loading?: boolean; action: () => unknown | Promise; + variant?: 'filled' | 'outlined'; destructive?: boolean; + success?: boolean; hint?: string; } @@ -41,19 +45,55 @@ export const CtaButton = ({ loading, disabled, destructive = false, + success = false, action, icon, rightExtra, hint, containerStyle, + variant = 'filled', ...rest }: Props) => { - const { palettes, fontSizes, spacing } = useTheme(); + const { palettes, colors, fontSizes, spacing, dark } = useTheme(); const styles = useStylesheet(createStyles); const { left, right } = useSafeAreaInsets(); const bottomBarHeight = useSafeBottomBarHeight(); const { isFeedbackVisible } = useFeedbackContext(); + const outlined = variant === 'outlined'; + + const underlayColor = useMemo(() => { + if (variant === 'outlined') { + if (dark) return shadeColor(colors.background, 20); + else return shadeColor(colors.background, -10); + } else { + if (destructive) return palettes.danger[700]; + return palettes.primary[500]; + } + }, [ + colors.background, + dark, + destructive, + palettes.danger, + palettes.primary, + variant, + ]); + + const color = useMemo(() => { + if (success) { + return dark ? palettes.success[400] : palettes.success[700]; + } + if (destructive) return palettes.danger[600]; + return palettes.primary[400]; + }, [ + dark, + destructive, + palettes.danger, + palettes.primary, + palettes.success, + success, + ]); + return ( {hint}} - {loading && } + {loading && ( + + )} {/* {!loading && ( */} @@ -102,11 +153,23 @@ export const CtaButton = ({ )} - {title} + + {title} + {rightExtra && rightExtra} @@ -158,7 +221,7 @@ const createStyles = ({ fontSize: fontSizes.md, fontWeight: fontWeights.medium, textAlign: 'center', - color: 'white', + color: colors.white, }, icon: { marginVertical: -2, diff --git a/lib/ui/components/CtaButtonContainer.tsx b/lib/ui/components/CtaButtonContainer.tsx new file mode 100644 index 00000000..e1e32482 --- /dev/null +++ b/lib/ui/components/CtaButtonContainer.tsx @@ -0,0 +1,45 @@ +import { Children, PropsWithChildren } from 'react'; +import { Platform, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from '@lib/ui/hooks/useTheme'; + +import { useFeedbackContext } from '../../../src/core/contexts/FeedbackContext'; +import { useSafeBottomBarHeight } from '../../../src/core/hooks/useSafeBottomBarHeight'; + +interface Props { + absolute: boolean; +} + +export const CtaButtonContainer = ({ + absolute = true, + children, +}: PropsWithChildren) => { + const { left, right } = useSafeAreaInsets(); + const bottomBarHeight = useSafeBottomBarHeight(); + const { isFeedbackVisible } = useFeedbackContext(); + + const { spacing } = useTheme(); + return ( + + {children} + + ); +}; diff --git a/lib/ui/components/ScreenDateTime.tsx b/lib/ui/components/ScreenDateTime.tsx new file mode 100644 index 00000000..b4dc49ef --- /dev/null +++ b/lib/ui/components/ScreenDateTime.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; + +import { faCalendar, faClock } from '@fortawesome/free-regular-svg-icons'; +import { Icon } from '@lib/ui/components/Icon'; +import { Row } from '@lib/ui/components/Row'; +import { Text } from '@lib/ui/components/Text'; +import { useTheme } from '@lib/ui/hooks/useTheme'; + +interface Props { + accessible?: boolean; + date?: string; + time?: string; + inListItem?: boolean; +} + +export const ScreenDateTime = ({ + accessible, + date, + time, + inListItem = false, +}: Props) => { + const { colors, dark, fontSizes, palettes } = useTheme(); + + const color = useMemo(() => { + if (!inListItem) return colors.prose; + return dark ? palettes.gray[400] : palettes.gray[500]; + }, [colors.prose, dark, inListItem, palettes.gray]); + + return ( + + + + {date ?? ''} + + + + {time ?? ''} + + + ); +}; diff --git a/lib/ui/components/Section.tsx b/lib/ui/components/Section.tsx index 0073c6f7..4ce58583 100644 --- a/lib/ui/components/Section.tsx +++ b/lib/ui/components/Section.tsx @@ -1,13 +1,13 @@ import { PropsWithChildren } from 'react'; import { Platform, ViewProps } from 'react-native'; -import { Col } from '@lib/ui/components/Col'; +import { Col, ColProps } from '@lib/ui/components/Col'; export const Section = ({ style, children, ...rest -}: PropsWithChildren) => { +}: PropsWithChildren) => { return ( =16.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native-community/netinfo": { "version": "9.3.4", "license": "MIT", @@ -6139,6 +6154,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geopoint": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/geopoint/-/geopoint-1.0.1.tgz", + "integrity": "sha512-u0NMCW2hIxV970oUbyzfwi3aRiAghvUfbEtq3yDHoKgda67bUMsXl3Q7J6kKCFWbjZuQo6n+79IIT5mRTy6UlA==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.6", "dev": true, @@ -10175,6 +10196,14 @@ "node": ">=6.9.0" } }, + "node_modules/geopoint": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/geopoint/-/geopoint-1.0.1.tgz", + "integrity": "sha512-ojNWKDu4hdY2MeLUIiJH9cjP2s2BrVf9P2tuIvUPS8Hht0FzZpoZEAEmOa21eUCi8ahk1XBD3reSjPjsyVujkg==", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "license": "ISC", diff --git a/package.json b/package.json index fece4ab4..af4b7346 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,10 @@ "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-native-fontawesome": "^0.3.0", "@miblanchard/react-native-slider": "^2.2.0", - "@polito/api-client": "1.0.0-ALPHA.40", + "@polito/api-client": "^1.0.0-ALPHA.41", "@react-native-async-storage/async-storage": "^1.17.11", "@react-native-community/blur": "^4.3.0", + "@react-native-community/geolocation": "^3.1.0", "@react-native-community/netinfo": "^9.3.4", "@react-native-menu/menu": "^0.7.3", "@react-navigation/bottom-tabs": "^6.5.8", @@ -43,6 +44,7 @@ "@typescript-eslint/eslint-plugin": "^5.50.0", "color": "^4.2.3", "domutils": "^3.0.1", + "geopoint": "^1.0.1", "htmlparser2": "^8.0.1", "i18next": "^21.10.0", "intl": "^1.2.5", @@ -92,6 +94,7 @@ "@trivago/prettier-plugin-sort-imports": "^3.3.0", "@tsconfig/react-native": "^2.0.2", "@types/color": "^3.0.3", + "@types/geopoint": "^1.0.1", "@types/jest": "^29.2.1", "@types/lodash": "^4.14.192", "@types/luxon": "^3.2.0", diff --git a/src/core/components/EventDetails.tsx b/src/core/components/EventDetails.tsx index a871ec01..e6633a72 100644 --- a/src/core/components/EventDetails.tsx +++ b/src/core/components/EventDetails.tsx @@ -9,7 +9,7 @@ import { formatTime } from '../../utils/dates'; type Props = ViewProps & { title?: string; type?: string; - time?: string; + time?: string | JSX.Element; endTime?: Date; timeLabel?: string; }; @@ -29,12 +29,15 @@ export const EventDetails = ({ {type} - {time && ( - - {time} - {endTime && ` - ${formatTime(endTime)}`} - - )} + {time && + (typeof time === 'string' ? ( + + {time} + {endTime && ` - ${formatTime(endTime)}`} + + ) : ( + time + ))} {!!timeLabel && ( {timeLabel} )} diff --git a/src/core/hooks/useGeolocation.ts b/src/core/hooks/useGeolocation.ts new file mode 100644 index 00000000..441ec384 --- /dev/null +++ b/src/core/hooks/useGeolocation.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking } from 'react-native'; + +import Geolocation from '@react-native-community/geolocation'; + +import GeoPoint from 'geopoint'; + +import { IS_ANDROID } from '../constants'; +import { useFeedbackContext } from '../contexts/FeedbackContext'; + +export type Coordinates = { + latitude: number; + longitude: number; +}; + +Geolocation.setRNConfiguration({ + skipPermissionRequests: false, + authorizationLevel: 'whenInUse', + enableBackgroundLocationUpdates: true, + locationProvider: 'auto', +}); + +const openSettings = () => { + if (IS_ANDROID) { + Linking.sendIntent('android.settings.LOCATION_SOURCE_SETTINGS').catch( + console.error, + ); + } else { + Linking.openURL('app-settings:').catch(console.error); + } +}; + +export const computeDistance = (a?: Coordinates, b?: Coordinates) => { + if (!a || !b) return 0; + + const pointA = new GeoPoint(a.latitude, a.longitude); + const pointB = new GeoPoint(b.latitude, b.longitude); + + return pointA.distanceTo(pointB, true); +}; + +export const useGeolocation = () => { + const { setFeedback } = useFeedbackContext(); + const { t } = useTranslation(); + + const getCurrentPosition = useCallback(() => { + return new Promise((resolve, reject) => { + Geolocation.getCurrentPosition( + ({ coords: { latitude, longitude } }) => { + resolve({ latitude, longitude }); + }, + error => { + if (error.code === 2) { + setFeedback({ + text: t('common.enableLocationServiceFeedback'), + isPersistent: false, + }); + } + reject(error); + }, + ); + }); + }, [t]); + + useEffect(() => { + Geolocation.requestAuthorization( + () => console.debug('Geolocation permission granted'), + error => { + if (error.code === 1) { + setFeedback({ + text: t('common.enableLocationPermissionFeedback'), + action: { + label: t('common.openSettings'), + onPress: openSettings, + }, + isPersistent: true, + }); + } + }, + ); + }, [t]); + + return { + getCurrentPosition, + computeDistance, + }; +}; diff --git a/src/core/queries/bookingHooks.ts b/src/core/queries/bookingHooks.ts index fbdc2773..818a3a47 100644 --- a/src/core/queries/bookingHooks.ts +++ b/src/core/queries/bookingHooks.ts @@ -1,9 +1,14 @@ import { BookingsApi } from '@polito/api-client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { DateTime } from 'luxon'; + import { pluckData } from '../../utils/queries'; export const BOOKINGS_QUERY_KEY = ['bookings']; +export const BOOKINGS_TOPICS_QUERY_KEY = ['bookings', 'topics']; +export const BOOKINGS_SLOTS_QUERY_KEY = ['bookings', 'slots']; +export const BOOKINGS_SEATS_QUERY_KEY = ['bookings', 'seats']; const useBookingClient = (): BookingsApi => { return new BookingsApi(); @@ -17,6 +22,92 @@ export const useGetBookings = () => { ); }; +export const useGetBookingTopics = () => { + const bookingClient = useBookingClient(); + + return useQuery(BOOKINGS_TOPICS_QUERY_KEY, () => + bookingClient.getBookingTopics().then(pluckData), + ); +}; + +export const useGetBookingSlots = (bookingTopicId: string) => { + const bookingClient = useBookingClient(); + + return useQuery(BOOKINGS_SLOTS_QUERY_KEY, () => + bookingClient + .getBookingSlots({ + bookingTopicId, + fromDate: DateTime.fromObject({ + day: 1, + month: 11, + year: 2023, + }).toJSDate(), + toDate: DateTime.fromObject({ + day: 3, + month: 11, + year: 2023, + }).toJSDate(), + }) + .then(pluckData), + ); +}; + +export const useGetBookingSeats = ( + bookingTopicId: string, + bookingSlotId: string, +) => { + const bookingClient = useBookingClient(); + + return useQuery(BOOKINGS_SEATS_QUERY_KEY, () => + bookingClient + .getBookingSeats({ bookingTopicId, bookingSlotId }) + .then(pluckData), + ); +}; + +export const useUpdateBooking = () => { + const bookingClient = useBookingClient(); + const queryClient = useQueryClient(); + return useMutation( + ({ + bookingId, + isLocationChecked, + }: { + bookingId: number; + isLocationChecked: boolean; + }) => + bookingClient.updateBooking({ + bookingId, + updateBookingRequest: { isLocationChecked }, + }), + { + onSuccess() { + return queryClient.invalidateQueries(BOOKINGS_QUERY_KEY); + }, + }, + ); +}; + +export const useCreateBooking = () => { + const bookingClient = useBookingClient(); + const client = useQueryClient(); + + return useMutation( + ({ slotId, seatId }: { slotId: number; seatId?: number }) => + bookingClient.createBooking({ + createBookingRequest: { + slotId, + seatId, + }, + }), + { + onSuccess() { + return client.invalidateQueries(BOOKINGS_QUERY_KEY); + }, + }, + ); +}; + export const useDeleteBooking = (bookingId: number) => { const bookingClient = useBookingClient(); const client = useQueryClient(); diff --git a/src/core/queries/courseHooks.ts b/src/core/queries/courseHooks.ts index 4a2c1b99..4a1083f4 100644 --- a/src/core/queries/courseHooks.ts +++ b/src/core/queries/courseHooks.ts @@ -6,11 +6,11 @@ import { CourseDirectoryContentInner, CourseFileOverview, CourseOverview, + CourseOverviewPreviousEditionsInner, + CourseVcOtherCoursesInner, CoursesApi, UploadCourseAssignmentRequest, } from '@polito/api-client'; -import { CourseOverviewPreviousEditionsInner } from '@polito/api-client/models/CourseOverviewPreviousEditionsInner'; -import { CourseVcOtherCoursesInner } from '@polito/api-client/models/CourseVcOtherCoursesInner'; import { useMutation, useQueries, diff --git a/src/core/themes/light.ts b/src/core/themes/light.ts index c0dafce2..de9ec0f9 100644 --- a/src/core/themes/light.ts +++ b/src/core/themes/light.ts @@ -118,6 +118,7 @@ export const lightTheme: Theme = { background: backgroundColor, surface: '#FFFFFF', surfaceDark: '#143959', + white: '#FFFFFF', headersBackground: IS_ANDROID ? '#FFFFFF' : '#EDEEF0', heading: navy[700], subHeading: lightBlue[700], diff --git a/src/features/agenda/components/ExamCard.tsx b/src/features/agenda/components/ExamCard.tsx index 7dfb56ff..f3ae7f57 100644 --- a/src/features/agenda/components/ExamCard.tsx +++ b/src/features/agenda/components/ExamCard.tsx @@ -28,7 +28,7 @@ export const ExamCard = ({ item, compact = false }: Props) => { time={ item.isTimeToBeDefined ? t('common.timeToBeDefined') : item.fromTime } - location={item.classroom} + location={item.places?.map(place => place.name).join(', ')} onPress={() => navigate({ name: 'Exam', diff --git a/src/features/agenda/queries/agendaHooks.ts b/src/features/agenda/queries/agendaHooks.ts index 29131e67..e0c5c868 100644 --- a/src/features/agenda/queries/agendaHooks.ts +++ b/src/features/agenda/queries/agendaHooks.ts @@ -55,7 +55,7 @@ const groupItemsByDay = ( fromTime: formatTime(exam.examStartsAt!), isTimeToBeDefined: exam.isTimeToBeDefined, title: exam.courseName, - classroom: exam?.classrooms, + places: exam?.places ?? [], teacherId: exam.teacherId, }; return item; @@ -112,7 +112,7 @@ const groupItemsByDay = ( startDate.setHours(0, 0, 0); const item: DeadlineItem = { - key: 'deadline' + deadline.date.valueOf(), + key: 'deadline-' + deadline.id, start: DateTime.fromJSDate(startDate), end: DateTime.fromJSDate(startDate).plus({ hour: 1 }), startTimestamp: deadline.date.valueOf(), diff --git a/src/features/agenda/screens/BookingScreen.tsx b/src/features/agenda/screens/BookingScreen.tsx index f41885a1..516a6256 100644 --- a/src/features/agenda/screens/BookingScreen.tsx +++ b/src/features/agenda/screens/BookingScreen.tsx @@ -1,76 +1,153 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Platform, SafeAreaView, ScrollView, StyleSheet } from 'react-native'; +import { + Linking, + Platform, + SafeAreaView, + ScrollView, + StyleSheet, + View, +} from 'react-native'; import Barcode from 'react-native-barcode-svg'; -import { faLocation } from '@fortawesome/free-solid-svg-icons'; +import { faCheckCircle, faLocation } from '@fortawesome/free-solid-svg-icons'; import { Card } from '@lib/ui/components/Card'; import { CtaButton, CtaButtonSpacer } from '@lib/ui/components/CtaButton'; +import { CtaButtonContainer } from '@lib/ui/components/CtaButtonContainer'; import { Icon } from '@lib/ui/components/Icon'; import { ListItem } from '@lib/ui/components/ListItem'; import { OverviewList } from '@lib/ui/components/OverviewList'; import { RefreshControl } from '@lib/ui/components/RefreshControl'; +import { ScreenTitle } from '@lib/ui/components/ScreenTitle'; import { Section } from '@lib/ui/components/Section'; -import { Separator } from '@lib/ui/components/Separator'; +import { SectionHeader } from '@lib/ui/components/SectionHeader'; 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 { isToday } from '@lib/ui/utils/calendar'; import { Booking } from '@polito/api-client'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { DateTime } from 'luxon'; + import { BottomBarSpacer } from '../../../core/components/BottomBarSpacer'; -import { EventDetails } from '../../../core/components/EventDetails'; import { useFeedbackContext } from '../../../core/contexts/FeedbackContext'; +import { useConfirmationDialog } from '../../../core/hooks/useConfirmationDialog'; +import { useGeolocation } from '../../../core/hooks/useGeolocation'; import { useOfflineDisabled } from '../../../core/hooks/useOfflineDisabled'; import { useDeleteBooking, useGetBookings, + useUpdateBooking, } from '../../../core/queries/bookingHooks'; import { useGetStudent } from '../../../core/queries/studentHooks'; -import { formatDateTime, formatTime } from '../../../utils/dates'; +import { BookingDateTime } from '../../bookings/components/BookingDateTime'; import { AgendaStackParamList } from '../components/AgendaNavigator'; type Props = NativeStackScreenProps; +const bookingLocationHasValidCoordinates = ( + location: Booking['locationCheck'], +) => { + return !!location?.latitude && !!location?.longitude && !!location.radiusInKm; +}; + export const BookingScreen = ({ navigation, route }: Props) => { const { id } = route.params; const { t } = useTranslation(); const { setFeedback } = useFeedbackContext(); - + const { getCurrentPosition, computeDistance } = useGeolocation(); const { colors, palettes, spacing } = useTheme(); const bookingsQuery = useGetBookings(); - const bookingMutation = useDeleteBooking(id); + const deleteBookingMutation = useDeleteBooking(id); + const updateBookingMutation = useUpdateBooking(); const studentQuery = useGetStudent(); + const confirmCancel = useConfirmationDialog({ + title: t('bookingScreen.cancelBooking'), + message: t('bookingScreen.cancelBookingText'), + }); const isDisabled = useOfflineDisabled(); const styles = useStylesheet(createStyles); const booking = bookingsQuery.data?.find((e: Booking) => e.id === id); const title = booking?.topic?.title ?? ''; - const timeLabel = useMemo(() => { - if (!booking) return ''; - const fromDate = formatDateTime(booking.startsAt); - const toTime = formatTime(booking.endsAt); - return `${fromDate} - ${toTime}`; - }, [booking]); + const subTopicTitle = booking?.subtopic?.title ?? ''; - const onPressLocation = () => {}; + const hasCheckIn = useMemo( + () => + booking?.startsAt && + isToday(DateTime.fromJSDate(booking?.startsAt)) && + booking?.locationCheck?.enabled && + bookingLocationHasValidCoordinates(booking?.locationCheck), + [booking], + ); + + const completedCheckIn = useMemo( + () => hasCheckIn && booking?.locationCheck?.checked, + [booking?.locationCheck?.checked, hasCheckIn], + ); - const onPressDelete = () => { - bookingMutation - .mutateAsync() - .then(() => navigation.goBack()) - .then(() => setFeedback({ text: t('bookingScreen.cancelFeedback') })); + const canBeCancelled = useMemo( + () => + !!booking?.cancelableUntil && + booking?.cancelableUntil.getTime() > Date.now(), + [booking], + ); + + const onPressCheckIn = async () => { + if (!booking?.id) return; + getCurrentPosition().then(currentDeviceCoordinates => { + const computedDistance = computeDistance(currentDeviceCoordinates, { + latitude: Number(booking?.locationCheck?.latitude), + longitude: Number(booking?.locationCheck?.longitude), + }); + if (computedDistance < Number(booking?.locationCheck?.radiusInKm)) { + updateBookingMutation + .mutateAsync({ + bookingId: booking.id, + isLocationChecked: true, + }) + .then(() => { + setFeedback({ text: t('bookingScreen.checkInFeedback') }); + }); + } else { + setFeedback({ text: t('bookingScreen.checkLocationErrorFeedback') }); + } + }); + }; + + const onPressLocation = async (location: Booking['location']) => { + if (location.type === 'virtualPlace') { + await Linking.openURL(location.url); + } + }; + + const onPressDelete = async () => { + if (await confirmCancel()) { + setFeedback({ text: t('bookingScreen.cancelFeedback') }); + return deleteBookingMutation + .mutateAsync() + .then(() => navigation.goBack()) + .then(() => setFeedback({ text: t('bookingScreen.cancelFeedback') })); + } }; return ( <> } + refreshControl={} > - + + + {subTopicTitle && ( + + {subTopicTitle} + + )} + + {booking?.location?.name && ( { /> } title={booking.location.name} - subtitle={booking.location?.type} - onPress={onPressLocation} + subtitle={t( + `bookingScreen.locationType.${booking.location?.type}`, + )} + onPress={() => onPressLocation(booking?.location)} /> )} -
- - {t('Barcode')} -
-
- +
+ + {studentQuery.data && ( { )}
- + {hasCheckIn && } + {canBeCancelled && } - {booking?.canBeCancelled && ( - - )} + + {hasCheckIn && ( + + )} + {canBeCancelled && ( + + )} + ); }; -const createStyles = ({ spacing, palettes, fontSizes }: Theme) => +const createStyles = ({ spacing, fontSizes }: Theme) => StyleSheet.create({ barCodeCard: { - width: '100%', padding: fontSizes.md, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', - borderRadius: fontSizes.md, marginHorizontal: Platform.select({ ios: spacing[4] }), }, - sectionSeparator: { - paddingHorizontal: fontSizes.lg, - marginTop: fontSizes.xs, - }, - sectionContainer: { - paddingHorizontal: fontSizes.md, - flexDirection: 'column', - justifyContent: 'center', - alignItems: 'center', - }, - wrapper: { - marginTop: fontSizes.xs, - // padding: fontSizes.sm, - }, - booking: { - color: palettes.primary[400], - textTransform: 'uppercase', - marginVertical: fontSizes.sm, - }, - time: { - textTransform: 'capitalize', - }, }); diff --git a/src/features/agenda/screens/LectureScreen.tsx b/src/features/agenda/screens/LectureScreen.tsx index e10fc652..94a2e076 100644 --- a/src/features/agenda/screens/LectureScreen.tsx +++ b/src/features/agenda/screens/LectureScreen.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { SafeAreaView, ScrollView } from 'react-native'; +import { SafeAreaView, ScrollView, View } from 'react-native'; import { faLocationDot } from '@fortawesome/free-solid-svg-icons'; import { Icon } from '@lib/ui/components/Icon'; @@ -19,6 +19,11 @@ import { useGetPerson } from '../../../core/queries/peopleHooks'; import { GlobalStyles } from '../../../core/styles/globalStyles'; import { convertMachineDateToFormatDate } from '../../../utils/dates'; import { CourseIcon } from '../../teaching/components/CourseIcon'; +import { + isLiveVC, + isRecordedVC, + isVideoLecture, +} from '../../teaching/utils/lectures'; import { AgendaStackParamList } from '../components/AgendaNavigator'; type Props = NativeStackScreenProps; @@ -47,11 +52,18 @@ export const LectureScreen = ({ route }: Props) => { contentContainerStyle={GlobalStyles.fillHeight} > - {virtualClassroom?.videoUrl && ( - + {lecture && + (isRecordedVC(lecture) || isVideoLecture(lecture)) && + lecture.videoUrl && ( + + )} + {lecture && isLiveVC(lecture) && ( + + // TODO handle live VC + // )} { + return ( + + ); +}; diff --git a/src/features/offering/screens/StaffScreen.tsx b/src/features/offering/screens/StaffScreen.tsx index 84db12e6..245a315c 100644 --- a/src/features/offering/screens/StaffScreen.tsx +++ b/src/features/offering/screens/StaffScreen.tsx @@ -3,7 +3,7 @@ import { SafeAreaView, ScrollView } from 'react-native'; import { OverviewList } from '@lib/ui/components/OverviewList'; import { Section } from '@lib/ui/components/Section'; -import { OfferingCourseStaffInner } from '@polito/api-client/models'; +import { OfferingCourseStaff } from '@polito/api-client/models'; import { Person } from '@polito/api-client/models/Person'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; @@ -20,12 +20,12 @@ export const StaffScreen = ({ route }: Props) => { const { queries: staffQueries, isLoading } = useGetPersons(staffIds); - const staffPeople: (Person & OfferingCourseStaffInner)[] = useMemo(() => { + const staffPeople: (Person & OfferingCourseStaff)[] = useMemo(() => { if (isLoading) { return []; } - const staffData: (Person & OfferingCourseStaffInner)[] = []; + const staffData: (Person & OfferingCourseStaff)[] = []; staffQueries.forEach((staffQuery, index) => { if (!staffQuery.data) return; diff --git a/src/features/services/components/BookingListItem.tsx b/src/features/services/components/BookingListItem.tsx new file mode 100644 index 00000000..95c7fd18 --- /dev/null +++ b/src/features/services/components/BookingListItem.tsx @@ -0,0 +1,80 @@ +import { StyleSheet } from 'react-native'; + +import { faChevronRight } from '@fortawesome/free-solid-svg-icons'; +import { Icon } from '@lib/ui/components/Icon'; +import { ListItem } from '@lib/ui/components/ListItem'; +import { useStylesheet } from '@lib/ui/hooks/useStylesheet'; +import { useTheme } from '@lib/ui/hooks/useTheme'; +import { Theme } from '@lib/ui/types/Theme'; +import { Booking } from '@polito/api-client'; + +import { DateTime } from 'luxon'; + +import { useAccessibility } from '../../../core/hooks/useAccessibilty'; +import { getHtmlTextContent } from '../../../utils/html'; +import { BookingDateTime } from '../../bookings/components/BookingDateTime'; + +interface Props { + booking: Booking; + index: number; + totalData: number; +} + +export const BookingListItem = ({ booking, index, totalData }: Props) => { + const { colors } = useTheme(); + const styles = useStylesheet(createStyles); + const { accessibilityListLabel } = useAccessibility(); + const date = DateTime.fromJSDate(booking?.startsAt).toFormat('dd MMMM'); + const startsAtTime = DateTime.fromJSDate(booking?.startsAt).toFormat('HH:mm'); + const endAtTime = DateTime.fromJSDate(booking?.endsAt).toFormat('HH:mm'); + + const accessibilityLabel = accessibilityListLabel(index, totalData); + const title = getHtmlTextContent(booking?.topic?.title ?? ''); + + return ( + } + accessibilityLabel={[ + accessibilityLabel, + title, + date, + `${startsAtTime} - ${endAtTime}`, + ].join(', ')} + subtitleStyle={styles.subtitle} + trailingItem={ + + } + /> + ); +}; + +const createStyles = ({ spacing, fontSizes, fontWeights, palettes }: Theme) => + StyleSheet.create({ + title: { + fontSize: fontSizes.md, + fontWeight: fontWeights.medium, + }, + subtitle: { + color: palettes.text['500'], + fontWeight: fontWeights.medium, + textTransform: 'capitalize', + fontSize: fontSizes.sm, + marginTop: spacing[0.5], + }, + icon: { + marginRight: -spacing[1], + }, + }); diff --git a/src/features/services/components/ServicesNavigator.tsx b/src/features/services/components/ServicesNavigator.tsx index ebe61a46..25044a6a 100644 --- a/src/features/services/components/ServicesNavigator.tsx +++ b/src/features/services/components/ServicesNavigator.tsx @@ -3,13 +3,14 @@ import { Platform } from 'react-native'; import { useTheme } from '@lib/ui/hooks/useTheme'; import { TicketStatus } from '@polito/api-client'; -import { OfferingCourseStaffInner } from '@polito/api-client/models'; +import { OfferingCourseStaff } from '@polito/api-client/models'; import { TicketFAQ } from '@polito/api-client/models/TicketFAQ'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { HeaderCloseButton } from '../../../core/components/HeaderCloseButton'; import { HeaderLogo } from '../../../core/components/HeaderLogo'; import { useTitlesStyles } from '../../../core/hooks/useTitlesStyles'; +import { BookingScreen } from '../../agenda/screens/BookingScreen'; import { DegreeTopTabsNavigator } from '../../offering/navigation/DegreeTopTabsNavigator'; import { OfferingTopTabsNavigator } from '../../offering/navigation/OfferingTopTabsNavigator'; import { DegreeCourseGuideScreen } from '../../offering/screens/DegreeCourseGuideScreen'; @@ -17,10 +18,12 @@ import { DegreeCourseScreen } from '../../offering/screens/DegreeCourseScreen'; import { StaffScreen } from '../../offering/screens/StaffScreen'; import { PersonScreen } from '../../teaching/screens/PersonScreen'; import { UnreadMessagesModal } from '../../user/screens/UnreadMessagesModal'; +import { BookingsScreen } from '../screens/BookingsScreen'; import { ContactsScreen } from '../screens/ContactsScreen'; import { CreateTicketScreen } from '../screens/CreateTicketScreen'; import { JobOfferScreen } from '../screens/JobOfferScreen'; import { JobOffersScreen } from '../screens/JobOffersScreen'; +import { NewBookingScreen } from '../screens/NewBookingScreen'; import { NewsItemScreen } from '../screens/NewsItemScreen'; import { NewsScreen } from '../screens/NewsScreen'; import { ServicesScreen } from '../screens/ServicesScreen'; @@ -41,7 +44,7 @@ export type OfferingStackParamList = { courseShortcode: string; year?: string; }; - Staff: { staff: OfferingCourseStaffInner[] }; + Staff: { staff: OfferingCourseStaff[] }; }; export type ServiceStackParamList = OfferingStackParamList & { @@ -66,6 +69,9 @@ export type ServiceStackParamList = OfferingStackParamList & { MessagesModal: undefined; Contacts: undefined; Person: { id: number }; + Bookings: undefined; + Booking: { id: number }; + NewBooking: undefined; }; const Stack = createNativeStackNavigator(); @@ -254,6 +260,31 @@ export const ServicesNavigator = () => { headerBackTitleVisible: false, }} /> + + + ); diff --git a/src/features/services/screens/BookingsScreen.tsx b/src/features/services/screens/BookingsScreen.tsx new file mode 100644 index 00000000..7ec7acbc --- /dev/null +++ b/src/features/services/screens/BookingsScreen.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from 'react-i18next'; +import { SafeAreaView, ScrollView } from 'react-native'; + +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { CtaButton } from '@lib/ui/components/CtaButton'; +import { OverviewList } from '@lib/ui/components/OverviewList'; +import { RefreshControl } from '@lib/ui/components/RefreshControl'; +import { Section } from '@lib/ui/components/Section'; +import { SectionHeader } from '@lib/ui/components/SectionHeader'; +import { useTheme } from '@lib/ui/hooks/useTheme'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; + +import { BottomBarSpacer } from '../../../core/components/BottomBarSpacer'; +import { useGetBookings } from '../../../core/queries/bookingHooks'; +import { BookingListItem } from '../components/BookingListItem'; +import { ServiceStackParamList } from '../components/ServicesNavigator'; + +type Props = NativeStackScreenProps; +const createBookingEnabled = false; + +export const BookingsScreen = ({ navigation }: Props) => { + const bookingsQuery = useGetBookings(); + const { t } = useTranslation(); + const { spacing } = useTheme(); + + return ( + <> + } + > + +
+ + + {bookingsQuery?.data?.map((booking, index) => ( + + ))} + +
+ +
+
+ {createBookingEnabled && ( + { + navigation.goBack(); + navigation.navigate('NewBooking'); + }} + title={t('bookingsScreen.newBooking')} + icon={faPlus} + /> + )} + + ); +}; diff --git a/src/features/services/screens/NewBookingScreen.tsx b/src/features/services/screens/NewBookingScreen.tsx new file mode 100644 index 00000000..8c6b88d8 --- /dev/null +++ b/src/features/services/screens/NewBookingScreen.tsx @@ -0,0 +1,22 @@ +import { ScrollView } from 'react-native'; + +import { Text } from '@lib/ui/components/Text'; + +import { + useGetBookingSlots, + useGetBookingTopics, +} from '../../../core/queries/bookingHooks'; + +export const NewBookingScreen = () => { + const topicsQuery = useGetBookingTopics(); + const bookingsSlotsQuery = useGetBookingSlots('TEST_COORDINATE'); + + console.debug('topicsQuery', topicsQuery?.data); + console.debug('bookingsSlotsQuery', bookingsSlotsQuery?.data); + + return ( + + + + ); +}; diff --git a/src/features/services/screens/ServicesScreen.tsx b/src/features/services/screens/ServicesScreen.tsx index 2bef96bd..044d92b9 100644 --- a/src/features/services/screens/ServicesScreen.tsx +++ b/src/features/services/screens/ServicesScreen.tsx @@ -22,6 +22,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { BottomBarSpacer } from '../../../core/components/BottomBarSpacer'; import { usePreferencesContext } from '../../../core/contexts/PreferencesContext'; import { useOfflineDisabled } from '../../../core/hooks/useOfflineDisabled'; +import { BOOKINGS_QUERY_KEY } from '../../../core/queries/bookingHooks'; import { TICKETS_QUERY_KEY } from '../../../core/queries/ticketHooks'; import { split } from '../../../utils/reducers'; import { ServiceCard } from '../components/ServiceCard'; @@ -99,16 +100,19 @@ export const ServicesScreen = () => { disabled: isOffline && peopleSearched?.length === 0, linkTo: { screen: 'Contacts' }, }, - { - id: 'guides', - name: t('guidesScreen.title'), - icon: faSignsPost, - disabled: true, - }, { id: 'bookings', name: t('bookingsScreen.title'), icon: faPersonCirclePlus, + disabled: + isOffline && + queryClient.getQueryData(BOOKINGS_QUERY_KEY) === undefined, + linkTo: { screen: 'Bookings' }, + }, + { + id: 'guides', + name: t('guidesScreen.title'), + icon: faSignsPost, disabled: true, }, { diff --git a/src/features/teaching/components/ExamListItem.tsx b/src/features/teaching/components/ExamListItem.tsx index 1533c02b..ad4ef8af 100644 --- a/src/features/teaching/components/ExamListItem.tsx +++ b/src/features/teaching/components/ExamListItem.tsx @@ -102,7 +102,7 @@ export const ExamListItem = ({ ellipsizeMode="tail" style={{ flexShrink: 1 }} > - {exam.classrooms} + {exam.places?.map(place => place.name).join(', ')}
diff --git a/src/features/teaching/screens/CourseLecturesScreen.tsx b/src/features/teaching/screens/CourseLecturesScreen.tsx index 9ea593fd..f0568553 100644 --- a/src/features/teaching/screens/CourseLecturesScreen.tsx +++ b/src/features/teaching/screens/CourseLecturesScreen.tsx @@ -37,6 +37,7 @@ import { CourseLecture, CourseLectureSection, } from '../types/CourseLectureSections'; +import { isRecordedVC, isVideoLecture } from '../utils/lectures'; export const CourseLecturesScreen = () => { const { t } = useTranslation(); @@ -193,21 +194,26 @@ export const CourseLectureListItem = ({ const { fontSizes } = useTheme(); const { t } = useTranslation(); + let duration = null; + if (isRecordedVC(lecture) || isVideoLecture(lecture)) { + duration = lecture.duration; + } + return ( !!i) .join(' - ')} accessibilityLabel={[ lecture.title, - lecture.duration - .replace('m', t('common.minutes')) - .replace('h', t('common.hours')), + duration + ?.replace('m', t('common.minutes')) + ?.replace('h', t('common.hours')), teacher && `${teacher.firstName} ${teacher.lastName}`, ] .filter(i => !!i) diff --git a/src/features/teaching/screens/CourseVirtualClassroomScreen.tsx b/src/features/teaching/screens/CourseVirtualClassroomScreen.tsx index ac4fac1c..da7e9a77 100644 --- a/src/features/teaching/screens/CourseVirtualClassroomScreen.tsx +++ b/src/features/teaching/screens/CourseVirtualClassroomScreen.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { SafeAreaView, ScrollView } from 'react-native'; +import { SafeAreaView, ScrollView, View } from 'react-native'; import { OverviewList } from '@lib/ui/components/OverviewList'; import { PersonListItem } from '@lib/ui/components/PersonListItem'; @@ -15,6 +15,7 @@ import { useGetPerson } from '../../../core/queries/peopleHooks'; import { GlobalStyles } from '../../../core/styles/globalStyles'; import { formatDateWithTimeIfNotNull } from '../../../utils/dates'; import { TeachingStackParamList } from '../components/TeachingNavigator'; +import { isLiveVC, isRecordedVC } from '../utils/lectures'; type Props = NativeStackScreenProps< TeachingStackParamList, @@ -43,12 +44,17 @@ export const CourseVirtualClassroomScreen = ({ route }: Props) => { contentContainerStyle={GlobalStyles.fillHeight} > - {lecture?.type === 'recording' && lecture?.videoUrl && ( + {lecture && isRecordedVC(lecture) && lecture.videoUrl && ( )} + {lecture && isLiveVC(lecture) && ( + + // TODO handle live VC + // + )} { } }, [navigation, routes, t]); + const classrooms = exam?.places?.map(p => p.name).join(', ') ?? '-'; + const examAccessibilityLabel = useMemo(() => { if (!exam || !teacherQuery.data) return; @@ -78,15 +77,13 @@ export const ExamScreen = ({ route, navigation }: Props) => { } } - const classrooms = - exam?.classrooms && exam?.classrooms !== '-' - ? `${t('examScreen.location')}: ${exam?.classrooms}` - : ''; + const accessibleClassrooms = + classrooms !== '-' ? `${t('examScreen.location')}: ${classrooms}` : ''; const teacher = `${t('common.teacher')}: ${teacherQuery.data.firstName} ${ teacherQuery.data.lastName }`; - return `${exam.courseName}. ${accessibleDateTime}. ${classrooms} ${teacher}`; + return `${exam.courseName}. ${accessibleDateTime}. ${accessibleClassrooms} ${teacher}`; }, [exam, t, teacherQuery]); return ( @@ -115,34 +112,21 @@ export const ExamScreen = ({ route, navigation }: Props) => { {exam?.status && } - - - - - {exam?.examStartsAt - ? formatReadableDate(exam.examStartsAt) - : t('common.dateToBeDefined')} - - - - - - {exam?.examStartsAt - ? `${formatTime(exam.examStartsAt)} - ${formatTime( - exam.examEndsAt!, - )}` - : t('common.timeToBeDefined')} - - - + @@ -150,11 +134,9 @@ export const ExamScreen = ({ route, navigation }: Props) => { leadingItem={ } - title={exam?.classrooms ?? '-'} + title={classrooms} accessibilityLabel={`${t('examScreen.location')}: ${ - exam?.classrooms === '-' - ? t('examScreen.noClassroom') - : exam?.classrooms + classrooms !== '-' ? classrooms : t('examScreen.noClassroom') }`} subtitle={t('examScreen.location')} /> diff --git a/src/features/teaching/screens/TeachingScreen.tsx b/src/features/teaching/screens/TeachingScreen.tsx index 117b4faa..ee790cab 100644 --- a/src/features/teaching/screens/TeachingScreen.tsx +++ b/src/features/teaching/screens/TeachingScreen.tsx @@ -69,7 +69,7 @@ export const TeachingScreen = ({ navigation }: Props) => { const hiddenNonModuleCourses: string[] = []; Object.keys(coursePreferences).forEach((key: string) => { - if (coursePreferences[+key].isHidden) { + if (coursePreferences[+key]?.isHidden) { const hiddenCourse = coursesQuery.data?.find(c => c.id === +key); if (hiddenCourse && !hiddenCourse.isModule) hiddenNonModuleCourses.push(hiddenCourse.shortcode); diff --git a/src/features/teaching/utils/lectures.ts b/src/features/teaching/utils/lectures.ts new file mode 100644 index 00000000..3bf48a1f --- /dev/null +++ b/src/features/teaching/utils/lectures.ts @@ -0,0 +1,19 @@ +import { VideoLecture } from '@polito/api-client'; +import { instanceOfVideoLecture } from '@polito/api-client/models/VideoLecture'; +import { + VirtualClassroom, + instanceOfVirtualClassroom, +} from '@polito/api-client/models/VirtualClassroom'; +import { + VirtualClassroomLive, + instanceOfVirtualClassroomLive, +} from '@polito/api-client/models/VirtualClassroomLive'; + +export const isLiveVC = (l: object): l is VirtualClassroomLive => + instanceOfVirtualClassroomLive(l); + +export const isRecordedVC = (l: object): l is VirtualClassroom => + instanceOfVirtualClassroom(l); + +export const isVideoLecture = (l: object): l is VideoLecture => + instanceOfVideoLecture(l);