move folders etc, need to solve app import issue

This commit is contained in:
Loewy
2024-02-06 14:24:49 -08:00
parent 4a5dd47bc0
commit b5ca868050
48 changed files with 16 additions and 16 deletions

View File

@@ -0,0 +1,18 @@
import React from "react";
import { Button } from "react-native";
import { handleSignOut } from "../../auth";
import { useAuthHeader } from "../../graphql/client";
export default function SignOutButton(): React.JSX.Element {
const { setAuthHeader } = useAuthHeader();
return (
<Button
title={"Sign out"}
onPress={async () => {
setAuthHeader({ key: "Authorization", value: "" });
await handleSignOut();
}}
/>
);
}

View File

@@ -0,0 +1,137 @@
import * as scale from "d3-scale";
import React from "react";
import { View } from "react-native";
import { BarChart, XAxis, YAxis } from "react-native-svg-charts";
import { GraphData, YAxisProps } from "../graph-types";
import { useGraphData } from "../use-graph-data";
import ChartLabel from "../chart-label/chart-label";
import { graphStyles } from "../chart-styles";
import ChartView from "../chart-view";
import { CustomBars } from "../custom-bars";
import { CustomGrid } from "../custom-grid";
interface Props {
data: GraphData;
height?: number;
spacingInner?: number;
spacingOuter?: number;
contentInset?: { top: number; bottom: number };
min?: number;
numberOfTicks?: number;
barColors?: Array<string>;
useCommonScale?: boolean;
yAxisProps?: YAxisProps;
testID?: string;
}
// TODO: separate PR will update useGraphData to take into account useCommonScale
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
export const BarGraph: React.FC<Props> = ({
data,
useCommonScale = false,
testID,
...props
}) => {
if (!data) {
return null;
} // TODO:#38
const {
xValues,
yData,
yAxisRightLabels,
yAxisLeftLabels,
defaultProps: {
height,
spacingInner,
spacingOuter,
contentInset,
min,
numberOfTicks,
barColors,
yAxisProps: {
maxLeftYAxisValue,
maxRightYAxisValue,
formatRightYAxisLabel,
formatLeftYAxisLabel,
},
},
// Proper error/loading handling from useQueryHandler can work with this rule #38
// eslint-disable-next-line react-hooks/rules-of-hooks
} = useGraphData(data, { includeColors: false, ...props });
// TODO: Will come from BE & destructured / color assigned in useGraphData
const yLabels = [
{ displayName: "Shots Taken", axis: "LEFT" as "LEFT", color: barColors[0] },
{
displayName: "Make Percentage",
axis: "RIGHT" as "RIGHT",
color: barColors[1],
},
];
const title = "Shots Taken / Make Percentage by Cut Angle";
return (
<ChartView>
<ChartLabel title={title} yLabels={yLabels} />
<View
style={[graphStyles.rowContainer, { height: height }]}
testID={`bar-graph-${testID}`}
>
<YAxis
data={yAxisLeftLabels.values}
contentInset={contentInset}
svg={graphStyles.yAxisFontStyle}
style={graphStyles.yAxisLeftPadding}
min={min}
max={maxLeftYAxisValue}
numberOfTicks={numberOfTicks}
formatLabel={formatLeftYAxisLabel}
/>
<View style={graphStyles.flex}>
<BarChart
style={graphStyles.flex}
data={yData}
gridMin={min}
numberOfTicks={numberOfTicks} // rethink numberOfTicks, it should be determined automatically if we do our y axis scaling properly
yAccessor={({ item }) =>
(item as unknown as { value: number }).value
}
contentInset={contentInset}
spacingInner={spacingInner}
spacingOuter={spacingOuter}
>
<CustomGrid />
<CustomBars
barData={yData}
xValues={xValues}
barColors={barColors}
/>
</BarChart>
<XAxis
data={xValues.map((_, index) => index)}
formatLabel={(_, index) => xValues[index]}
style={graphStyles.xAxisMarginTop}
svg={graphStyles.xAxisFontStyle}
scale={scale.scaleBand}
/>
</View>
<YAxis
data={yAxisRightLabels?.values ?? yAxisLeftLabels.values}
contentInset={contentInset}
svg={graphStyles.yAxisFontStyle}
style={graphStyles.yAxisRightPadding}
min={min}
max={maxRightYAxisValue}
numberOfTicks={numberOfTicks}
formatLabel={formatRightYAxisLabel}
/>
</View>
</ChartView>
);
};
export default BarGraph;

View File

@@ -0,0 +1,48 @@
import React from "react";
import { Path } from "react-native-svg";
import { calculateBarOrigin, drawBarPath } from "./custom-bar-utils";
import { ScaleBandType, ScaleLinearType } from "./graph-types";
interface BarProps {
scaleX: ScaleBandType;
scaleY: ScaleLinearType;
data: { value: number };
barNumber: number;
index: number;
fill: string;
barWidth: number;
gap: number;
roundedRadius: number;
}
export const Bar: React.FC<BarProps> = ({
scaleX,
scaleY,
data,
barNumber,
index,
fill,
barWidth,
gap,
roundedRadius,
}) => {
const { xOrigin, yOrigin, height } = calculateBarOrigin({
scaleX,
scaleY,
index,
data,
barNumber,
barWidth,
gap,
});
return (
<Path
key={`bar-path-${barNumber}-${index}`}
d={drawBarPath(xOrigin, yOrigin, barWidth, height, roundedRadius)}
fill={fill}
testID={`bar-${barNumber}-${index}`}
/>
);
};

View File

@@ -0,0 +1,44 @@
import React from "react";
import { Text, View } from "react-native";
import { chartLabel } from "../chart-styles";
type Axis = "RIGHT" | "LEFT";
interface YLabel {
axis: Axis;
displayName: string;
color: string;
}
type ChartLabelProps = {
title: string;
yLabels: Array<YLabel>;
};
const renderLabels = (yLabels: Array<YLabel>) => {
return yLabels.map((label) => (
<View
key={`${label.axis}-${label.displayName}`}
style={chartLabel.labelInnerRow}
>
<View
style={[chartLabel.labelColorBox, { backgroundColor: label.color }]}
/>
<View style={chartLabel.labelTextMargin}>
<Text style={chartLabel.labelText}>{label.displayName}</Text>
</View>
</View>
));
};
export default function ChartLabel({ title, yLabels }: ChartLabelProps) {
return (
<View>
<View style={chartLabel.titleRow}>
<Text style={chartLabel.titleText}>{title}</Text>
</View>
<View style={chartLabel.labelOuterRow}>{renderLabels(yLabels)}</View>
</View>
);
}

View File

@@ -0,0 +1,46 @@
import { StyleSheet } from "react-native";
import { colors, shadows } from "../../styles";
export const graphStyles = StyleSheet.create({
container: {
backgroundColor: colors.panelWhite,
borderColor: "black",
borderRadius: 5,
marginVertical: 10,
marginHorizontal: 15,
paddingTop: 15,
paddingHorizontal: 15,
...shadows.standard,
},
rowContainer: { flexDirection: "row", padding: 10 },
flex: { flex: 1 },
yAxisLeftPadding: { paddingRight: 3 },
yAxisRightPadding: { paddingLeft: 3 },
yAxisFontStyle: { fontSize: 10, fill: "grey" },
xAxisFontStyle: { fontSize: 12, fill: "black" },
xAxisMarginTop: { marginTop: -15 },
horizontalInset: { right: 10, left: 10 },
});
export const chartLabel = StyleSheet.create({
titleRow: {
flexDirection: "row",
marginHorizontal: 10,
},
titleText: { fontWeight: "500" },
labelOuterRow: {
flexDirection: "row",
flexWrap: "wrap",
marginHorizontal: 10,
},
labelInnerRow: { flexDirection: "row", alignItems: "center", marginTop: 5 },
labelColorBox: {
height: 15,
width: 15,
borderRadius: 4,
marginRight: 2,
},
labelTextMargin: { marginRight: 15 },
labelText: { fontSize: 12 },
});

View File

@@ -0,0 +1,20 @@
import React from "react";
import { StyleProp, View, ViewStyle } from "react-native";
import { graphStyles } from "./chart-styles";
interface ChartViewProps {
style?: StyleProp<ViewStyle>;
children: React.ReactNode;
testID?: string;
}
const ChartView: React.FC<ChartViewProps> = ({ children, style, testID }) => {
return (
<View style={[graphStyles.container, style]} testID={testID}>
{children}
</View>
);
};
export default ChartView;

View File

@@ -0,0 +1,63 @@
import { path as d3 } from "d3-path";
import { ScaleBandType, ScaleLinearType } from "./graph-types";
type BarCalculationProps = {
scaleX: ScaleBandType;
scaleY: ScaleLinearType;
data: { value: number };
barNumber: number;
index: number;
barWidth: number;
gap: number;
};
export function calculateBarOrigin({
scaleX,
scaleY,
barWidth,
data,
index,
barNumber,
gap,
}: BarCalculationProps): { xOrigin: number; yOrigin: number; height: number } {
const firstBar = barNumber === 0;
const xOrigin =
scaleX(index) + (firstBar ? 0 : barWidth * barNumber + gap * barNumber);
const yOrigin = scaleY(data.value);
const height = scaleY(0) - yOrigin;
return { xOrigin, yOrigin, height };
}
export function drawBarPath(
xOrigin: number,
yOrigin: number,
barWidth: number,
height: number,
roundedRadius: number,
): string {
const path = d3();
path.moveTo(xOrigin, yOrigin + height);
path.lineTo(xOrigin, yOrigin + roundedRadius);
path.arcTo(xOrigin, yOrigin, xOrigin + roundedRadius, yOrigin, roundedRadius);
path.lineTo(xOrigin + barWidth - roundedRadius, yOrigin);
path.arcTo(
xOrigin + barWidth,
yOrigin,
xOrigin + barWidth,
yOrigin + roundedRadius,
roundedRadius,
);
path.lineTo(xOrigin + barWidth, yOrigin + height);
path.lineTo(xOrigin, yOrigin + height);
path.closePath();
return path.toString();
}
export const calculateBarWidth = (
bandwidth: number,
combinedDataLength: number,
gap: number,
) => (bandwidth - gap * (combinedDataLength - 1)) / combinedDataLength;

View File

@@ -0,0 +1,53 @@
import { Svg } from "react-native-svg";
import { Bar } from "./bar";
import { calculateBarWidth } from "./custom-bar-utils";
import { ScaleBandType, ScaleLinearType } from "./graph-types";
export interface CombinedData {
data: { value: number }[];
svg: { fill: string };
}
interface CustomBarsProps {
x: ScaleBandType;
y: ScaleLinearType;
bandwidth: number;
barColors: string[];
xValues: unknown[]; // TODO: update this value when this data type is defined
barData: CombinedData[];
gap: number;
roundedRadius: number;
}
export const CustomBars: React.FC<Partial<CustomBarsProps>> = ({
x: scaleX,
y: scaleY,
bandwidth,
barData,
xValues,
barColors,
gap = 2,
roundedRadius = 4,
}) => {
const barWidth = calculateBarWidth(bandwidth, barData.length, gap);
return xValues.map((_, index) => (
<Svg key={`group-${index}`} testID={`svg-${index}`}>
{barData.map((item, i) => (
<Bar
key={`bar-${i}-${index}`}
scaleX={scaleX}
scaleY={scaleY}
data={item.data[index]}
barNumber={i}
index={index}
fill={barColors[i]}
barWidth={barWidth}
gap={gap}
roundedRadius={roundedRadius}
/>
))}
</Svg>
));
};

View File

@@ -0,0 +1,107 @@
import React from "react";
import { G, Line } from "react-native-svg";
import { colors } from "../../styles";
import { ScaleBandType, ScaleLinearType } from "./graph-types";
interface CustomGridProps {
x: ScaleBandType;
y: ScaleLinearType;
ticks: Array<number>;
includeVertical?: boolean;
xTicks?: Array<number | string>;
}
/**
* CustomGrid Component
*
* This component is used within a react-native-svg-chart component to render grid lines.
*
* @param {ScaleBandType} x - X-axis scale function, received from the parent chart component.
* @param {ScaleLinearType} y - Y-axis scale function, received from the parent chart component.
* @param {Array<number>} ticks - Y-axis tick values for horizontal lines, received from the parent.
* @param {boolean} [includeVertical=false] - Enables experimental vertical grid lines.
* @param {Array<number|string>} [xTicks] - X-axis tick values for vertical lines, necessary if `includeVertical` is true.
*
* Behavior:
* - Renders horizontal grid lines based on the `ticks` & scale functions provided by the parent chart component.
* - Vertical grid lines are experimental and can be enabled with `includeVertical` set to true.
* However, their scaling and positioning may not be accurate relative to XAxis component (if used).
*
* Usage:
* <Chart ... >
* <CustomGrid />
* </Chart>
*
* Note: Use `includeVertical` cautiously; vertical lines are not fully developed.
*/
export const CustomGrid: React.FC<Partial<CustomGridProps>> = ({
x,
y,
ticks,
xTicks,
includeVertical = false,
}) => {
const [firstTick, ...remainingTicks] = ticks;
const dashArray = [1, 3];
const strokeSolidWidth = 0.2;
const strokeSolidColor = colors.bgBlack;
const strokeDashWidth = 1;
const strokeDashColor = colors.lightGrey;
const renderHorizontalLine = (
tick: number,
stroke: string,
strokeWidth: number,
dashArray?: number[],
) => (
<Line
key={`line-${tick}`}
x1="0%"
x2="100%"
y1={y(tick)}
y2={y(tick)}
stroke={stroke}
strokeWidth={strokeWidth}
strokeDasharray={dashArray}
/>
);
const topY = y(Math.max(...ticks));
const bottomY = y(Math.min(...ticks));
const renderVerticalLine = (
tick: number,
stroke: string,
strokeWidth: number,
dashArray?: number[],
) => {
return (
<Line
key={`vertical-line-${tick}`}
x1={x(tick)}
x2={x(tick)}
y1={topY}
y2={bottomY}
stroke={stroke}
strokeWidth={strokeWidth}
strokeDasharray={dashArray}
/>
);
};
return (
<G>
{renderHorizontalLine(firstTick, strokeSolidColor, strokeSolidWidth)}
{remainingTicks.map((tick) =>
renderHorizontalLine(tick, strokeDashColor, strokeDashWidth, dashArray),
)}
{includeVertical &&
xTicks.map((_, index) =>
renderVerticalLine(
index,
strokeDashColor,
strokeDashWidth,
dashArray,
),
)}
</G>
);
};

View File

@@ -0,0 +1,13 @@
// TODO: Style values should be moved to styles
// non-style config can live here as chartDefaults
export const chartDefaults = {
height: 300,
spacingInner: 0.3,
spacingOuter: 0.2,
contentInset: { top: 30, bottom: 30 },
numberOfTicks: 6,
min: 0,
barColors: ["#598EBB", "#F2D4BC", "#DB7878"],
includeColors: true,
lineStrokeWidth: 2,
};

View File

@@ -0,0 +1,78 @@
import { ScaleBand, ScaleLinear } from "d3-scale";
export type ScaleLinearType = ScaleLinear<number, number>;
export type ScaleBandType = ScaleBand<number | string>;
export interface YAxisData {
key: string; // string value for ChartLabel and useGraphData
values: Array<number>;
// including this code only for review --
// do we prefer the idea of passing label formatting from the data or in the component?
// generic function type, specific usage of value varies
// eslint-disable-next-line no-unused-vars
formatLabel?: (value: number) => string;
}
export type XValue = string | number;
export interface GraphData {
xValues: Array<XValue>;
yValues: Array<YAxisData>;
}
export interface YAxisProps {
maxLeftYAxisValue?: number;
maxRightYAxisValue?: number;
selectedLeftYAxisLabel?: string;
selectedRightYAxisLabel?: string;
// generic function type, specific usage of value varies
// eslint-disable-next-line no-unused-vars
formatRightYAxisLabel?: (value: string) => string;
// generic function type, specific usage of value varies
// eslint-disable-next-line no-unused-vars
formatLeftYAxisLabel?: (value: string) => string;
}
export interface GraphProps {
data: GraphData;
includeColors?: boolean;
height?: number;
spacingInner?: number;
spacingOuter?: number;
contentInset?: { top: number; bottom: number };
min?: number;
numberOfTicks?: number;
barColors?: Array<string>;
lineStrokeWidth?: number;
useCommonScale?: boolean;
yAxisProps?: YAxisProps;
}
/**
* Common properties for graph render components.
*
* @interface CommonProps
* @property {GraphData} data - The primary data source for the graph.
* @property {number} [height] - Optional. The height of the graph.
* @property {number} [spacingInner] - Optional. The inner spacing between elements in the graph.
* @property {number} [spacingOuter] - Optional. The outer spacing of the elements in the graph.
* @property {{ top: number; bottom: number }} [contentInset] - Optional. Insets for the content of the graph.
* @property {number} [min] - Optional. The minimum value represented on the graph.
* @property {number} [numberOfTicks] - Optional. The number of tick marks along the axis.
* @property {Array<string>} [barColors] - Optional. Colors used for bars in the graph.
* @property {boolean} [useCommonScale] - Optional. Flag to use a common scale across multiple components.
* @property {YAxisProps} [yAxisProps] - Optional. Properties for the Y-axis of the graph.
* @property {string} [testID] - Optional. Identifier for testing purposes.
*/
export interface CommonProps {
data: GraphData;
height?: number;
spacingInner?: number;
spacingOuter?: number;
contentInset?: { top: number; bottom: number };
min?: number;
numberOfTicks?: number;
barColors?: Array<string>;
lineStrokeWidth?: number;
useCommonScale?: boolean;
yAxisProps?: YAxisProps;
testID?: string;
}

View File

@@ -0,0 +1,52 @@
import { GraphData } from "./graph-types";
export const convertToGraphData = (
graphData: GraphData,
options: {
selectedLeftYAxisLabel?: string;
selectedRightYAxisLabel?: string;
maxRightYAxisValue: number;
maxLeftYAxisValue: number;
includeColors: boolean;
barColors: Array<string>;
},
) => {
const xValues = graphData.xValues;
const leftAxisIndex = graphData.yValues.findIndex(
(y) => y.key === options.selectedLeftYAxisLabel,
);
const rightAxisIndex = graphData.yValues.findIndex(
(y) => y.key === options.selectedRightYAxisLabel,
);
// scale data points according to max value
const yData = graphData.yValues.map((yAxis, index) => {
const maxValue =
index === rightAxisIndex
? options.maxRightYAxisValue
: options.maxLeftYAxisValue;
// scale values as a percentage of the max value
const scaledValues = yAxis.values.map((value) => (value / maxValue) * 100);
const strokeColor =
options.includeColors && options.barColors
? options.barColors[index % options.barColors.length]
: "transparent";
const mappedData = scaledValues.map((scaledValue) => ({
value: scaledValue,
}));
return {
data: mappedData,
svg: { fill: "transparent", stroke: strokeColor }, // Apply the stroke color here
};
});
const yAxisLeftLabels =
leftAxisIndex !== -1 ? graphData.yValues[leftAxisIndex] : null;
const yAxisRightLabels =
rightAxisIndex !== -1 ? graphData.yValues[rightAxisIndex] : undefined;
return { yData, yAxisLeftLabels, yAxisRightLabels, xValues };
};

View File

@@ -0,0 +1,103 @@
import * as shape from "d3-shape";
import React from "react";
import { View } from "react-native";
import { LineChart, XAxis, YAxis } from "react-native-svg-charts";
import { graphStyles } from "../chart-styles";
import ChartView from "../chart-view";
import { CustomGrid } from "../custom-grid";
import { CommonProps } from "../graph-types";
import { useGraphData } from "../use-graph-data";
// TODO: separate PR will update useGraphData to take into account useCommonScale
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const LineGraph: React.FC<CommonProps> = ({
data,
useCommonScale = false,
testID,
...props
}) => {
if (!data || typeof data !== "object") {
return null;
} // TODO:#38
const {
xValues,
yData,
yAxisLeftLabels,
yAxisRightLabels,
defaultProps: {
height,
contentInset,
min,
numberOfTicks,
lineStrokeWidth,
yAxisProps: {
maxLeftYAxisValue,
maxRightYAxisValue,
formatLeftYAxisLabel,
formatRightYAxisLabel,
},
},
// Proper error/loading handling from useQueryHandler can work with this rule #38
// eslint-disable-next-line react-hooks/rules-of-hooks
} = useGraphData(data, props);
return (
<ChartView>
<View
style={[graphStyles.rowContainer, { height: height }]}
testID={`line-graph-${testID}`}
>
<YAxis
data={yAxisLeftLabels.values}
contentInset={contentInset}
svg={graphStyles.yAxisFontStyle}
style={graphStyles.yAxisLeftPadding}
min={min}
max={maxLeftYAxisValue}
numberOfTicks={numberOfTicks}
formatLabel={formatLeftYAxisLabel}
/>
<View style={graphStyles.flex}>
<LineChart
style={graphStyles.flex}
data={yData}
curve={shape.curveNatural}
svg={{ strokeWidth: lineStrokeWidth }}
yAccessor={({ item }) =>
(item as unknown as { value: number }).value
}
xAccessor={({ index }) => xValues[index] as number}
gridMin={min}
contentInset={contentInset}
numberOfTicks={numberOfTicks}
>
<CustomGrid />
</LineChart>
<XAxis
data={xValues.map((_, index: number) => index)} // TODO: update when useGraphHook returns explicit display values
style={[graphStyles.xAxisMarginTop]}
svg={graphStyles.xAxisFontStyle}
numberOfTicks={numberOfTicks}
contentInset={graphStyles.horizontalInset}
/>
</View>
<YAxis
data={yAxisRightLabels?.values ?? yAxisLeftLabels.values}
contentInset={contentInset}
svg={graphStyles.yAxisFontStyle}
style={[
graphStyles.yAxisRightPadding,
{ height: useCommonScale ? 0 : "auto" },
]}
min={min}
max={maxRightYAxisValue}
numberOfTicks={numberOfTicks}
formatLabel={formatRightYAxisLabel} // formatRightYAxisLabel formatting could come from yAxisRightLabels object
/>
</View>
</ChartView>
);
};
export default LineGraph;

View File

@@ -0,0 +1,70 @@
import { useMemo } from "react";
import { chartDefaults } from "./graph-config";
import { GraphData, GraphProps, XValue, YAxisData } from "./graph-types";
import { convertToGraphData } from "./graph-utils";
interface useGraphDataInterface {
xValues: Array<XValue>;
yData: {
data: {
value: number;
}[];
svg: {
fill: string;
};
}[];
yAxisLeftLabels: YAxisData;
yAxisRightLabels: YAxisData;
defaultProps: Partial<GraphProps>;
}
// this version assumes string values for X, this isn't necessarily the case
// convertToGraphData is specifically tailored to bar/group bar graphs
// ultimately this component could be used by any x & y axis graph types (line/bar/scatter)
export const useGraphData = (
graphData: GraphData,
props: Partial<GraphProps>,
): useGraphDataInterface => {
const { yAxisProps = {}, ...otherProps } = props;
const defaultProps = {
...chartDefaults,
...otherProps,
// assign default values for yAxisProps + spread to override with values coming from props
yAxisProps: {
maxLeftYAxisValue: Math.max(...(graphData.yValues[0]?.values ?? [0])),
maxRightYAxisValue:
graphData.yValues.length > 1
? Math.max(...graphData.yValues[1]?.values)
: undefined,
formatRightYAxisLabel: yAxisProps.formatRightYAxisLabel,
formatLeftYAxisLabel: yAxisProps.formatLeftYAxisLabel,
selectedLeftYAxisLabel: graphData.yValues[0]?.key,
selectedRightYAxisLabel: graphData.yValues[1]?.key,
...yAxisProps,
},
};
const { yData, yAxisLeftLabels, yAxisRightLabels, xValues } = useMemo(
() =>
convertToGraphData(graphData, {
...defaultProps.yAxisProps,
includeColors: defaultProps.includeColors,
barColors: defaultProps.barColors,
}),
[
graphData,
defaultProps.yAxisProps,
defaultProps.includeColors,
defaultProps.barColors,
],
);
return {
xValues,
yData,
yAxisLeftLabels,
yAxisRightLabels,
defaultProps,
};
};

View File

@@ -0,0 +1,14 @@
import { Text, View } from "react-native";
function DisplayShots({ data }) {
const renderShots = (shots) => {
return shots.map((shot) => (
<View key={shot.id}>
<Text>{shot.id}</Text>
</View>
));
};
return renderShots(data);
}
export default DisplayShots;

16
src/component/shot.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { GET_SHOTS } from "../graphql/query";
import DisplayShots from "./display-shot";
import withQueryHandling from "./with-query-handling";
const ShotsContainer = withQueryHandling({
WrappedComponent: DisplayShots,
query: GET_SHOTS,
dataKey: "getShots",
queryOptions: {
variables: {
includeCueObjectAngle: true,
},
},
});
export default ShotsContainer;

View File

@@ -0,0 +1,286 @@
import { ApolloClient, useApolloClient } from "@apollo/client";
import { useIsFocused } from "@react-navigation/native";
import * as gql from "railbird-gql";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Button, StyleSheet, Text, View } from "react-native";
import * as RNFS from "react-native-fs";
import {
Camera,
CameraRuntimeError,
Orientation,
PhotoFile,
VideoFile,
useCameraDevice,
useCameraFormat,
useCameraPermission,
} from "react-native-vision-camera";
import { RecordingButton } from "./capture-button";
import { useIsForeground } from "./is-foreground";
type Dictionary<KeyType extends string | number | symbol, ValueType> = {
[key in KeyType]: ValueType;
};
class StreamUploadManager<TCacheShape> {
client: ApolloClient<TCacheShape>;
videoId: number;
nextUploadIdToRequest: number = 0;
highestUploadLinkObtained: number = -1;
prefetchedUploadLinks: Dictionary<number, string> = {};
uploadQueue: Array<() => Promise<void>> = [];
isUploading: boolean = false;
constructor(client: ApolloClient<TCacheShape>, videoId: number) {
this.client = client;
this.videoId = videoId;
}
enqueueUploadTask(task: () => Promise<void>) {
this.uploadQueue.push(task);
this.processUploadQueue();
}
async processUploadQueue() {
if (this.isUploading || this.uploadQueue.length === 0) {
return;
}
this.isUploading = true;
const task = this.uploadQueue.shift();
try {
if (task) {
await task();
}
} catch (error) {
console.error("Error processing upload task", error);
} finally {
this.isUploading = false;
this.processUploadQueue();
}
}
async uploadChunk({ filepath, index }: { filepath: string; index: number }) {
this.enqueueUploadTask(async () => {
const uploadUrl = await this.getUploadLink(index);
const uploadRequest = RNFS.uploadFiles({
toUrl: uploadUrl,
// @ts-ignore
files: [{ filepath: filepath }],
method: "PUT",
binaryStreamOnly: true,
headers: {
"Content-Type": "application/octet-stream",
},
begin: (res) => {
console.log("Start upload", res);
},
progress: (res) => {
console.log("Uploading", res);
},
});
console.log(JSON.stringify(uploadRequest));
const result = await uploadRequest.promise;
if (result.statusCode === 200) {
console.log(`${filepath} Uploaded`);
} else {
console.error("SERVER ERROR");
}
});
}
async getUploadLink(chunkId: number): Promise<string> {
if (this.prefetchedUploadLinks[chunkId]) {
return this.prefetchedUploadLinks[chunkId];
}
return this.requestUploadLink(chunkId);
}
async requestUploadLink(chunkId: number): Promise<string> {
console.log(`Requesting upload link for chunk ${chunkId}`);
const result = await this.client.mutate({
mutation: gql.GetUploadLinkDocument,
variables: { videoId: this.videoId, chunkIndex: chunkId },
});
this.prefetchedUploadLinks[chunkId] = result.data.getUploadLink.uploadUrl;
return result.data.getUploadLink.uploadUrl;
}
}
export default function CameraScreen({
route,
navigation,
}): React.ReactElement {
const apolloClient = useApolloClient();
const [createUpload, { data, loading, error }] =
gql.useCreateUploadStreamMutation();
const [uploadManager, setUploadManager] = useState(null);
useEffect(() => {
if (
data &&
data.createUploadStream &&
data.createUploadStream.videoId &&
!uploadManager
) {
const newVideoId = data.createUploadStream.videoId;
console.log(`VideoId: ${newVideoId}`);
setUploadManager(new StreamUploadManager(apolloClient, newVideoId));
}
}, [data, uploadManager]);
const camera = useRef<Camera>(null);
const { hasPermission, requestPermission } = useCameraPermission();
const [isCameraInitialized, setIsCameraInitialized] =
useState<boolean>(false);
const isForeground = useIsForeground();
const isFocused = useIsFocused();
const isActive = isForeground && isFocused;
const onError = useCallback((error: CameraRuntimeError) => {
console.error(error);
}, []);
const onInitialized = useCallback(() => {
console.log("Camera initialized!");
setIsCameraInitialized(true);
createUpload({ variables: { videoName: "Test" } });
}, []);
const onMediaCaptured = useCallback((media: PhotoFile | VideoFile) => {
console.log(`Media captured! ${JSON.stringify(media)}`);
}, []);
const onVideoChunkReady = useCallback(
(event) => {
console.log(
`Chunk ready in react-native ${JSON.stringify(event.nativeEvent)}`,
);
uploadManager.uploadChunk(event.nativeEvent);
},
[uploadManager],
);
if (!hasPermission) {
requestPermission();
// Error handling in case they refuse to give permission
}
const device = useCameraDevice("back");
const format = useCameraFormat(device, [
{ videoResolution: { width: 1920, height: 1080 } },
{ fps: 30 },
]);
// TODO(#60): setOrientation should be called when changes are detected
const [orientation, setOrientation] = useState<Orientation>("portrait");
const toggleOrientation = () => {
setOrientation((currentOrientation) =>
currentOrientation === "landscape-left" ? "portrait" : "landscape-left",
);
};
// Replace with error handling
if (device === null) {
console.log(device);
return (
<Text>
Camera not available. Does user have permissions: {hasPermission}
</Text>
);
}
return (
hasPermission && (
<View style={styles.container}>
<Camera
ref={camera}
style={StyleSheet.absoluteFill}
device={device}
format={format}
onInitialized={onInitialized}
onError={onError}
// @ts-ignore
onVideoChunkReady={onVideoChunkReady}
video={true}
orientation={orientation}
isActive={isActive}
/>
<View
style={
orientation === "portrait"
? styles.goBackPortrait
: styles.goBackLandscape
}
>
<Button title="Go back" onPress={() => navigation.goBack()} />
</View>
<RecordingButton
style={[
styles.captureButton,
orientation === "portrait" ? styles.portrait : styles.landscape,
]}
camera={camera}
onMediaCaptured={onMediaCaptured}
enabled={isCameraInitialized}
/>
<View
style={[
styles.button,
orientation === "portrait"
? styles.togglePortrait
: styles.toggleLandscape,
]}
>
<Button
title="Toggle Orientation"
onPress={toggleOrientation}
color="#841584"
accessibilityLabel="Toggle camera orientation"
/>
</View>
</View>
)
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "black",
},
captureButton: {
position: "absolute",
alignSelf: "center",
},
button: {
position: "absolute",
alignSelf: "center",
},
togglePortrait: {
bottom: 110, // needs refined
},
toggleLandscape: {
transform: [{ rotate: "90deg" }],
bottom: "43%", // Should come from SafeAreaProvider, hardcoded right now, should roughly appear above the button
left: 50, // needs refined
},
portrait: {
bottom: 20, // needs refined
},
landscape: {
bottom: "40%", // Should come from SafeAreaProvider
left: 20, // needs refined
},
goBackPortrait: {
position: "absolute",
top: 20, // or wherever you want the button to be positioned in portrait
left: 20, // or wherever you want the button to be positioned in portrait
},
goBackLandscape: {
position: "absolute",
top: 40,
right: 20,
transform: [{ rotate: "90deg" }],
},
});

View File

@@ -0,0 +1,123 @@
import React, { useCallback, useRef, useState } from "react";
import {
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from "react-native";
// @ts-ignore
import { Camera } from "react-native-vision-camera/lib/typescript/Camera";
// @ts-ignore
import { VideoFile } from "react-native-vision-camera/lib/typescript/VideoFile";
interface RecordingButtonProps {
style: StyleProp<ViewStyle>;
camera: React.RefObject<Camera>;
// eslint-disable-next-line no-unused-vars
onMediaCaptured: (media: VideoFile, mediaType: string) => void;
enabled: boolean;
}
export const RecordingButton: React.FC<RecordingButtonProps> = ({
style,
camera,
onMediaCaptured,
enabled,
}) => {
const isRecording = useRef(false);
// UseRef won't trigger a re-render
const [, setRecordingState] = useState(false);
const onStoppedRecording = useCallback(() => {
isRecording.current = false;
setRecordingState(false);
console.log("stopped recording video!");
}, []);
const stopRecording = useCallback(async () => {
try {
if (camera.current === null) {
throw new Error("Camera ref is null!"); // Error handling could be more graceful
}
console.log("calling stopRecording()...");
await camera.current.stopRecording();
console.log("called stopRecording()!");
} catch (e) {
console.error("failed to stop recording!", e);
}
}, [camera]);
const startRecording = useCallback(() => {
try {
if (camera.current === null) {
throw new Error("Camera ref is null!"); // Error handling could be more graceful
}
console.log("calling startRecording()...");
camera.current.startRecording({
onRecordingError: (error) => {
console.error("Recording failed!", error);
onStoppedRecording();
},
onRecordingFinished: async (video) => {
onMediaCaptured(video, "video");
onStoppedRecording();
},
});
console.log("called startRecording()!");
isRecording.current = true;
setRecordingState(true);
} catch (e) {
console.error("failed to start recording!", e, "camera");
}
}, [camera, onMediaCaptured, onStoppedRecording]);
const handlePress = () => {
if (isRecording.current) {
stopRecording();
} else {
startRecording();
}
};
return (
<TouchableOpacity
style={[styles.captureButton, style]}
onPress={handlePress}
disabled={!enabled}
>
<View
style={
isRecording.current ? styles.recordingSquare : styles.innerCircle
}
/>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
captureButton: {
height: 80,
width: 80,
borderRadius: 40,
borderWidth: 3,
borderColor: "white",
backgroundColor: "transparent",
justifyContent: "center",
alignItems: "center",
},
innerCircle: {
height: 70,
width: 70,
borderRadius: 35,
backgroundColor: "#FF3B30",
},
recordingSquare: {
height: 40,
width: 40,
borderRadius: 10,
backgroundColor: "#FF3B30",
},
});
export default RecordingButton;

View File

@@ -0,0 +1,32 @@
import { Dimensions, Platform } from "react-native";
import StaticSafeAreaInsets from "react-native-static-safe-area-insets";
export const CONTENT_SPACING = 15;
const SAFE_BOTTOM =
Platform.select({
ios: StaticSafeAreaInsets.safeAreaInsetsBottom,
}) ?? 0;
export const SAFE_AREA_PADDING = {
paddingLeft: StaticSafeAreaInsets.safeAreaInsetsLeft + CONTENT_SPACING,
paddingTop: StaticSafeAreaInsets.safeAreaInsetsTop + CONTENT_SPACING,
paddingRight: StaticSafeAreaInsets.safeAreaInsetsRight + CONTENT_SPACING,
paddingBottom: SAFE_BOTTOM + CONTENT_SPACING,
};
// The maximum zoom _factor_ you should be able to zoom in
export const MAX_ZOOM_FACTOR = 10;
export const SCREEN_WIDTH = Dimensions.get("window").width;
export const SCREEN_HEIGHT = Platform.select<number>({
android:
Dimensions.get("screen").height - StaticSafeAreaInsets.safeAreaInsetsBottom,
ios: Dimensions.get("window").height,
}) as number;
// Capture Button
export const CAPTURE_BUTTON_SIZE = 78;
// Control Button like Flash
export const CONTROL_BUTTON_SIZE = 40;

View File

@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
import { AppState, AppStateStatus } from "react-native";
export const useIsForeground = (): boolean => {
const [isForeground, setIsForeground] = useState(true);
useEffect(() => {
const onChange = (state: AppStateStatus): void => {
setIsForeground(state === "active");
};
const listener = AppState.addEventListener("change", onChange);
return () => listener.remove();
}, [setIsForeground]);
return isForeground;
};

View File

@@ -0,0 +1,42 @@
import { DocumentNode, OperationVariables, useQuery } from "@apollo/client";
import React from "react";
import { Text } from "react-native";
/**
* A higher-order component that provides loading and error handling for GraphQL queries.
* @param {React.ComponentType} WrappedComponent - The component to wrap.
* @param {DocumentNode} query - The GraphQL query to execute.
* @param {string} dataKey - The key in the data object to pass to the wrapped component.
* @param {Object} queryOptions - Optional. Additional options for the Apollo query.
* @returns {React.ComponentType} A component that renders the WrappedComponent with loading and error handling.
*/
/* eslint-disable react/display-name */
type WithQueryHandlingProps = {
WrappedComponent: React.ComponentType<any>;
query: DocumentNode;
dataKey: string;
queryOptions?: OperationVariables;
};
function withQueryHandling({
WrappedComponent,
query,
dataKey,
queryOptions = {},
}: WithQueryHandlingProps) {
return function (props: any) {
// You can replace 'any' with specific props type if known
const { loading, error, data } = useQuery(query, queryOptions);
if (loading) {
return <Text>Loading...</Text>;
}
if (error) {
return <Text>Error: {error.message}</Text>;
}
const specificData = data[dataKey];
return <WrappedComponent {...props} data={specificData} />;
};
}
export default withQueryHandling;