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

48
src/App.tsx Normal file
View File

@@ -0,0 +1,48 @@
import React, { useEffect } from "react";
import { ClientProvider, useAuthHeader } from "./graphql/client";
import AppNavigator from "./navigation/app-navigator";
import { DEV_USER_ID } from "@env";
import AsyncStorage from "@react-native-async-storage/async-storage";
// TODO: move to different file?
const SetAuthHeaderBasedOnEnv = () => {
const { setAuthHeader } = useAuthHeader();
useEffect(() => {
const setAuthAsync = async () => {
if (DEV_USER_ID) {
console.log("Setting fake authorization user to: ", DEV_USER_ID);
setAuthHeader({ key: "user_id", value: DEV_USER_ID });
} else {
// Fetch token for authenticated users in production
const token = await AsyncStorage.getItem("token"); // get from not firebase auth ASYNC
if (token) {
console.log("Setting firebase auth token");
setAuthHeader({ key: "Authorization", value: `Bearer ${token}` });
}
}
};
setAuthAsync();
}, [setAuthHeader]);
return null;
};
const App: React.FC = () => {
return (
<ClientProvider>
<SetAuthHeaderBasedOnEnv />
<AppNavigator />
</ClientProvider>
);
};
export default function Root() {
return (
<React.StrictMode>
<App />
</React.StrictMode>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,53 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import auth, { FirebaseAuthTypes } from "@react-native-firebase/auth";
import { Alert } from "react-native";
export const handleSignInWithPhoneNumber = async (
phoneNumber: string,
): Promise<FirebaseAuthTypes.ConfirmationResult | undefined> => {
if (!phoneNumber) {
Alert.alert("Please enter a valid phone number with a country code");
return;
}
try {
const confirmation = await auth().signInWithPhoneNumber(phoneNumber);
return confirmation;
} catch (err) {
console.warn(err);
Alert.alert(
"There was an error. Make sure you are using a country code (ex: +1 for US)",
);
}
};
export const confirmCode = async (
confirm: FirebaseAuthTypes.ConfirmationResult,
code: string,
): Promise<void> => {
try {
await confirm.confirm(code);
} catch {
Alert.alert("Invalid code, please try again.");
}
};
export const onAuthStateChanged = (
// TODO: eslint not detecting ts?
// eslint-disable-next-line no-unused-vars
callback: (user: FirebaseAuthTypes.User | null) => void,
) => {
return auth().onAuthStateChanged(callback);
};
export const getCurrentUserToken = async (): Promise<string | null> => {
const user = auth().currentUser;
if (user) {
return await user.getIdToken();
}
return null;
};
export const handleSignOut = async (): Promise<void> => {
await AsyncStorage.removeItem("token");
await auth().signOut();
};

15
src/auth/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import {
confirmCode,
getCurrentUserToken,
handleSignInWithPhoneNumber,
handleSignOut,
onAuthStateChanged,
} from "./firebase-auth";
export {
confirmCode,
getCurrentUserToken,
handleSignInWithPhoneNumber,
handleSignOut,
onAuthStateChanged,
};

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;

81
src/graphql/client.tsx Normal file
View File

@@ -0,0 +1,81 @@
import {
ApolloClient,
ApolloLink,
ApolloProvider,
HttpLink,
InMemoryCache,
from,
} from "@apollo/client";
import React, {
ReactNode,
createContext,
useContext,
useMemo,
useState,
} from "react";
import { API_URI } from "@env";
type Props = {
children: ReactNode;
};
export const AuthHeaderContext = createContext<
| {
authHeader: { key: string; value: string };
setAuthHeader: React.Dispatch<
React.SetStateAction<{ key: string; value: string }>
>;
}
| undefined
>(undefined);
// Hook to use the auth header context
export const useAuthHeader = () => {
const context = useContext(AuthHeaderContext);
if (!context) {
throw new Error("useAuthHeader must be used within a ClientProvider");
}
return context;
};
export const ClientProvider: React.FC<Props> = ({ children }) => {
const [authHeader, setAuthHeader] = useState({ key: "", value: "" });
console.log(`The api uri is ${API_URI}`);
const httpLink = new HttpLink({
uri: API_URI,
});
const cache = new InMemoryCache({});
const authMiddleware = new ApolloLink((operation, forward) => {
const { key, value } = authHeader;
console.log("Auth Key", key, "Value", value);
if (key && value) {
operation.setContext({
headers: {
[key]: value,
},
});
}
return forward(operation);
});
// We use useMemo to avoid recreating the client on every render
const client = useMemo(
() =>
new ApolloClient({
// Chain the middleware with the httpLink
link: from([authMiddleware, httpLink]),
cache: new InMemoryCache(),
}),
[authHeader],
);
return (
<AuthHeaderContext.Provider value={{ authHeader, setAuthHeader }}>
<ApolloProvider client={client}>{children}</ApolloProvider>
</AuthHeaderContext.Provider>
);
};

31
src/graphql/filter.ts Normal file
View File

@@ -0,0 +1,31 @@
type BaseFilter = {
createVars: () => { [key: string]: any };
};
function createBaseFilter(key: string, content: any): BaseFilter {
return {
createVars: () => ({ [key]: content }),
};
}
export function createAndFilter(filters: Array<BaseFilter>): BaseFilter {
const filterVars = filters.map((filter) => filter.createVars());
return createBaseFilter("andFilters", { filters: filterVars });
}
export function createCategoryFilter(
feature: string,
value: string,
): BaseFilter {
const content = { [feature]: { value } };
return createBaseFilter(feature, content);
}
export function createRangeFilter(
feature: string,
greaterThanEqualTo: number,
lessThan: number,
): BaseFilter {
const content = { greaterThanEqualTo, lessThan };
return createBaseFilter(feature, content);
}

34
src/graphql/query.ts Normal file
View File

@@ -0,0 +1,34 @@
import { gql } from "@apollo/client";
export const GET_SHOTS = gql`
query GetShots(
$filterInput: FilterInput
$includeCueObjectDistance: Boolean! = false
$includeCueObjectAngle: Boolean! = false
$includeCueBallSpeed: Boolean! = false
$includeShotDirection: Boolean! = false
$includeTargetPocketDistance: Boolean! = false
$includeMake: Boolean! = false
$includeIntendedPocketType: Boolean! = false
) {
getShots(filterInput: $filterInput) {
id
videoId
startFrame
endFrame
createdAt
updatedAt
cueObjectFeatures {
cueObjectDistance @include(if: $includeCueObjectDistance)
cueObjectAngle @include(if: $includeCueObjectAngle)
cueBallSpeed @include(if: $includeCueBallSpeed)
shotDirection @include(if: $includeShotDirection)
}
pocketingIntentionFeatures {
targetPocketDistance @include(if: $includeTargetPocketDistance)
make @include(if: $includeMake)
intendedPocketType @include(if: $includeIntendedPocketType)
}
}
}
`;

View File

@@ -0,0 +1,46 @@
import {
DarkTheme,
DefaultTheme,
NavigationContainer,
} from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { useColorScheme } from "react-native";
import Login from "../screens/login";
import Tabs from "./tab-navigator";
const Stack = createNativeStackNavigator();
const ScreensStack = () => (
<Stack.Navigator>
<Stack.Screen
name="Tabs"
component={Tabs}
options={{ headerShown: false }}
/>
<Stack.Screen
name="Login"
component={Login}
options={{ headerShown: false }}
/>
</Stack.Navigator>
);
/**
* Functional component for app navigation. Configures a navigation container with a stack navigator.
* Dynamically selects between dark and light themes based on the device's color scheme.
* The stack navigator is configured to manage various app screens.
*
* @returns {React.ComponentType} A NavigationContainer wrapping a Stack.Navigator for app screens.
*/
export default function AppNavigator(): React.JSX.Element {
// useColorScheme get's the theme from device settings
const scheme = useColorScheme();
return (
<NavigationContainer theme={scheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="App" component={ScreensStack} />
</Stack.Navigator>
</NavigationContainer>
);
}

View File

@@ -0,0 +1,61 @@
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { Image } from "react-native";
import CameraScreen from "../component/video/camera";
import Session from "../screens/session";
import RecordScreen from "../screens/video-stack/record";
// TODO: add ts support for assets folder to use imports
const Icon = require("../assets/favicon.png");
const Tab = createBottomTabNavigator();
const RecordStack = createNativeStackNavigator();
// tabBarIcon configuration should live on separate file and contain all logic/icons/rendering for the Tabs
const tabIcons = {
Session: <Image source={Icon} style={{ width: 20, height: 20 }} />,
VideoStack: <Image source={Icon} style={{ width: 20, height: 20 }} />,
};
function VideoTabStack() {
return (
<RecordStack.Navigator screenOptions={{ headerShown: false }}>
<RecordStack.Screen name="Record" component={RecordScreen} />
<RecordStack.Screen name="Camera" component={CameraScreen} />
</RecordStack.Navigator>
);
}
/**
* Functional component creating a tab navigator with called
* Uses React Navigation's Tab.Navigator. Customizes tab bar appearance and icons.
* Import screens and call them on component of Tab.Screen.
*
* @returns {React.ComponentType} A Tab.Navigator component with bottom tabs with screens.
*/
export default function Tabs(): React.JSX.Element {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarActiveTintColor: "tomato",
tabBarInactiveTintColor: "gray",
tabBarIcon: () => {
return tabIcons[route.name];
},
})}
>
<Tab.Screen
name="Session"
component={Session}
options={{ tabBarLabel: "Session" }}
/>
<Tab.Screen
name="VideoStack"
component={VideoTabStack}
options={{ tabBarLabel: "Record" }}
/>
</Tab.Navigator>
);
}

110
src/screens/login.tsx Normal file
View File

@@ -0,0 +1,110 @@
// Login.tsx
import AsyncStorage from "@react-native-async-storage/async-storage";
import { FirebaseAuthTypes } from "@react-native-firebase/auth";
import React, { useEffect, useState } from "react";
import {
Button,
Keyboard,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from "react-native";
import {
confirmCode,
handleSignInWithPhoneNumber,
onAuthStateChanged,
} from "../auth";
import SignOutButton from "../component/buttons/sign-out";
import { useAuthHeader } from "../graphql/client";
export default function Login({ navigation }) {
const [phoneNumber, setPhoneNumber] = useState<string>("");
const [code, setCode] = useState<string>("");
const [user, setUser] = useState<FirebaseAuthTypes.User | null>(null);
const [confirm, setConfirm] =
useState<FirebaseAuthTypes.ConfirmationResult | null>(null);
const { authHeader, setAuthHeader } = useAuthHeader();
useEffect(() => {
const unsubscribe = onAuthStateChanged(async (user) => {
setUser(user);
if (user) {
const token = await user.getIdToken();
if (token) {
await AsyncStorage.setItem("token", token);
setAuthHeader({ key: "Authorization", value: token });
}
}
});
return unsubscribe;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
console.log(authHeader.value);
return (
<TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}>
<View style={{ alignItems: "center" }}>
<TextInput
style={{
width: "50%",
height: 30,
borderWidth: 1,
borderColor: "black",
marginBottom: 20,
}}
placeholder="Phone"
textContentType="telephoneNumber"
keyboardType="phone-pad"
autoCapitalize="none"
value={phoneNumber}
onChangeText={(value) => setPhoneNumber(value)}
/>
{confirm && (
<TextInput
style={{
width: "50%",
height: 30,
borderWidth: 1,
borderColor: "black",
marginBottom: 20,
}}
placeholder="Code"
keyboardType="number-pad"
textContentType="oneTimeCode"
autoCapitalize="none"
value={code}
onChangeText={(value) => setCode(value)}
/>
)}
<Button
title={!confirm ? "Receive code" : "Confirm code"}
onPress={() =>
!confirm
? handleSignInWithPhoneNumber(phoneNumber).then(setConfirm)
: confirm && confirmCode(confirm, code)
}
/>
<Text>
{authHeader.key}: {authHeader.value}
</Text>
{user && (
<>
<Text style={{ marginTop: 10 }}>
Display name: {user?.displayName}
</Text>
<Text>Phone number: {user?.phoneNumber}</Text>
<SignOutButton />
<Button
color="orange"
title="Go to app"
onPress={() => navigation.push("Tabs")}
/>
</>
)}
</View>
</TouchableWithoutFeedback>
);
}

14
src/screens/session.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from "react";
import { StyleSheet, View } from "react-native";
import BarGraph from "../component/charts/bar-graph/bar-graph";
import { graph_data_two_measures } from "../../mock/charts/mock-data";
// Session Mock - can be used for session summary screen using a query handler component
// BarGraph component using mocked data currently
export default function SessionScreen() {
return (
<View style={StyleSheet.absoluteFill}>
<BarGraph data={graph_data_two_measures} />
</View>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useCallback, useState } from "react";
import {
Keyboard,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from "react-native";
import DropDownPicker from "react-native-dropdown-picker";
import { recordStyles as styles } from "./styles";
interface CameraScreenParams {
gameType: string;
tableSize: string;
tags: Array<string>;
location: string;
}
// Record Screen
// Precedes Camera.tsx
// Can be made into Modal when ready
export default function RecordScreen({ navigation }): React.JSX.Element {
// Game type dropdown
const [gameTypeOpen, setGameTypeOpen] = useState<boolean>(false);
const [gameType, setGameType] = useState<string | null>(null); // This is a dropdown
const [gameTypes, setGameTypes] = useState([
{ label: "Free Play", value: "freePlay" },
{ label: "Straight Pool", value: "straightPool" },
{ label: "Nine Ball", value: "nineBall" },
]);
const onGameTypeOpen = useCallback(() => {
setTableSizeOpen(false);
setTagsOpen(false);
}, []);
// Table size dropdown
const [tableSizeOpen, setTableSizeOpen] = useState<boolean>(false);
const [tableSize, setTableSize] = useState<string>("");
const [tableSizes, setTableSizes] = useState([
{ label: `9'`, value: "nineFoot" },
{ label: `8'`, value: "eightFoot" },
{ label: "7", value: "sevenFoot" },
]);
const onTableSizeOpen = useCallback(() => {
setGameTypeOpen(false);
setTagsOpen(false);
}, []);
// Tags multi-select dropdown
const [tagsOpen, setTagsOpen] = useState<boolean>(false);
const [tags, setTags] = useState<Array<string>>([]);
const [tagsList, setTagsList] = useState([
{ label: `Tag1`, value: "tag1" },
{ label: `Tag2`, value: "tag2" },
{ label: "Tag3", value: "tag3" },
]);
const onTagsOpen = useCallback(() => {
setTableSizeOpen(false);
setGameTypeOpen(false);
}, []);
// Location
const [location, setLocation] = useState<string>("");
const handleSubmit = () => {
// needs to pass info as params or store in a context/state provider
const params: CameraScreenParams = {
gameType: gameType,
tableSize: tableSize,
tags: tags,
location: location,
};
navigation.push("Camera", params);
};
const dropDownStyles = {
style: styles.dropdownStyle,
};
return (
<TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}>
<View style={styles.container}>
<View style={styles.dropdownContainer}>
<Text style={styles.dropdownTitle}>Game Type</Text>
<DropDownPicker
zIndex={3000}
zIndexInverse={1000}
open={gameTypeOpen}
value={gameType}
items={gameTypes}
setOpen={setGameTypeOpen}
setValue={setGameType}
setItems={setGameTypes}
onOpen={onGameTypeOpen}
{...dropDownStyles}
/>
<Text style={styles.dropdownTitle}>Table size</Text>
<DropDownPicker
zIndex={2000}
zIndexInverse={2000}
open={tableSizeOpen}
value={tableSize}
items={tableSizes}
setOpen={setTableSizeOpen}
setValue={setTableSize}
setItems={setTableSizes}
onOpen={onTableSizeOpen}
{...dropDownStyles}
/>
<Text style={styles.dropdownTitle}>Tags</Text>
<DropDownPicker
zIndex={1000}
zIndexInverse={3000}
multiple
min={0}
max={5}
open={tagsOpen}
value={tags}
items={tagsList}
setOpen={setTagsOpen}
setValue={setTags}
setItems={setTagsList}
onOpen={onTagsOpen}
{...dropDownStyles}
/>
</View>
<Text style={styles.dropdownTitle}>Location</Text>
<TextInput
style={styles.input}
placeholder="Location"
value={location}
onChangeText={(value) => setLocation(value)}
/>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={styles.buttonStyle}
onPress={() => navigation.goBack()}
>
<Text style={styles.buttonText}>Back</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.buttonStyle} onPress={handleSubmit}>
<Text style={styles.buttonText}>Next</Text>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
);
}

View File

@@ -0,0 +1,57 @@
import { StyleSheet } from "react-native";
export const recordStyles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
},
dropdownContainer: {
width: "100%",
marginBottom: 20,
zIndex: 50,
},
dropdownTitle: {
fontSize: 16,
fontWeight: "500",
marginBottom: 5,
alignSelf: "flex-start",
},
input: {
width: "100%",
marginBottom: 20,
borderWidth: 1,
borderColor: "grey",
borderRadius: 5,
padding: 10,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
},
buttonStyle: {
backgroundColor: "lightblue",
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 20,
margin: 10,
},
buttonText: {
color: "white",
textAlign: "center",
},
dropdownStyle: {
backgroundColor: "#ffffff",
borderColor: "#D1D1D1",
borderWidth: 1,
borderRadius: 4,
},
dropdownContainerStyle: {
marginBottom: 10,
borderColor: "#D1D1D1",
borderWidth: 1,
borderRadius: 4,
},
});

26
src/styles.ts Normal file
View File

@@ -0,0 +1,26 @@
// GLOBAL STYLES
// COLORS:
// can be made more granular to specify utility (ex: fontColors vs backgroundColors)
export const colors = {
bgBlack: "#121212",
lightGrey: "#BFC2C8",
themeBrown: "#D9AA84",
panelWhite: "#F2FBFE",
tournamentBlue: "#50a6c2",
blueCloth: "#539dc2",
buttonBlue: "#1987ff",
textWhite: "#ffffff",
};
export const shadows = {
standard: {
shadowColor: "#000000",
shadowOffset: {
width: 0,
height: 3,
},
shadowOpacity: 0.1,
shadowRadius: 4.65,
elevation: 3,
},
};