diff --git a/package.json b/package.json index c8731f2..e8991b5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "jest": { "preset": "jest-expo", "transformIgnorePatterns": [ - "node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-native-svg-charts|d3-path)/)" + "node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-native-svg-charts|d3-path|expo-constants|expo-modules-core)/)" ] }, "dependencies": { @@ -57,6 +57,7 @@ "react-native-dotenv": "^3.4.9", "react-native-dropdown-picker": "^5.4.6", "react-native-fs": "^2.20.0", + "react-native-modal": "^13.0.1", "react-native-reanimated": "^3.6.2", "react-native-safe-area-context": "^4.8.2", "react-native-screens": "~3.22.0", diff --git a/src/component/headers/back-header.tsx b/src/component/headers/back-header.tsx new file mode 100644 index 0000000..22b0cd8 --- /dev/null +++ b/src/component/headers/back-header.tsx @@ -0,0 +1,42 @@ +import { NavigationProp } from "@react-navigation/native"; +import React from "react"; +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { shadows } from "../../styles"; + +type BackHeaderProps = { + navigation: NavigationProp; // TODO: #135 should use a RootStackParamList + title: string; +}; + +const BackHeader: React.FC = ({ navigation, title }) => { + return ( + + navigation.goBack()} + accessibilityLabel="Go back" + accessibilityRole="button" + > + {`< ${title}`} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: "white", + zIndex: 100, + ...shadows.standard, + }, + button: { + padding: 10, + }, + text: { + fontSize: 16, + fontWeight: "500", + color: "black", + }, +}); + +export default BackHeader; diff --git a/src/component/image/image-with-fallback.tsx b/src/component/image/image-with-fallback.tsx new file mode 100644 index 0000000..57aac20 --- /dev/null +++ b/src/component/image/image-with-fallback.tsx @@ -0,0 +1,34 @@ +import React, { memo, useState } from "react"; +import { Image, ImageStyle, StyleProp } from "react-native"; + +type ImageWithFallbackProps = { + source: { uri: string }; + fallbackSource: { uri: string }; + style?: StyleProp; +}; +/** + * A React component that displays an image with a fallback option. + * If the primary image fails to load, it will display a fallback image instead. + * + * @param {Object} source - The source of the primary image. + * @param {Object} fallbackSource - The source of the fallback image. Should be an asset rather than loaded from an endpoint. + * @param {StyleProp} style - Optional. The style to apply to the image. It can be any valid React Native style object. + * @returns {React.ReactElement} A React element representing the image with fallback. + */ +const ImageWithFallback: React.FC = memo( + ({ source, fallbackSource, style }) => { + const [imageSource, setImageSource] = useState(source); + + const handleError = () => { + if (fallbackSource) { + setImageSource(fallbackSource); + } + }; + + return ; + }, +); + +ImageWithFallback.displayName = "ImageWithFallback"; + +export default ImageWithFallback; diff --git a/src/component/video-details/video-details-header.tsx b/src/component/video-details/video-details-header.tsx new file mode 100644 index 0000000..2ec9720 --- /dev/null +++ b/src/component/video-details/video-details-header.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +// Type assumes preformatted date +type SessionHeaderProps = { + sessionTitle: string; + date: string; +}; + +const SessionHeader: React.FC = ({ + sessionTitle, + date, +}) => { + return ( + + {sessionTitle} + {date} + + ); +}; + +const styles = StyleSheet.create({ + headerSection: { + paddingHorizontal: 38, + paddingTop: 17, + paddingBottom: 14, + }, + header: { + fontSize: 24, + fontWeight: "bold", + }, +}); + +export default SessionHeader; diff --git a/src/component/video-details/video-stat-list.tsx b/src/component/video-details/video-stat-list.tsx new file mode 100644 index 0000000..d7d1df2 --- /dev/null +++ b/src/component/video-details/video-stat-list.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { StyleProp, StyleSheet, Text, View, ViewStyle } from "react-native"; +import { borders } from "../../styles"; + +type StatItem = { + title: string; + value: string; +}; + +type StatListProps = { + items: StatItem[]; + style?: StyleProp; +}; + +const StatList: React.FC = ({ items, style }) => { + return ( + + {items.map((item, index) => ( + + {item.title} + {item.value} + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 5, + }, + item: { + marginBottom: 10, + ...borders.dottedLeftBorder, + }, + title: { + fontSize: 15, + color: "grey", + textAlign: "center", + }, + value: { + fontSize: 28, + fontWeight: "bold", + textAlign: "center", + }, +}); + +export default StatList; diff --git a/src/navigation/tab-navigator.tsx b/src/navigation/tab-navigator.tsx index 74683e4..83b4636 100644 --- a/src/navigation/tab-navigator.tsx +++ b/src/navigation/tab-navigator.tsx @@ -4,7 +4,7 @@ import { Image } from "react-native"; import CameraScreen from "../component/recording/camera"; import Profile from "../screens/profile"; import RecordingDetails from "../screens/recording-stack/recording-details"; -import Video from "../screens/video-stack/video-detail"; +import Video from "../screens/video-stack/video-details"; import VideoFeed from "../screens/video-stack/video-feed"; import { tabIconColors } from "../styles"; @@ -70,7 +70,7 @@ export default function Tabs(): React.JSX.Element { - - - - ); -} diff --git a/src/screens/video-stack/video-details.tsx b/src/screens/video-stack/video-details.tsx new file mode 100644 index 0000000..d95ea3f --- /dev/null +++ b/src/screens/video-stack/video-details.tsx @@ -0,0 +1,119 @@ +import React from "react"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { + graph_data_two_measures, + mock_session_details, +} from "../../../test/mock/charts/mock-data"; +import BarGraph from "../../component/charts/bar-graph/bar-graph"; +import ChartContainer from "../../component/charts/container/chart-container"; +import ImageWithFallback from "../../component/image/image-with-fallback"; +import StatList from "../../component/video-details/video-stat-list"; + +// TODO: #134 remove Session when data piped through +// Splash should be an asset we use if an Image failed to load +import Session from "../../assets/sample_session.png"; +import Splash from "../../assets/splash.png"; +import BackHeader from "../../component/headers/back-header"; +import SessionHeader from "../../component/video-details/video-details-header"; +import { borders, colors } from "../../styles"; + +export default function VideoDetails({ navigation }) { + // TODO: #134 Remove mock destructure block when data is piped through from BE + const { + sessionTitle, + date, + timePlayed, + medianRun, + makeRate, + shotPacing, + gameType, + notes, + } = mock_session_details; + + const leftColumnStats = [ + { title: "TIME PLAYED", value: timePlayed }, + { title: "MAKE RATE", value: makeRate }, + ]; + + const rightColumnStats = [ + { title: "MEDIAN RUN", value: medianRun }, + { title: "SHOT PACING", value: shotPacing }, + ]; + // End mock destructure + + return ( + <> + + + + + + + + + + + Game Type + {gameType} + Notes + {notes} + + + + + + ); +} + +// TODO: #130 scaled styles + maintain consistency with video-feed styles +const styles = StyleSheet.create({ + scrollContainer: { + backgroundColor: "white", // TODO #125 -- this color should not be set but implicitly inherit from theme + paddingBottom: 20, // guarantees some space at bottom of scrollable views + }, + image: { + width: "100%", + height: 248, + }, + statsContainer: { + flexDirection: "row", + justifyContent: "space-between", + paddingLeft: 45, + paddingTop: 42, + paddingBottom: 38, + }, + statColumn: { + flex: 1, + padding: 5, + }, + horizontalDivider: { + width: "92%", + alignSelf: "center", + ...borders.dottedBottomBorder, + }, + textContainer: { + paddingHorizontal: 38, + }, + title: { + fontSize: 16, + color: colors.text.greyText, + paddingTop: 16, + }, + text: { + fontSize: 18, + textAlign: "left", + paddingTop: 6, + fontWeight: "400", + }, +}); diff --git a/src/styles.ts b/src/styles.ts index 9b229b2..d1aef1e 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -1,6 +1,14 @@ -// GLOBAL STYLES -import { StyleSheet } from "react-native"; +import Constants from "expo-constants"; +import { Platform, StyleSheet } from "react-native"; +// STYLE CONSTS +let STATUS_BAR_HEIGHT: number; + +Platform.OS === "android" + ? (STATUS_BAR_HEIGHT = 24) + : (STATUS_BAR_HEIGHT = Constants.statusBarHeight || 24); + +// GLOBALLY STYLED export const globalInputStyles = StyleSheet.create({ input: { width: "100%", @@ -28,18 +36,21 @@ export const globalInputStyles = StyleSheet.create({ borderRadius: 4, }, }); + // COLORS: // can be made more granular to specify utility (ex: fontColors vs backgroundColors) export const colors = { bgBlack: "#121212", lightGrey: "#BFC2C8", darkGrey: "#767577", - themeBrown: "#D9AA84", panelWhite: "#F2FBFE", - tournamentBlue: "#50a6c2", - blueCloth: "#539dc2", buttonBlue: "#1987ff", - textWhite: "#ffffff", + border: { + grey: "#8F8E94", + }, + text: { + greyText: "#848580", + }, }; export const tabIconColors = { @@ -47,7 +58,8 @@ export const tabIconColors = { selected: "#598EBB", }; -export const shadows = { +// SHADOWS +export const shadows = StyleSheet.create({ standard: { shadowColor: "#000000", shadowOffset: { @@ -58,4 +70,47 @@ export const shadows = { shadowRadius: 4.65, elevation: 3, }, -}; +}); + +// BORDER +export const borders = StyleSheet.create({ + dottedBottomBorder: { + borderBottomWidth: 1, + borderStyle: "dotted", + color: colors.border.grey, + }, + dottedLeftBorder: { + borderLeftWidth: 1, + borderStyle: "dotted", + color: colors.border.grey, + }, +}); + +// MODAL +const MODAL_TOP_PADDING = 20; +const MODAL_TOP_RADIUS = 20; + +export const modal = StyleSheet.create({ + alignModalContainer: { + flex: 1, + marginTop: STATUS_BAR_HEIGHT + MODAL_TOP_PADDING ?? 54 + MODAL_TOP_PADDING, + backgroundColor: "white", + borderTopLeftRadius: MODAL_TOP_RADIUS, + borderTopRightRadius: MODAL_TOP_RADIUS, + width: "100%", + }, + contentStyles: { + paddingBottom: 20, + }, + grabber: { + alignSelf: "center", + width: 40, + height: 5, + backgroundColor: "#ccc", + borderRadius: 2.5, + marginVertical: 15, + }, + noMargin: { + margin: 0, + }, +}); diff --git a/test/mock/charts/mock-data.ts b/test/mock/charts/mock-data.ts index 3a7e472..f764a06 100644 --- a/test/mock/charts/mock-data.ts +++ b/test/mock/charts/mock-data.ts @@ -97,3 +97,14 @@ export const mockYData = [ ]; export const mockXValues = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +export const mock_session_details = { + sessionTitle: "Afternoon Session", + date: "Today at 2:37pm", + timePlayed: "5:03:10", + medianRun: "7.3", + makeRate: "34%", + shotPacing: "0:00:26", + gameType: "Free Play", + notes: "Lorem ipsum dolor sit amet, consectetur adipiscing elit...", +}; diff --git a/yarn.lock b/yarn.lock index 6f11c2d..ee23801 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9889,7 +9889,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2, prompts@^2.4.0, prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@*, prop-types@^15.6.0, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@*, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -10041,6 +10041,13 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-native-animatable@1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/react-native-animatable/-/react-native-animatable-1.3.3.tgz#a13a4af8258e3bb14d0a9d839917e9bb9274ec8a" + integrity sha512-2ckIxZQAsvWn25Ho+DK3d1mXIgj7tITkrS4pYDvx96WyOttSvzzFeQnM2od0+FUMzILbdHDsDEqZvnz1DYNQ1w== + dependencies: + prop-types "^15.7.2" + react-native-dotenv@^3.4.9: version "3.4.9" resolved "https://registry.yarnpkg.com/react-native-dotenv/-/react-native-dotenv-3.4.9.tgz#621c5b0c1d0c5c7f569bfe5a1d804bec7885c010" @@ -10061,6 +10068,14 @@ react-native-fs@^2.20.0: base-64 "^0.1.0" utf8 "^3.0.0" +react-native-modal@^13.0.1: + version "13.0.1" + resolved "https://registry.yarnpkg.com/react-native-modal/-/react-native-modal-13.0.1.tgz#691f1e646abb96fa82c1788bf18a16d585da37cd" + integrity sha512-UB+mjmUtf+miaG/sDhOikRfBOv0gJdBU2ZE1HtFWp6UixW9jCk/bhGdHUgmZljbPpp0RaO/6YiMmQSSK3kkMaw== + dependencies: + prop-types "^15.6.2" + react-native-animatable "1.3.3" + react-native-reanimated@^3.6.2: version "3.6.2" resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.6.2.tgz#8a48c37251cbd3b665a659444fa9778f5b510356"