Merge pull request 'Finalize recording' (#117) from loewy/finalize-video-flow into master

Reviewed-on: railbird/railbird-mobile#117
Reviewed-by: Kat Huang <kkathuang@gmail.com>
This commit is contained in:
loewy 2024-02-20 11:28:14 -07:00
commit 7bddeca783
14 changed files with 336 additions and 206 deletions

View File

@ -31,7 +31,7 @@ module.exports = {
// Best Practices
eqeqeq: ["error", "always"], // Enforce '===' instead of '=='
curly: ["error", "multi-line", "consistent"], // Require curly braces for all control statements
"no-unused-vars": "warn", // Warn about variables that are declared but not used
"no-unused-vars": "off", // Not needed with TypeScript
// React Specific
"react/jsx-filename-extension": [1, { extensions: [".tsx"] }], // Allow jsx syntax in .tsx files

View File

@ -38,7 +38,7 @@
"googleServicesFile": "./google-services.json"
},
"web": {
"favicon": "./src/assets/favicon.png"
"favicon": "./src/assets/icons/favicon.png"
}
}
}

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -4,7 +4,6 @@ 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,
@ -19,6 +18,7 @@ import { RecordingButton } from "./capture-button";
import { useIsForeground } from "./is-foreground";
type Dictionary<KeyType extends string | number | symbol, ValueType> = {
// eslint-disable-next-line no-unused-vars
[key in KeyType]: ValueType;
};
@ -111,6 +111,7 @@ export default function CameraScreen({
navigation,
}): React.ReactElement {
const apolloClient = useApolloClient();
const { params } = route;
const [createUpload, { data, loading, error }] =
gql.useCreateUploadStreamMutation();
@ -127,6 +128,7 @@ export default function CameraScreen({
console.log(`VideoId: ${newVideoId}`);
setUploadManager(new StreamUploadManager(apolloClient, newVideoId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, uploadManager]);
const camera = useRef<Camera>(null);
@ -143,14 +145,24 @@ export default function CameraScreen({
}, []);
const onInitialized = useCallback(() => {
console.log("Camera initialized!");
setIsCameraInitialized(true);
createUpload({ variables: { videoName: "Test" } });
createUpload({
variables: { videoName: params?.sessionName ?? "New session" },
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onMediaCaptured = useCallback((media: PhotoFile | VideoFile) => {
console.log(`Media captured! ${JSON.stringify(media)}`);
}, []);
const onMediaCaptured = useCallback(
(media: PhotoFile | VideoFile) => {
console.log(`Media captured! ${JSON.stringify(media)}`);
navigation.push("SaveVideo", {
videoId: uploadManager?.videoId,
...params,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[uploadManager, navigation, params],
);
const onVideoChunkReady = useCallback(
(event) => {
@ -179,7 +191,6 @@ export default function CameraScreen({
// Replace with error handling
if (device === null) {
console.log(device);
// hasPermission redundant here - user should not be able to launch camera without permissions
return (
<Text>

View File

@ -0,0 +1,154 @@
import * as gql from "railbird-gql";
import { useCallback, useState } from "react";
// @ts-ignore
import { useCameraPermission } from "react-native-vision-camera";
import { showAlert } from "../../lib/alert-messages";
// TODO: #122 should decide on what values/labels go here.
enum GameType {
FreePlay = "freePlay",
StraightPool = "straightPool",
NineBall = "nineBall",
}
// TODO: #122 should be int -- inch value of table size.
enum TableSize {
NineFoot = "nineFoot",
EightFoot = "eightFoot",
SevenFoot = "sevenFoot",
}
interface VideoUserInputMeta {
sessionName: string;
gameType: GameType | null;
tableSize: TableSize | null;
}
interface VideoFlowInputParams extends VideoUserInputMeta {
videoId?: number;
}
function useDropdown<T>(
initialValue: T | null,
options: Array<{ label: string; value: T }>,
closeOthers: () => void,
) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [value, setValue] = useState<T | null>(initialValue);
const [optionsList, setOptionsList] = useState(options);
const toggleOpen = useCallback(() => {
if (!isDropdownOpen) {
closeOthers();
}
setIsDropdownOpen(!isDropdownOpen);
}, [isDropdownOpen, closeOthers]);
return {
isDropdownOpen,
toggleOpen,
value,
setValue,
optionsList,
setOptionsList,
setIsDropdownOpen,
};
}
export const useVideoDetails = ({
params: {
mode,
videoId,
sessionName: initialSessionName,
gameType: initialGameType,
tableSize: initialTableSize,
},
navigation,
}) => {
const initialState = {
sessionName: initialSessionName || "",
gameType: initialGameType || null,
tableSize: initialTableSize || null,
};
const { hasPermission, requestPermission } = useCameraPermission();
const [sessionName, setSessionName] = useState<string>(
initialState.sessionName,
);
const closeAllDropdowns = useCallback(() => {
gameType.setIsDropdownOpen(false);
tableSize.setIsDropdownOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gameType = useDropdown<GameType | null>(
initialState.gameType,
[
{ label: "Free Play", value: GameType.FreePlay },
{ label: "Straight Pool", value: GameType.StraightPool },
{ label: "Nine Ball", value: GameType.NineBall },
],
closeAllDropdowns,
);
const tableSize = useDropdown<TableSize | null>(
initialState.tableSize,
[
{ label: `9'`, value: TableSize.NineFoot },
{ label: `8'`, value: TableSize.EightFoot },
{ label: `7'`, value: TableSize.SevenFoot },
],
closeAllDropdowns,
);
const [terminateUploadStream, { loading, error }] =
gql.useTerminateUploadStreamMutation();
const handleSubmit = async () => {
// Check permissions
if (!hasPermission) {
try {
const permissionResult = await requestPermission();
if (permissionResult.status !== "granted") {
return showAlert("camera");
}
} catch (err) {
return showAlert("permissionError");
}
}
// Navigate if starting flow, terminateUploadStream if completing flow
if (mode === "start-video") {
const params: VideoFlowInputParams = {
sessionName,
gameType: gameType.value,
tableSize: tableSize.value,
};
navigation.push("Camera", params);
} else {
try {
await terminateUploadStream({
variables: {
videoId,
videoName: sessionName,
tableSize: tableSize.value,
gameType: gameType.value,
},
});
navigation.push("Tabs");
} catch (err) {
showAlert("terminateUpload");
}
}
};
return {
sessionName,
setSessionName,
gameType,
tableSize,
handleSubmit,
loading,
error,
};
};

View File

@ -50,7 +50,6 @@ export const ClientProvider: React.FC<Props> = ({ children }) => {
const authMiddleware = new ApolloLink((operation, forward) => {
const { key, value } = authHeader;
console.log("Auth Key", key, "Value", value);
if (key && value) {
operation.setContext({
headers: {

View File

@ -8,6 +8,10 @@ interface PermissionMessage {
message: string;
};
}
interface ApiError {
title: string;
message: string;
}
export const CAMERA_PERMISSION_DENIED: PermissionMessage = {
android: {
@ -19,3 +23,13 @@ export const CAMERA_PERMISSION_DENIED: PermissionMessage = {
message: "Please go to Settings > Railbird > Camera and grant permissions.",
},
};
export const PERMISSION_ERROR: ApiError = {
title: "There was an issue accessing permissions. ",
message: "Please check you settings and try again",
};
export const TERMINATE_UPLOAD_ERROR: ApiError = {
title: "There was an issue.",
message: "Please try again",
};

View File

@ -1,12 +1,18 @@
import { Alert, Platform } from "react-native";
import { CAMERA_PERMISSION_DENIED } from "./constants";
import {
CAMERA_PERMISSION_DENIED,
PERMISSION_ERROR,
TERMINATE_UPLOAD_ERROR,
} from "./constants";
const ALERT_TYPE = {
camera: CAMERA_PERMISSION_DENIED,
permissionError: PERMISSION_ERROR,
terminateUpload: TERMINATE_UPLOAD_ERROR,
};
export const showAlert = (alertType: string) => {
const alert = ALERT_TYPE[alertType];
const { title, message } = alert[Platform.OS];
const { title, message } = alert[Platform.OS] ?? alert;
Alert.alert(title, message);
};

View File

@ -4,10 +4,10 @@ import { Image } from "react-native";
import CameraScreen from "../component/video/camera";
import Profile from "../screens/profile";
import Session from "../screens/session";
import RecordScreen from "../screens/video-stack/record";
import VideoDetails from "../screens/video-stack/video-details";
import { tabIconColors } from "../styles";
import Icon from "../assets/favicon.png";
import Icon from "../assets/icons/favicon.png";
const Tab = createBottomTabNavigator();
const RecordStack = createNativeStackNavigator();
@ -22,8 +22,17 @@ const tabIcons = {
function VideoTabStack() {
return (
<RecordStack.Navigator screenOptions={{ headerShown: false }}>
<RecordStack.Screen name="Record" component={RecordScreen} />
<RecordStack.Screen
name="Record"
component={VideoDetails}
initialParams={{ mode: "start-video" }}
/>
<RecordStack.Screen name="Camera" component={CameraScreen} />
<RecordStack.Screen
name="SaveVideo"
component={VideoDetails}
initialParams={{ mode: "save-video" }}
/>
</RecordStack.Navigator>
);
}

View File

@ -1,161 +0,0 @@
import React, { useCallback, useState } from "react";
import {
Keyboard,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from "react-native";
import DropDownPicker from "react-native-dropdown-picker";
// @ts-ignore
import { useCameraPermission } from "react-native-vision-camera";
import { showAlert } from "../../lib/alert-messages";
import { recordStyles as styles } from "./styles";
interface CameraScreenParams {
gameType: string;
tableSize: string;
tags: Array<string>;
location: string;
}
export default function RecordScreen({ navigation }): React.JSX.Element {
// Permissions
const { hasPermission, requestPermission } = useCameraPermission();
if (!hasPermission) {
requestPermission();
}
// 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 = () => {
if (!hasPermission) {
return showAlert("camera");
}
// 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

@ -7,26 +7,13 @@ export const recordStyles = StyleSheet.create({
justifyContent: "center",
padding: 20,
},
dropdownContainer: {
width: "100%",
marginBottom: 20,
zIndex: 50,
},
dropdownTitle: {
fontSize: 16,
headerText: {
fontSize: 22,
fontWeight: "500",
marginBottom: 5,
alignSelf: "flex-start",
},
input: {
width: "100%",
marginBottom: 20,
borderWidth: 1,
borderColor: "grey",
borderRadius: 5,
padding: 10,
paddingBottom: "10%",
},
buttonContainer: {
marginTop: 10,
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
@ -42,16 +29,4 @@ export const recordStyles = StyleSheet.create({
color: "white",
textAlign: "center",
},
dropdownStyle: {
backgroundColor: "#ffffff",
borderColor: "#D1D1D1",
borderWidth: 1,
borderRadius: 4,
},
dropdownContainerStyle: {
marginBottom: 10,
borderColor: "#D1D1D1",
borderWidth: 1,
borderRadius: 4,
},
});

View File

@ -0,0 +1,94 @@
import React from "react";
import {
ActivityIndicator,
Keyboard,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from "react-native";
import DropDownPicker from "react-native-dropdown-picker";
import { useVideoDetails } from "../../component/video/use-video-details";
import { globalInputStyles } from "../../styles";
import { recordStyles as styles } from "./styles";
export default function VideoDetails({ navigation, route }): React.JSX.Element {
const { mode } = route.params;
const {
sessionName,
setSessionName,
gameType,
tableSize,
handleSubmit,
loading,
} = useVideoDetails({ params: route.params, navigation });
const dropDownStyles = {
style: globalInputStyles.dropdownStyle,
};
return (
<TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}>
<View style={styles.container}>
<Text style={styles.headerText}>
{mode === "start-video" ? "Record Session" : "Save Session"}
</Text>
<View style={globalInputStyles.dropdownContainer}>
<Text style={globalInputStyles.dropdownTitle}>Session Name</Text>
<TextInput
style={globalInputStyles.input}
placeholder="Session name"
autoCapitalize="none"
value={sessionName}
onChangeText={setSessionName}
/>
<Text style={globalInputStyles.dropdownTitle}>Game Type</Text>
<DropDownPicker
zIndex={3000}
zIndexInverse={1000}
open={gameType.isDropdownOpen}
value={gameType.value}
items={gameType.optionsList}
setOpen={gameType.toggleOpen}
setValue={gameType.setValue}
setItems={gameType.setOptionsList}
{...dropDownStyles}
/>
<Text style={globalInputStyles.dropdownTitle}>Table Size</Text>
<DropDownPicker
zIndex={2000}
zIndexInverse={2000}
open={tableSize.isDropdownOpen}
value={tableSize.value}
items={tableSize.optionsList}
setOpen={tableSize.toggleOpen}
setValue={tableSize.setValue}
setItems={tableSize.setOptionsList}
{...dropDownStyles}
/>
</View>
<View style={styles.buttonContainer}>
{mode === "start-video" && (
<TouchableOpacity
style={styles.buttonStyle}
onPress={() => navigation.goBack()}
>
<Text style={styles.buttonText}>Back</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.buttonStyle} onPress={handleSubmit}>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>
{mode === "start-video" ? "Next" : "Save"}
</Text>
)}
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
);
}

View File

@ -1,4 +1,33 @@
// GLOBAL STYLES
import { StyleSheet } from "react-native";
export const globalInputStyles = StyleSheet.create({
input: {
width: "100%",
padding: 10,
backgroundColor: "#ffffff",
borderColor: "#D1D1D1",
borderWidth: 1,
borderRadius: 4,
},
dropdownContainer: {
width: "100%",
marginBottom: 20,
zIndex: 50,
},
dropdownTitle: {
fontSize: 16,
fontWeight: "500",
marginBottom: 5,
alignSelf: "flex-start",
},
dropdownStyle: {
backgroundColor: "#ffffff",
borderColor: "#D1D1D1",
borderWidth: 1,
borderRadius: 4,
},
});
// COLORS:
// can be made more granular to specify utility (ex: fontColors vs backgroundColors)
export const colors = {

View File

@ -9800,7 +9800,7 @@ queue@6.0.2:
"railbird-gql@git+https://dev.railbird.ai/railbird/railbird-gql.git#master":
version "1.0.0"
resolved "git+https://dev.railbird.ai/railbird/railbird-gql.git#204e289627b08a96b947b6f23ed4a843d9e8abff"
resolved "git+https://dev.railbird.ai/railbird/railbird-gql.git#ce8cfd6a68d7b27f478c99dd5ae74a411d4d0c77"
dependencies:
"@apollo/client" "^3.9.2"
"@graphql-codegen/cli" "^5.0.0"