Commit 139503ff authored by Milan John Paul Digiuseppe's avatar Milan John Paul Digiuseppe

Merge branch 'cache-add-image' into 'master'

Create cache flow: add images

See merge request !4
parents 43082c69 e666b2ee
......@@ -3583,6 +3583,15 @@
"url-parse": "^1.4.4"
}
},
"expo-camera": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-8.2.0.tgz",
"integrity": "sha512-wGVqQVVR4YCkHuI7u+qloI1p2UbYwpU8hs0IEM+1Th4JQekAPUEfOmJI6ZHGj36El8f87ghRed2MXzOssn4hHg==",
"requires": {
"lodash": "^4.6.0",
"prop-types": "^15.6.0"
}
},
"expo-constants": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-8.0.0.tgz",
......@@ -3612,6 +3621,14 @@
"fontfaceobserver": "^2.1.0"
}
},
"expo-image-picker": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-8.0.2.tgz",
"integrity": "sha512-oa0/JoKIv5mxyadpVqiQ+BXak2s7QS3+Rurj1j/gL5GGFmb3cDJWbGHxvtrEXEccv8812JAp5BfBRqfzlW1gMQ==",
"requires": {
"expo-permissions": "*"
}
},
"expo-keep-awake": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-8.0.0.tgz",
......
import React from 'react';
import {
TouchableOpacity, View, ViewStyle, StyleSheet,
} from 'react-native';
import theme from '../theme';
const SIZE = 50;
const styles = StyleSheet.create({
outerRing: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
height: SIZE,
width: SIZE,
borderRadius: SIZE,
borderColor: theme.colors.white,
},
innerCircle: {
borderWidth: 2,
height: SIZE - 10,
width: SIZE - 10,
borderRadius: SIZE - 10,
borderColor: theme.colors.white,
backgroundColor: theme.colors.white,
},
});
interface Props {
onPress: () => void;
style?: ViewStyle;
}
const CameraButton: React.FC<Props> = ({ onPress, style }) => (
<TouchableOpacity style={style} onPress={onPress}>
<View style={styles.outerRing}>
<View style={styles.innerCircle} />
</View>
</TouchableOpacity>
);
export default CameraButton;
import React from 'react';
import { TouchableOpacity } from 'react-native-gesture-handler';
import { Icon } from 'react-native-elements';
import { ViewStyle } from 'react-native';
import { Color, COLOR_MAP } from '../theme';
const SIZE = 20;
interface Props {
color?: Color;
icon: string;
onPress: () => void;
style?: ViewStyle;
}
const IconButton: React.FC<Props> = ({
color = 'black',
icon,
onPress,
style,
}) => (
<TouchableOpacity
onPress={onPress}
style={[style, { width: SIZE, height: SIZE }]}
>
<Icon
color={COLOR_MAP[color]}
name={icon}
size={SIZE}
type={'font-awesome-5'}
/>
</TouchableOpacity>
);
export default IconButton;
......@@ -2,6 +2,7 @@ import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import HomeNavigator from './HomeNavigator';
import CreateCacheNavigator from './CreateCacheNavigator';
import CameraModal from '../screens/create/CameraModal';
const Stack = createStackNavigator();
const AppNavigator = () => (
......@@ -14,6 +15,10 @@ const AppNavigator = () => (
name={'Create'}
component={CreateCacheNavigator}
/>
<Stack.Screen
name={'CameraModal'}
component={CameraModal}
/>
</Stack.Navigator>
);
......
import React, { useState, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { StyleSheet, TextInput, View } from 'react-native';
import React, {
useState, useCallback, useEffect, useMemo,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
StyleSheet, TextInput, View, Image, LayoutChangeEvent,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import * as ImagePicker from 'expo-image-picker';
import Constants from 'expo-constants';
import { ScrollView } from 'react-native-gesture-handler';
import { ImageInfo } from 'expo-image-picker/build/ImagePicker.types';
import theme from '../../theme';
import GeoText from '../../components/GeoText';
import { setPendingCacheContent } from '../../store/pendingNewCache/PendingCacheSlice';
import { setPendingCacheContent, setPendingCacheImage } from '../../store/pendingNewCache/PendingCacheSlice';
import GeoButton from '../../components/GeoButton';
const { spacing } = theme;
import ScreenContainer from './ScreenContainer';
import IconButton from '../../components/IconButton';
import { getPendingCacheImage } from '../../store/pendingNewCache/PendingCacheSelectors';
const CHAR_MAX = 100;
const styles = StyleSheet.create({
container: {
paddingTop: spacing.medium,
paddingHorizontal: spacing.screenPadding,
flex: 1,
paddingHorizontal: theme.spacing.screenPadding,
},
textAreaFooter: {
flex: 1,
justifyContent: 'space-between',
flexDirection: 'row',
padding: theme.spacing.small,
},
});
const CacheMessageScreen: React.FC = () => {
const dispatch = useDispatch();
const { navigate } = useNavigation();
const dispatch = useDispatch();
const [text, setText] = useState('');
const image = useSelector(getPendingCacheImage);
const setImage = useCallback((img: ImageInfo) => {
dispatch(setPendingCacheImage(img));
}, [dispatch]);
// Request camera roll permissions
useEffect(() => {
(async () => {
if (Constants.platform!.ios) {
const { status } = await ImagePicker.requestCameraRollPermissionsAsync();
if (status !== 'granted') {
console.warn('Camera roll permissions not granted');
}
}
})();
}, []);
// Determine image height a la "auto"
const [scrollWidth, setScrollWidth] = useState(0);
const onScrollViewLayout = useCallback((event: LayoutChangeEvent) => {
setScrollWidth(event.nativeEvent.layout.width);
}, [setScrollWidth]);
const imageHeight = useMemo(() => (image ? (scrollWidth * image.height) / image.width : 0), [image, scrollWidth]);
const onPickImage = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All,
allowsEditing: true,
quality: 1,
});
if (!result.cancelled) {
setImage(result);
}
};
const onNext = useCallback(() => {
dispatch(setPendingCacheContent(text));
navigate('CacheDuration');
}, [dispatch, navigate, text]);
return (
<View style={styles.container}>
<GeoText
variant={'formHeader'}
text={'milan.digiuseppe'}
style={{ marginTop: theme.spacing.medium }}
/>
<TextInput
value={text}
onChangeText={setText}
multiline
maxLength={CHAR_MAX}
style={{
marginTop: theme.spacing.medium,
fontSize: 20,
backgroundColor: theme.colors.lightgray,
borderRadius: theme.spacing.small,
padding: theme.spacing.medium,
paddingTop: theme.spacing.medium,
}}
placeholder={'My cache...'}
/>
<GeoText
text={`${CHAR_MAX - text.length}`}
variant={'textBold'}
color={'mediumgray'}
style={{ textAlign: 'right', fontSize: 20, padding: theme.spacing.small }}
/>
<GeoButton
title={'Next'}
onPress={onNext}
disabled={text.length === 0}
/>
</View>
<ScreenContainer>
<View style={styles.container}>
<ScrollView
keyboardShouldPersistTaps={'handled'}
showsVerticalScrollIndicator={false}
onLayout={onScrollViewLayout}
>
<GeoText
variant={'formHeader'}
text={'milan.digiuseppe'}
style={{ marginTop: theme.spacing.medium }}
/>
<TextInput
value={text}
onChangeText={setText}
multiline
maxLength={CHAR_MAX}
style={{
marginTop: theme.spacing.medium,
fontSize: 20,
backgroundColor: theme.colors.lightgray,
borderRadius: theme.spacing.small,
padding: theme.spacing.medium,
paddingTop: theme.spacing.medium,
}}
placeholder={'My cache...'}
/>
<View style={styles.textAreaFooter}>
<View style={{ flex: 1, flexDirection: 'row' }}>
<IconButton icon={'image'} onPress={onPickImage} />
<IconButton
icon={'camera'}
onPress={() => navigate('CameraModal')}
style={{ marginLeft: theme.spacing.medium }}
/>
</View>
<GeoText
text={`${CHAR_MAX - text.length}`}
variant={'textBold'}
color={'mediumgray'}
style={{ fontSize: 20 }}
/>
</View>
{image && (
<Image
source={image}
style={{
width: '100%',
height: imageHeight,
}}
resizeMode={'contain'}
/>
)}
</ScrollView>
<GeoButton
title={'Next'}
onPress={onNext}
disabled={text.length === 0}
style={{ marginVertical: theme.spacing.medium }}
/>
</View>
</ScreenContainer>
);
};
......
import React, {
useState, useEffect, useRef, useCallback,
} from 'react';
import {
Text, View, StyleSheet, ImageBackground,
} from 'react-native';
import { Camera } from 'expo-camera';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { useDispatch } from 'react-redux';
import IconButton from '../../components/IconButton';
import CameraButton from '../../components/CameraButton';
import theme from '../../theme';
import GeoButton from '../../components/GeoButton';
import { setPendingCacheImage, ImageInfo } from '../../store/pendingNewCache/PendingCacheSlice';
type CameraDirection = 'front' | 'back';
const styles = StyleSheet.create({
buttonBar: {
display: 'flex',
flexDirection: 'row',
paddingVertical: theme.spacing.medium,
padding: theme.spacing.medium,
},
buttonContainer: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
imagePreview: {
flex: 1,
resizeMode: 'cover',
justifyContent: 'flex-end',
},
});
const CameraModal = () => {
const dispatch = useDispatch();
const navigation = useNavigation();
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [cameraDirection, setCameraDirection] = useState<CameraDirection>('back');
const [image, setImage] = useState<ImageInfo | null>(null);
const cameraRef = useRef<Camera>(null);
useEffect(() => {
(async () => {
const { status } = await Camera.requestPermissionsAsync();
setHasPermission(status === 'granted');
})();
}, []);
const flipCamera = useCallback(() => {
setCameraDirection(cameraDirection === 'back' ? 'front' : 'back');
}, [cameraDirection, setCameraDirection]);
const takePicture = useCallback(async () => {
if (cameraRef) {
const photo = await cameraRef.current!.takePictureAsync();
setImage(photo);
}
}, [cameraRef, setImage]);
const onRetake = useCallback(() => {
setImage(null);
}, [setImage]);
const onDone = useCallback(() => {
dispatch(setPendingCacheImage(image));
navigation.goBack();
}, [image, dispatch, navigation]);
if (hasPermission === null) {
return <View />;
}
if (hasPermission === false) {
// TODO: close modal?
return <Text>No access to camera</Text>;
}
if (image) {
return (
<ImageBackground source={image} style={styles.imagePreview}>
<View style={styles.buttonBar}>
<GeoButton
title={'Retake'}
onPress={onRetake}
style={{ flex: 1, marginRight: theme.spacing.small }}
/>
<GeoButton
title={'Done'}
onPress={onDone}
style={{ flex: 1 }}
/>
</View>
</ImageBackground>
);
}
return (
<Camera
style={{ flex: 1 }}
type={cameraDirection}
ref={cameraRef}
>
<SafeAreaView style={{ flex: 1, justifyContent: 'flex-end' }}>
<View style={styles.buttonBar}>
<View style={styles.buttonContainer} />
<CameraButton onPress={takePicture} style={styles.buttonContainer} />
<View style={styles.buttonContainer}>
<IconButton color={'white'} icon={'sync-alt'} onPress={flipCamera} />
</View>
</View>
</SafeAreaView>
</Camera>
);
};
export default CameraModal;
import React from 'react';
import { SafeAreaView } from 'react-native-safe-area-context';
import { KeyboardAvoidingView } from 'react-native';
const ScreenContainer: React.FC = ({ children }) => (
<SafeAreaView
style={{
flex: 1,
paddingTop: 0, // remove SAV padding for top nav
}}
>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={'padding'}
keyboardVerticalOffset={94} // hard coded bc KAV sux
>
{children}
</KeyboardAvoidingView>
</SafeAreaView>
);
export default ScreenContainer;
......@@ -2,3 +2,4 @@ import { RootState } from '../RootReducer';
export const getPendingCacheContent = (state: RootState) => state.pendingCache.content;
export const getPendingCacheDuration = (state: RootState) => state.pendingCache.duration;
export const getPendingCacheImage = (state: RootState) => state.pendingCache.image;
......@@ -3,9 +3,16 @@ import { createSlice } from '@reduxjs/toolkit';
export const DURATION_OPTIONS = [1, 4, 12, 24] as const;
type DurationOption = typeof DURATION_OPTIONS[number]
export interface ImageInfo {
width: number;
height: number;
uri: string;
}
export interface PendingNewCacheState {
content: string;
duration: DurationOption;
image?: ImageInfo;
}
const pendingCacheSlice = createSlice({
......@@ -21,6 +28,9 @@ const pendingCacheSlice = createSlice({
setDuration(state, action) {
state.duration = action.payload;
},
setImage(state, action) {
state.image = action.payload;
},
},
});
......@@ -28,4 +38,5 @@ export default pendingCacheSlice;
export const {
setContent: setPendingCacheContent,
setDuration: setPendingCacheDuration,
setImage: setPendingCacheImage,
} = pendingCacheSlice.actions;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment