Add meme-adding logic

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-07-21 09:46:13 +03:00
parent 1b2ce96c5e
commit 4b601872bc
40 changed files with 1037 additions and 324 deletions

View File

@@ -3,8 +3,10 @@ import { Keyboard } from 'react-native';
import { FAB } from 'react-native-paper';
import { ParamListBase, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { pick } from 'react-native-document-picker';
import { useDimensions } from '../contexts';
import { ROUTE } from '../types';
import { allowedMimeTypes, noOp } from '../utilities';
const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => {
const { navigate } =
@@ -39,22 +41,34 @@ const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => {
{
icon: 'tag',
label: 'Tag',
onPress: () => navigate(ROUTE.EDIT_TAG),
onPress: () => navigate(ROUTE.ADD_TAG),
},
{
icon: 'note-text',
label: 'Text',
onPress: () => navigate(ROUTE.EDIT_MEME),
onPress: () => {
throw new Error('Not yet implemented');
},
},
{
icon: 'image-album',
label: 'Album',
onPress: () => navigate(ROUTE.EDIT_MEME),
onPress: async () => {
const res = await pick({
allowMultiSelection: true,
type: allowedMimeTypes,
}).catch(noOp);
if (!res) return;
navigate(ROUTE.ADD_MEME, { uri: res });
},
},
]}
onStateChange={({ open }) => setState(open)}
onPress={() => {
if (state) navigate(ROUTE.EDIT_MEME);
onPress={async () => {
if (!state) return;
const res = await pick({ type: allowedMimeTypes }).catch(noOp);
if (!res) return;
navigate(ROUTE.ADD_MEME, { uri: res });
}}
style={{
paddingBottom: responsive.verticalScale(75),

View File

@@ -10,7 +10,7 @@ const hideableHeaderStyles = StyleSheet.create({
top: 0,
left: 0,
right: 0,
zIndex: 100,
zIndex: 1,
},
});

View File

@@ -1,6 +1,6 @@
export { MemeEditor } from './memes';
export { TagChip, TagEditor, TagPreview } from './tags';
export { default as FloatingActionButton } from './floatingActionButton';
export { default as HideableBottomNavigationBar } from './hideableBottomNavigationBar';
export { default as HideableHeader } from './hideableHeader';
export { default as LoadingView } from './loadingView';
export { default as TagChip } from './tags/tagChip';
export { default as TagPreview } from './tags/tagPreview';

View File

@@ -0,0 +1,3 @@
export { default as MemeEditor } from './memeEditor';
export { default as MemeTagSearchModal } from './memeTagSearchModal';
export { default as MemeTagSelector } from './memeTagSelector';

View File

@@ -0,0 +1,99 @@
import React, { useEffect, useState } from 'react';
import { HelperText, TextInput } from 'react-native-paper';
import { Image } from 'react-native';
import { useDimensions } from '../../contexts';
import LoadingView from '../loadingView';
import { MemeTagSelector } from '.';
import { Tag } from '../../database';
const MemeEditor = ({
imageUri,
memeTitle,
setMemeTitle,
memeDescription,
setMemeDescription,
memeTags,
setMemeTags,
memeTitleError,
setMemeTitleError,
}: {
imageUri: string[];
memeTitle: string;
setMemeTitle: (name: string) => void;
memeDescription: string;
setMemeDescription: (description: string) => void;
memeTags: Map<string, Tag>;
setMemeTags: (tags: Map<string, Tag>) => void;
memeTitleError: string | undefined;
setMemeTitleError: (error: string | undefined) => void;
}) => {
const { dimensions, fixed, responsive } = useDimensions();
const [imageWidth, setImageWidth] = useState<number>();
const [imageHeight, setImageHeight] = useState<number>();
useEffect(() => {
Image.getSize(imageUri[0], (width, height) => {
const paddedWidth = dimensions.width - dimensions.width * 0.08;
setImageWidth(paddedWidth);
setImageHeight((paddedWidth / width) * height);
});
}, [dimensions.width, imageUri]);
const handleMemeTitleChange = (name: string) => {
setMemeTitle(name);
if (name.length === 0) {
setMemeTitleError('Meme title cannot be empty');
} else {
// eslint-disable-next-line unicorn/no-useless-undefined
setMemeTitleError(undefined);
}
};
if (!imageWidth || !imageHeight) return <LoadingView />;
return (
<>
<TextInput
mode="outlined"
label="Title"
value={memeTitle}
onChangeText={handleMemeTitleChange}
error={!!memeTitleError}
selectTextOnFocus
/>
<HelperText type="error" visible={!!memeTitleError}>
{memeTitleError}
</HelperText>
<Image
source={{ uri: imageUri[0] }}
style={{
width: imageWidth,
height: imageHeight,
marginBottom: fixed.verticalScale(10),
borderRadius: fixed.verticalScale(5),
}}
/>
<MemeTagSelector
memeTags={memeTags}
setMemeTags={setMemeTags}
style={{
marginBottom: responsive.verticalScale(10),
}}
/>
<TextInput
mode="outlined"
label="Description"
multiline
numberOfLines={6}
style={{
marginBottom: responsive.verticalScale(15),
}}
value={memeDescription}
onChangeText={setMemeDescription}
/>
</>
);
};
export default MemeEditor;

View File

@@ -0,0 +1,128 @@
import React, { useRef, useState } from 'react';
import { TagChip } from '../tags';
import { Tag } from '../../database';
import { useQuery, useRealm } from '@realm/react';
import { TAG_SORT, tagSortQuery } from '../../types';
import { Chip, Modal, Portal, Searchbar, useTheme } from 'react-native-paper';
import { StyleSheet } from 'react-native';
import { useDimensions } from '../../contexts';
import styles from '../../styles';
import { FlashList } from '@shopify/flash-list';
const memeTagSearchModalStyles = StyleSheet.create({
modal: {
position: 'absolute',
bottom: 0,
},
});
const MemeTagSearchModal = ({
visible,
setVisible,
memeTags,
setMemeTags,
}: {
visible: boolean;
setVisible: (visible: boolean) => void;
memeTags: Map<string, Tag>;
setMemeTags: (tags: Map<string, Tag>) => void;
}) => {
const { colors } = useTheme();
const { fixed, responsive } = useDimensions();
const realm = useRealm();
const flashListRef = useRef<FlashList<Tag>>(null);
const [search, setSearch] = useState('');
const handleSearch = (newSearch: string) => {
flashListRef.current?.scrollToOffset({ offset: 0 });
setSearch(newSearch);
};
const tags = useQuery<Tag>(
Tag.schema.name,
collection =>
collection
.filtered(`name CONTAINS[c] "${search}"`)
.sorted(tagSortQuery(TAG_SORT.DATE_MODIFIED), true),
[search],
);
const handleTagPress = (tag: Tag) => {
const id = tag.id.toHexString();
memeTags.delete(id) || memeTags.set(id, tag);
setMemeTags(new Map(memeTags));
};
const handleCreateTag = (name: string) => {
let tag: Tag | undefined;
realm.write(() => {
tag = realm.create<Tag>(Tag.schema.name, {
name,
});
});
if (!tag) return;
memeTags.set(tag.id.toHexString(), tag);
setMemeTags(new Map(memeTags));
setSearch(tag.name);
};
return (
<Portal>
<Modal
visible={visible}
contentContainerStyle={[
{
padding: fixed.horizontalScale(10),
borderTopLeftRadius: fixed.verticalScale(20),
borderTopRightRadius: fixed.verticalScale(20),
backgroundColor: colors.surface,
},
styles.fullWidth,
memeTagSearchModalStyles.modal,
]}
onDismiss={() => setVisible(false)}>
<Searchbar
placeholder="Search or Create Tags"
onChangeText={handleSearch}
value={search}
style={{
marginBottom: responsive.verticalScale(10),
}}
autoFocus
/>
<FlashList
ref={flashListRef}
data={tags}
extraData={memeTags}
keyExtractor={tag => tag.id.toHexString()}
horizontal
estimatedItemSize={120}
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps={'always'}
renderItem={({ item: tag }) => (
<TagChip
tag={tag}
style={{
marginRight: fixed.horizontalScale(8),
}}
onPress={() => handleTagPress(tag)}
active={memeTags.has(tag.id.toHexString())}
/>
)}
ListEmptyComponent={() => (
<Chip
icon="plus"
mode="outlined"
onPress={() => handleCreateTag(search.replaceAll(/\s+/g, ''))}>
Create Tag #{search.replaceAll(/\s+/g, '')}
</Chip>
)}
/>
</Modal>
</Portal>
);
};
export default MemeTagSearchModal;

View File

@@ -0,0 +1,70 @@
import React, { ComponentProps, useState } from 'react';
import { View } from 'react-native';
import { Chip } from 'react-native-paper';
import { TagChip } from '../tags';
import { Tag } from '../../database';
import { useDimensions } from '../../contexts';
import { MemeTagSearchModal } from '.';
import { FlashList } from '@shopify/flash-list';
const MemeTagSelector = ({
memeTags,
setMemeTags,
...props
}: {
memeTags: Map<string, Tag>;
setMemeTags: (tags: Map<string, Tag>) => void;
} & ComponentProps<typeof View>) => {
const { fixed, dimensions } = useDimensions();
const [tagSearchModalVisible, setTagSearchModalVisible] = useState(false);
const handleTagPress = (tag: Tag) => {
const id = tag.id.toHexString();
memeTags.delete(id) || memeTags.set(id, tag);
setMemeTags(new Map(memeTags));
};
return (
<>
<View {...props}>
<FlashList
data={[...memeTags.values()]}
extraData={memeTags}
keyExtractor={tag => tag.id.toHexString()}
horizontal
estimatedItemSize={120}
showsHorizontalScrollIndicator={false}
renderItem={({ item: tag }) => (
<TagChip
tag={tag}
onPress={() => handleTagPress(tag)}
style={{
marginRight: fixed.horizontalScale(4),
}}
/>
)}
ListFooterComponent={() => (
<Chip
icon="plus"
mode="outlined"
onPress={() => setTagSearchModalVisible(true)}
style={{
marginRight: dimensions.width * 0.92 - 105.8,
}}>
Add Tag
</Chip>
)}
/>
</View>
<MemeTagSearchModal
visible={tagSearchModalVisible}
setVisible={setTagSearchModalVisible}
memeTags={memeTags}
setMemeTags={setMemeTags}
/>
</>
);
};
export default MemeTagSelector;

View File

@@ -0,0 +1,3 @@
export { default as TagChip } from './tagChip';
export { default as TagEditor } from './tagEditor';
export { default as TagPreview } from './tagPreview';

View File

@@ -1,24 +1,57 @@
import React from 'react';
import React, { useMemo } from 'react';
import { getContrastColor } from '../../utilities';
import { Chip } from 'react-native-paper';
import { Chip, useTheme } from 'react-native-paper';
import { Tag } from '../../database';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import { StyleSheet } from 'react-native';
const tagChipStyles = StyleSheet.create({
chip: {
borderWidth: 1,
},
});
const TagChip = ({
tag,
active = true,
onPress,
...props
}: {
tag: Tag;
active?: boolean;
onPress?: () => void;
} & Omit<React.ComponentProps<typeof Chip>, 'children'>) => {
const theme = useTheme();
const chipTheme = useMemo(() => {
return {
...theme,
colors: {
...theme.colors,
secondaryContainer: tag.color,
outline: tag.color,
},
};
}, [tag.color, theme]);
const TagChip = ({ tag }: { tag: Tag }) => {
const contrastColor = getContrastColor(tag.color);
return (
<Chip
{...props}
icon={() => {
return <FontAwesome5 name="tag" color={contrastColor} />;
return (
<FontAwesome5 name="tag" color={active ? contrastColor : tag.color} />
);
}}
compact
style={[
{
backgroundColor: tag.color,
},
]}
textStyle={{ color: contrastColor }}>
theme={chipTheme}
mode={active ? 'flat' : 'outlined'}
style={[tagChipStyles.chip, props.style]}
textStyle={{
color: active ? contrastColor : theme.colors.onBackground,
}}
onPress={onPress}>
{'#' + tag.name}
</Chip>
);

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { HelperText, TextInput } from 'react-native-paper';
import TagPreview from './tagPreview';
import { generateRandomColor, isValidColor } from '../../utilities';
const TagEditor = ({
tagName,
setTagName,
tagColor,
setTagColor,
validatedTagColor,
setValidatedTagColor,
tagNameError,
setTagNameError,
tagColorError,
setTagColorError,
}: {
tagName: string;
setTagName: (name: string) => void;
tagColor: string;
setTagColor: (color: string) => void;
validatedTagColor: string;
setValidatedTagColor: (color: string) => void;
tagNameError: string | undefined;
setTagNameError: (error: string | undefined) => void;
tagColorError: string | undefined;
setTagColorError: (error: string | undefined) => void;
}) => {
const handleTagNameChange = (name: string) => {
setTagName(name);
if (name.length === 0) {
setTagNameError('Tag name cannot be empty');
} else if (name.includes(' ')) {
setTagNameError('Tag name cannot contain spaces');
} else {
// eslint-disable-next-line unicorn/no-useless-undefined
setTagNameError(undefined);
}
};
const handleTagColorChange = (color: string) => {
setTagColor(color);
if (isValidColor(color)) {
setValidatedTagColor(color);
// eslint-disable-next-line unicorn/no-useless-undefined
setTagColorError(undefined);
} else {
setTagColorError('Color must be a valid hex or rgb value');
}
};
return (
<>
<TagPreview name={tagName} color={validatedTagColor} />
<TextInput
mode="outlined"
label="Name"
value={tagName}
onChangeText={handleTagNameChange}
error={!!tagNameError}
autoCapitalize="none"
selectTextOnFocus
/>
<HelperText type="error" visible={!!tagNameError}>
{tagNameError}
</HelperText>
<TextInput
mode="outlined"
label="Color"
value={tagColor}
onChangeText={handleTagColorChange}
error={!!tagColorError}
autoCorrect={false}
right={
<TextInput.Icon
icon="palette"
onPress={() => handleTagColorChange(generateRandomColor())}
/>
}
/>
<HelperText type="error" visible={!!tagColorError}>
{tagColorError}
</HelperText>
</>
);
};
export default TagEditor;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import React, { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import { Chip } from 'react-native-paper';
import { Chip, useTheme } from 'react-native-paper';
import styles from '../../styles';
import { useDimensions } from '../../contexts';
import { getContrastColor } from '../../utilities';
@@ -16,7 +16,19 @@ const tagPreviewStyles = StyleSheet.create({
});
const TagPreview = ({ name, color }: { name: string; color: string }) => {
const theme = useTheme();
const { responsive } = useDimensions();
const chipTheme = useMemo(() => {
return {
...theme,
colors: {
...theme.colors,
secondaryContainer: color,
},
};
}, [theme, color]);
const contrastColor = getContrastColor(color);
return (
@@ -33,12 +45,8 @@ const TagPreview = ({ name, color }: { name: string; color: string }) => {
return <FontAwesome5 name="tag" size={14} color={contrastColor} />;
}}
elevated
style={[
tagPreviewStyles.chip,
{
backgroundColor: color,
},
]}
style={tagPreviewStyles.chip}
theme={chipTheme}
textStyle={[tagPreviewStyles.text, { color: contrastColor }]}>
{'#' + name}
</Chip>