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

@@ -118,6 +118,10 @@ dependencies {
} else {
implementation jscFlavor
}
implementation 'com.facebook.fresco:animated-gif:2.5.0'
implementation 'com.facebook.fresco:animated-webp:2.5.0'
implementation 'com.facebook.fresco:webpsupport:2.5.0'
}
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)

51
package-lock.json generated
View File

@@ -20,10 +20,10 @@
"react": "18.2.0",
"react-native": "0.72.2",
"react-native-document-picker": "^9.0.1",
"react-native-fast-image": "^8.6.3",
"react-native-file-access": "^3.0.4",
"react-native-gesture-handler": "^2.12.0",
"react-native-get-random-values": "^1.9.0",
"react-native-mime-types": "^2.4.0",
"react-native-paper": "^5.9.1",
"react-native-reanimated": "^3.3.0",
"react-native-safe-area-context": "^4.6.4",
@@ -13371,15 +13371,6 @@
}
}
},
"node_modules/react-native-fast-image": {
"version": "8.6.3",
"resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz",
"integrity": "sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg==",
"peerDependencies": {
"react": "^17 || ^18",
"react-native": ">=0.60.0"
}
},
"node_modules/react-native-file-access": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-native-file-access/-/react-native-file-access-3.0.4.tgz",
@@ -13416,6 +13407,25 @@
"react-native": ">=0.56"
}
},
"node_modules/react-native-mime-types": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-native-mime-types/-/react-native-mime-types-2.4.0.tgz",
"integrity": "sha512-a7LymNr7yQzrDEhSMPNAy9aIs1OckBpo6G8OkjVQTzaCe0XaSXCXu6KJsu/a4c3HVF9t0FiFSnxsRVEctpPI0g==",
"dependencies": {
"mime-db": "~1.37.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/react-native-mime-types/node_modules/mime-db": {
"version": "1.37.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.37.0.tgz",
"integrity": "sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/react-native-paper": {
"version": "5.9.1",
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.9.1.tgz",
@@ -25752,12 +25762,6 @@
"invariant": "^2.2.4"
}
},
"react-native-fast-image": {
"version": "8.6.3",
"resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz",
"integrity": "sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg==",
"requires": {}
},
"react-native-file-access": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-native-file-access/-/react-native-file-access-3.0.4.tgz",
@@ -25784,6 +25788,21 @@
"fast-base64-decode": "^1.0.0"
}
},
"react-native-mime-types": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-native-mime-types/-/react-native-mime-types-2.4.0.tgz",
"integrity": "sha512-a7LymNr7yQzrDEhSMPNAy9aIs1OckBpo6G8OkjVQTzaCe0XaSXCXu6KJsu/a4c3HVF9t0FiFSnxsRVEctpPI0g==",
"requires": {
"mime-db": "~1.37.0"
},
"dependencies": {
"mime-db": {
"version": "1.37.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.37.0.tgz",
"integrity": "sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg=="
}
}
},
"react-native-paper": {
"version": "5.9.1",
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.9.1.tgz",

View File

@@ -25,10 +25,10 @@
"react": "18.2.0",
"react-native": "0.72.2",
"react-native-document-picker": "^9.0.1",
"react-native-fast-image": "^8.6.3",
"react-native-file-access": "^3.0.4",
"react-native-gesture-handler": "^2.12.0",
"react-native-get-random-values": "^1.9.0",
"react-native-mime-types": "^2.4.0",
"react-native-paper": "^5.9.1",
"react-native-reanimated": "^3.3.0",
"react-native-safe-area-context": "^4.6.4",

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>

View File

@@ -21,7 +21,7 @@ interface DimensionsContext {
orientation: 'portrait' | 'landscape';
dimensions: ScaledSize;
responsive: ScaleFunctions;
static: ScaleFunctions;
fixed: ScaleFunctions;
}
const createScaleFunctions = (dimensionsIn: ScaledSize) => {
@@ -56,7 +56,7 @@ const DimensionsProvider = ({ children }: { children: ReactNode }) => {
}, []);
const responsiveScale = createScaleFunctions(dimensions);
const staticScale = createScaleFunctions(initialDimensions);
const fixedScale = createScaleFunctions(initialDimensions);
useEffect(() => {
const onChange = ({ window }: { window: ScaledSize }) => {
@@ -76,7 +76,7 @@ const DimensionsProvider = ({ children }: { children: ReactNode }) => {
orientation,
dimensions,
responsive: responsiveScale,
static: staticScale,
fixed: fixedScale,
}}>
{children}
</DimensionsContext.Provider>

View File

@@ -1,2 +1,2 @@
export { MEME_TYPE, memeTypePlural, Meme } from './meme';
export { Tag, deleteTag, deleteAllTags } from './tag';
export { Tag } from './tag';

View File

@@ -1,5 +1,4 @@
import { Realm } from '@realm/react';
import { BSON } from 'realm';
import { BSON, Object, ObjectSchema } from 'realm';
import { Tag } from './tag';
enum MEME_TYPE {
@@ -20,33 +19,36 @@ const memeTypePlural = {
[MEME_TYPE.TEXT]: 'Text',
};
class Meme extends Realm.Object<Meme> {
// eslint-disable-next-line @typescript-eslint/naming-convention
class Meme extends Object<Meme> {
id!: BSON.UUID;
type!: MEME_TYPE;
uri!: Realm.List<string>;
uri!: string[];
hash!: string[];
size!: number;
title!: string;
description?: string;
isFavorite!: boolean;
tags!: Realm.List<Tag>;
tags!: Tag[] | Set<Tag>;
tagsLength!: number;
dateCreated!: Date;
dateModified!: Date;
dateUsed?: Date;
timesUsed!: number;
static schema: Realm.ObjectSchema = {
static schema: ObjectSchema = {
name: 'Meme',
primaryKey: 'id',
properties: {
id: { type: 'uuid', default: () => new BSON.UUID() },
type: { type: 'string', indexed: true },
uri: 'string[]',
hash: 'string[]',
size: 'int',
title: 'string',
description: 'string?',
isFavorite: { type: 'bool', indexed: true, default: false },
tags: { type: 'list', objectType: 'Tag', default: [] },
tags: { type: 'set', objectType: 'Tag', default: [] },
tagsLength: { type: 'int', default: 0 },
dateCreated: { type: 'date', default: () => new Date() },
dateModified: { type: 'date', default: () => new Date() },

View File

@@ -1,43 +1,34 @@
import { Realm } from '@realm/react';
import { BSON } from 'realm';
import { BSON, Object, ObjectSchema } from 'realm';
import { Meme } from './meme';
import { generateRandomColor } from '../utilities';
class Tag extends Realm.Object<Tag> {
// eslint-disable-next-line @typescript-eslint/naming-convention
class Tag extends Object<Tag> {
id!: BSON.UUID;
name!: string;
color!: string;
memes!: Realm.List<Meme>;
memes!: Meme[] | Set<Meme>;
memesLength!: number;
dateCreated!: Date;
dateModified!: Date;
dateUsed?: Date;
timesUsed!: number;
static schema: Realm.ObjectSchema = {
static schema: ObjectSchema = {
name: 'Tag',
primaryKey: 'id',
properties: {
id: { type: 'uuid', default: () => new BSON.UUID() },
name: { type: 'string', indexed: true },
color: 'string',
memes: { type: 'list', objectType: 'Meme', default: [] },
color: { type: 'string', default: () => generateRandomColor() },
memes: { type: 'set', objectType: 'Meme', default: [] },
memesLength: { type: 'int', default: 0 },
dateCreated: { type: 'date', default: () => new Date() },
dateModified: { type: 'date', default: () => new Date() },
dateUsed: 'date?',
timesUsed: { type: 'int', default: 0 },
},
};
}
const deleteTag = (realm: Realm, tag: Tag) => {
realm.write(() => {
realm.delete(tag);
});
};
const deleteAllTags = (realm: Realm) => {
realm.write(() => {
realm.delete(realm.objects<Tag>('Tag'));
});
};
export { Tag, deleteTag, deleteAllTags };
export { Tag };

View File

@@ -5,7 +5,15 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useTheme } from 'react-native-paper';
import { useSelector } from 'react-redux';
import { Home, Tags, Settings, EditMeme, EditTag } from './screens';
import {
Memes,
Tags,
Settings,
EditMeme,
EditTag,
AddMeme,
AddTag,
} from './screens';
import { darkNavigationTheme, lightNavigationTheme } from './theme';
import {
FloatingActionButton,
@@ -19,7 +27,7 @@ const TabNavigator = () => {
(state: RootState) => state.navigation.navVisible,
);
const [route, setRoute] = React.useState(ROUTE.HOME);
const [route, setRoute] = React.useState(ROUTE.MEMES);
const TabNavigatorBase = createBottomTabNavigator();
return (
@@ -27,6 +35,7 @@ const TabNavigator = () => {
<TabNavigatorBase.Navigator
screenOptions={{
headerShown: false,
lazy: false,
}}
tabBar={({ navigation, state, descriptors, insets }) => (
<HideableBottomNavigationBar
@@ -39,11 +48,11 @@ const TabNavigator = () => {
/>
)}>
<TabNavigatorBase.Screen
name={ROUTE.HOME}
component={Home}
name={ROUTE.MEMES}
component={Memes}
options={{
tabBarIcon: ({ color, size }) => (
<FontAwesome5 name="home" color={color} size={size} />
<FontAwesome5 name="images" color={color} size={size} />
),
}}
/>
@@ -85,10 +94,12 @@ const NavigationContainer = () => {
animation: 'slide_from_bottom',
}}>
<StackNavigatorBase.Screen name={ROUTE.MAIN} component={TabNavigator} />
<StackNavigatorBase.Screen name={ROUTE.ADD_MEME} component={AddMeme} />
<StackNavigatorBase.Screen
name={ROUTE.EDIT_MEME}
component={EditMeme}
/>
<StackNavigatorBase.Screen name={ROUTE.ADD_TAG} component={AddTag} />
<StackNavigatorBase.Screen name={ROUTE.EDIT_TAG} component={EditTag} />
</StackNavigatorBase.Navigator>
</NavigationContainerBase>

141
src/screens/addMeme.tsx Normal file
View File

@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { useDimensions } from '../contexts';
import { ScrollView, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useRealm } from '@realm/react';
import { BSON } from 'realm';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { useSelector } from 'react-redux';
import { extension } from 'react-native-mime-types';
import styles from '../styles';
import { ROUTE, RootStackParamList } from '../types';
import { MEME_TYPE, Meme, Tag } from '../database';
import { RootState } from '../state';
import { getMemeType } from '../utilities';
import { MemeEditor } from '../components';
const AddMeme = ({
route,
}: NativeStackScreenProps<RootStackParamList, ROUTE.ADD_MEME>) => {
const navigation = useNavigation();
const { colors } = useTheme();
const { orientation } = useDimensions();
const realm = useRealm();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const { uri } = route.params;
const memeType =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uri.length > 1 ? MEME_TYPE.ALBUM : getMemeType(uri[0].type!);
const [memeTitle, setMemeTitle] = useState('New Meme');
const [memeDescription, setMemeDescription] = useState('');
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
const [memeTitleError, setMemeTitleError] = useState<string | undefined>();
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
const uuid = new BSON.UUID();
const savedUri: string[] = [];
const hash: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const fileExtension = extension(uri[0].type!);
if (!fileExtension) navigation.goBack();
savedUri.push(
AndroidScoped.appendPath(
storageUri,
`${uuid.toHexString()}.${fileExtension as string}`,
),
);
await FileSystem.cp(uri[0].uri, savedUri[0]);
const { size } = await FileSystem.stat(savedUri[0]);
hash.push(await FileSystem.hash(savedUri[0], 'MD5'));
realm.write(() => {
const meme: Meme | undefined = realm.create<Meme>(Meme.schema.name, {
id: uuid,
type: memeType,
uri: savedUri,
size,
hash,
title: memeTitle,
description: memeDescription,
isFavorite: memeIsFavorite,
tags: [...memeTags.values()],
tagsLength: memeTags.size,
});
meme.tags.forEach(tag => {
tag.dateModified = new Date();
const memes = tag.memes as Set<Meme>;
memes.add(meme);
tag.memesLength = memes.size;
});
});
setIsSaving(false);
navigation.goBack();
};
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => navigation.goBack()} />
<Appbar.Content title={'Add Meme'} />
<Appbar.Action
icon={memeIsFavorite ? 'heart' : 'heart-outline'}
onPress={() => setMemeIsFavorite(!memeIsFavorite)}
/>
</Appbar.Header>
<ScrollView
contentContainerStyle={[
orientation == 'portrait' && styles.paddingVertical,
orientation == 'landscape' && styles.smallPaddingVertical,
styles.paddingHorizontal,
styles.flexGrow,
styles.flexColumnSpaceBetween,
{ backgroundColor: colors.background },
]}>
<View style={[styles.flex, styles.justifyStart]}>
<MemeEditor
imageUri={uri.map(uriIn => uriIn.uri)}
memeTitle={memeTitle}
setMemeTitle={setMemeTitle}
memeDescription={memeDescription}
setMemeDescription={setMemeDescription}
memeTags={memeTags}
setMemeTags={setMemeTags}
memeTitleError={memeTitleError}
setMemeTitleError={setMemeTitleError}
/>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={!!memeTitleError || isSaving}
loading={isSaving}>
Save
</Button>
</View>
</ScrollView>
</>
);
};
export default AddMeme;

79
src/screens/addTag.tsx Normal file
View File

@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { ScrollView, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { useRealm } from '@realm/react';
import styles from '../styles';
import { generateRandomColor } from '../utilities';
import { useDimensions } from '../contexts';
import { Tag } from '../database';
import { TagEditor } from '../components';
const AddTag = () => {
const navigation = useNavigation();
const { colors } = useTheme();
const { orientation } = useDimensions();
const realm = useRealm();
const [tagName, setTagName] = useState('newTag');
const [tagColor, setTagColor] = useState(generateRandomColor());
const [validatedTagColor, setValidatedTagColor] = useState(tagColor);
const [tagNameError, setTagNameError] = useState<string | undefined>();
const [tagColorError, setTagColorError] = useState<string | undefined>();
const handleSave = () => {
realm.write(() => {
realm.create(Tag.schema.name, {
name: tagName,
color: tagColor,
});
});
navigation.goBack();
};
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => navigation.goBack()} />
<Appbar.Content title={'Add Tag'} />
</Appbar.Header>
<ScrollView
contentContainerStyle={[
orientation == 'portrait' && styles.paddingVertical,
orientation == 'landscape' && styles.smallPaddingVertical,
styles.paddingHorizontal,
styles.flexGrow,
styles.flexColumnSpaceBetween,
{ backgroundColor: colors.background },
]}>
<View style={[styles.flex, styles.justifyStart]}>
<TagEditor
tagName={tagName}
setTagName={setTagName}
tagColor={tagColor}
setTagColor={setTagColor}
validatedTagColor={validatedTagColor}
setValidatedTagColor={setValidatedTagColor}
tagNameError={tagNameError}
setTagNameError={setTagNameError}
tagColorError={tagColorError}
setTagColorError={setTagColorError}
/>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={!!tagNameError || !!tagColorError}>
Save
</Button>
</View>
</ScrollView>
</>
);
};
export default AddTag;

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Appbar, Text, useTheme } from 'react-native-paper';
import { ScrollView } from 'react-native';
import { Appbar, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { useDimensions } from '../contexts';
import { ScrollView } from 'react-native';
import styles from '../styles';
const EditMeme = () => {
@@ -14,20 +14,17 @@ const EditMeme = () => {
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => navigation.goBack()} />
<Appbar.Content title="Add Meme" />
<Appbar.Content title={'Edit Meme'} />
</Appbar.Header>
<ScrollView
contentContainerStyle={[
orientation == 'portrait' && styles.paddingVertical,
orientation == 'landscape' && styles.smallPaddingVertical,
styles.paddingHorizontal,
[styles.centered, styles.flex],
styles.fullSize,
styles.flexGrow,
styles.flexColumnSpaceBetween,
{ backgroundColor: colors.background },
]}
nestedScrollEnabled>
<Text>Add Meme</Text>
</ScrollView>
]}></ScrollView>
</>
);
};

View File

@@ -1,19 +1,12 @@
import React, { useState } from 'react';
import { ScrollView, View } from 'react-native';
import {
TextInput,
Appbar,
HelperText,
Button,
useTheme,
} from 'react-native-paper';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BSON, UpdateMode } from 'realm';
import { BSON } from 'realm';
import { useRealm } from '@realm/react';
import { TagPreview } from '../components';
import { TagEditor } from '../components';
import styles from '../styles';
import { generateRandomColor, isValidColor } from '../utilities';
import { useDimensions } from '../contexts';
import { ROUTE, RootStackParamList } from '../types';
import { Tag } from '../database';
@@ -26,62 +19,41 @@ const EditTag = ({
const { orientation } = useDimensions();
const realm = useRealm();
const tagId = route.params?.id;
const tag = tagId
? realm.objectForPrimaryKey(Tag, BSON.UUID.createFromHexString(tagId))
: undefined;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const tag = realm.objectForPrimaryKey(
Tag,
BSON.UUID.createFromHexString(route.params.id),
)!;
const [tagName, setTagName] = useState(tag?.name ?? 'newTag');
const [tagColor, setTagColor] = useState(tag?.color ?? generateRandomColor());
const [tagName, setTagName] = useState(tag.name);
const [tagColor, setTagColor] = useState(tag.color);
const [validatedTagColor, setValidatedTagColor] = useState(tagColor);
const [tagNameError, setTagNameError] = useState<string | undefined>();
const [tagColorError, setTagColorError] = useState<string | undefined>();
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');
}
};
const handleSave = () => {
realm.write(() => {
realm.create(
Tag,
{
id: tag?.id,
name: tagName,
color: tagColor,
},
UpdateMode.Modified,
);
tag.name = tagName;
tag.color = tagColor;
tag.dateModified = new Date();
});
navigation.goBack();
};
const handleDelete = () => {
realm.write(() => {
for (const meme of tag.memes) {
meme.dateModified = new Date();
const tags = meme.tags as Set<Tag>;
tags.delete(tag);
meme.tagsLength = tags.size;
}
realm.delete(tag);
});
navigation.goBack();
};
@@ -89,50 +61,32 @@ const EditTag = ({
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => navigation.goBack()} />
<Appbar.Content title={tag ? 'Edit Tag' : 'Add Tag'} />
{tag && <Appbar.Action icon="delete" onPress={handleDelete} />}
<Appbar.Content title={'Edit Tag'} />
<Appbar.Action icon="delete" onPress={handleDelete} />
</Appbar.Header>
<ScrollView
contentContainerStyle={[
orientation == 'portrait' && styles.paddingVertical,
orientation == 'landscape' && styles.smallPaddingVertical,
styles.paddingHorizontal,
styles.fullSize,
styles.flexGrow,
styles.flexColumnSpaceBetween,
{ backgroundColor: colors.background },
]}>
]}
nestedScrollEnabled>
<View style={[styles.flex, styles.justifyStart]}>
<TagPreview name={tagName} color={validatedTagColor} />
<TextInput
mode="outlined"
label="Tag Name"
value={tagName}
onChangeText={handleTagNameChange}
error={!!tagNameError}
autoCapitalize="none"
selectTextOnFocus
<TagEditor
tagName={tagName}
setTagName={setTagName}
tagColor={tagColor}
setTagColor={setTagColor}
validatedTagColor={validatedTagColor}
setValidatedTagColor={setValidatedTagColor}
tagNameError={tagNameError}
setTagNameError={setTagNameError}
tagColorError={tagColorError}
setTagColorError={setTagColorError}
/>
<HelperText type="error" visible={!!tagNameError}>
{tagNameError}
</HelperText>
<TextInput
mode="outlined"
label="Tag Color"
value={tagColor}
onChangeText={handleTagColorChange}
error={!!tagColorError}
autoCorrect={false}
right={
<TextInput.Icon
icon="palette"
onPress={() => handleTagColorChange(generateRandomColor())}
/>
}
/>
<HelperText type="error" visible={!!tagColorError}>
{tagColorError}
</HelperText>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
<Button

View File

@@ -1,6 +1,8 @@
export { default as AddMeme } from './addMeme';
export { default as AddTag } from './addTag';
export { default as EditMeme } from './editMeme';
export { default as EditTag } from './editTag';
export { default as Home } from './home';
export { default as Memes } from './memes';
export { default as Settings } from './settings';
export { default as Tags } from './tags';
export { default as Welcome } from './welcome';

View File

@@ -16,17 +16,17 @@ import { MEME_SORT, SORT_DIRECTION } from '../types';
import { getSortIcon, getViewIcon } from '../utilities';
import {
RootState,
cycleHomeView,
toggleHomeSortDirection,
setHomeSortDirection,
toggleHomeFavoritesOnly,
setHomeSort,
setHomeFilter,
cycleMemesView,
toggleMemesSortDirection,
setMemesSortDirection,
toggleMemesFavoritesOnly,
setMemesSort,
setMemesFilter,
} from '../state';
import { MEME_TYPE, Meme, memeTypePlural } from '../database';
import { useDimensions } from '../contexts';
const homeStyles = StyleSheet.create({
const memesStyles = StyleSheet.create({
headerButtonView: {
height: 50,
},
@@ -35,18 +35,18 @@ const homeStyles = StyleSheet.create({
},
});
const Home = () => {
const Memes = () => {
const { colors } = useTheme();
const { orientation } = useDimensions();
const sort = useSelector((state: RootState) => state.home.sort);
const sort = useSelector((state: RootState) => state.memes.sort);
const sortDirection = useSelector(
(state: RootState) => state.home.sortDirection,
(state: RootState) => state.memes.sortDirection,
);
const view = useSelector((state: RootState) => state.home.view);
const view = useSelector((state: RootState) => state.memes.view);
const favoritesOnly = useSelector(
(state: RootState) => state.home.favoritesOnly,
(state: RootState) => state.memes.favoritesOnly,
);
const filter = useSelector((state: RootState) => state.home.filter);
const filter = useSelector((state: RootState) => state.memes.filter);
const dispatch = useDispatch();
const [sortMenuVisible, setSortMenuVisible] = useState(false);
@@ -54,20 +54,20 @@ const Home = () => {
const handleSortModeChange = (newSort: MEME_SORT) => {
if (newSort === sort) {
dispatch(toggleHomeSortDirection());
dispatch(toggleMemesSortDirection());
} else {
dispatch(setHomeSort(newSort));
dispatch(setMemesSort(newSort));
if (newSort === MEME_SORT.TITLE) {
dispatch(setHomeSortDirection(SORT_DIRECTION.ASCENDING));
dispatch(setMemesSortDirection(SORT_DIRECTION.ASCENDING));
} else {
dispatch(setHomeSortDirection(SORT_DIRECTION.DESCENDING));
dispatch(setMemesSortDirection(SORT_DIRECTION.DESCENDING));
}
}
setSortMenuVisible(false);
};
const handleFilterChange = (newFilter: MEME_TYPE | undefined) => {
dispatch(setHomeFilter(newFilter));
dispatch(setMemesFilter(newFilter));
setFilterMenuVisible(false);
};
@@ -92,7 +92,7 @@ const Home = () => {
style={[
styles.flexRowSpaceBetween,
styles.alignCenter,
homeStyles.headerButtonView,
memesStyles.headerButtonView,
]}>
<View style={[styles.flexRow, styles.alignCenter]}>
<Menu
@@ -127,13 +127,15 @@ const Home = () => {
icon={getViewIcon(view)}
iconColor={colors.primary}
size={16}
onPress={() => dispatch(cycleHomeView())}
animated
onPress={() => dispatch(cycleMemesView())}
/>
<IconButton
icon={favoritesOnly ? 'heart' : 'heart-outline'}
iconColor={colors.primary}
size={16}
onPress={() => dispatch(toggleHomeFavoritesOnly())}
animated
onPress={() => dispatch(toggleMemesFavoritesOnly())}
/>
<Menu
visible={filterMenuVisible}
@@ -172,7 +174,7 @@ const Home = () => {
{memes.length === 0 && (
<HelperText
type={'info'}
style={[homeStyles.helperText, styles.centerText]}>
style={[memesStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
@@ -180,4 +182,4 @@ const Home = () => {
);
};
export default Home;
export default Memes;

View File

@@ -28,16 +28,16 @@ const SettingsScreen = () => {
const noMedia = useSelector((state: RootState) => state.settings.noMedia);
const dispatch = useDispatch();
const [optimizingDatabase, setOptimizingDatabase] = useState(false);
const [isOptimizingDatabase, setIsOptimizingDatabase] = useState(false);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const optimizeDatabase = () => {
setOptimizingDatabase(true);
setIsOptimizingDatabase(true);
// TODO: clean up missing / extra files
setSnackbarMessage('Database optimized!');
setSnackbarVisible(true);
setOptimizingDatabase(false);
setIsOptimizingDatabase(false);
};
return (
@@ -47,7 +47,6 @@ const SettingsScreen = () => {
orientation == 'portrait' && styles.paddingTop,
orientation == 'landscape' && styles.smallPaddingTop,
styles.paddingHorizontal,
styles.fullSize,
{ backgroundColor: colors.background },
]}>
<View>
@@ -55,10 +54,7 @@ const SettingsScreen = () => {
<List.Subheader>Database</List.Subheader>
<Button
mode="elevated"
style={{
marginBottom: responsive.verticalScale(15),
}}
loading={optimizingDatabase}
loading={isOptimizingDatabase}
onPress={optimizeDatabase}>
Optimize Database Now
</Button>

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useRef, useState } from 'react';
import {
StyleSheet,
View,
Text,
NativeSyntheticEvent,
NativeScrollEvent,
BackHandler,
@@ -13,6 +12,7 @@ import {
HelperText,
Menu,
Searchbar,
Text,
TouchableRipple,
useTheme,
} from 'react-native-paper';
@@ -48,14 +48,13 @@ const tagsStyles = StyleSheet.create({
height: 50,
},
tagRow: {
flexWrap: 'wrap',
justifyContent: 'space-between',
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 15,
},
tagView: {
tagChip: {
flexShrink: 1,
maxWidth: '80%',
},
@@ -195,6 +194,7 @@ const Tags = () => {
<FlashList
ref={flashListRef}
data={tags}
keyExtractor={tag => tag.id.toHexString()}
estimatedItemSize={52}
showsVerticalScrollIndicator={false}
renderItem={({ item: tag }) => (
@@ -203,9 +203,7 @@ const Tags = () => {
navigate(ROUTE.EDIT_TAG, { id: tag.id.toHexString() })
}>
<View style={tagsStyles.tagRow}>
<View style={tagsStyles.tagView}>
<TagChip tag={tag} />
</View>
<TagChip tag={tag} style={tagsStyles.tagChip} />
<Text>{tag.memesLength}</Text>
</View>
</TouchableRipple>

View File

@@ -1,87 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { MEME_SORT, SORT_DIRECTION, VIEW } from '../types';
import { MEME_TYPE } from '../database';
interface HomeState {
sort: MEME_SORT;
sortDirection: SORT_DIRECTION;
view: VIEW;
favoritesOnly: boolean;
filter: MEME_TYPE | undefined;
}
const initialState: HomeState = {
sort: MEME_SORT.TITLE,
sortDirection: SORT_DIRECTION.ASCENDING,
view: VIEW.MASONRY,
favoritesOnly: false,
filter: undefined,
};
const homeSlice = createSlice({
name: 'home',
initialState,
reducers: {
setHomeSort: (state, action: PayloadAction<MEME_SORT>) => {
state.sort = action.payload;
},
setHomeSortDirection: (state, action: PayloadAction<SORT_DIRECTION>) => {
state.sortDirection = action.payload;
},
toggleHomeSortDirection: state => {
state.sortDirection ^= 1;
},
setHomeView: (state, action: PayloadAction<VIEW>) => {
state.view = action.payload;
},
cycleHomeView: state => {
switch (state.view) {
case VIEW.MASONRY: {
state.view = VIEW.GRID;
break;
}
case VIEW.GRID: {
state.view = VIEW.LIST;
break;
}
case VIEW.LIST: {
state.view = VIEW.MASONRY;
break;
}
}
},
setHomeFavoritesOnly: (state, action: PayloadAction<boolean>) => {
state.favoritesOnly = action.payload;
},
toggleHomeFavoritesOnly: state => {
state.favoritesOnly = !state.favoritesOnly;
},
setHomeFilter: (state, action: PayloadAction<MEME_TYPE | undefined>) => {
state.filter = action.payload;
},
},
});
const {
setHomeSort,
setHomeSortDirection,
toggleHomeSortDirection,
setHomeView,
cycleHomeView,
setHomeFavoritesOnly,
toggleHomeFavoritesOnly,
setHomeFilter,
} = homeSlice.actions;
export {
type HomeState,
setHomeSort,
setHomeSortDirection,
toggleHomeSortDirection,
setHomeView,
cycleHomeView,
setHomeFavoritesOnly,
toggleHomeFavoritesOnly,
setHomeFilter,
};
export default homeSlice.reducer;

View File

@@ -11,20 +11,20 @@ import {
} from 'redux-persist';
import { createRealmPersistStorage } from '@bankify/redux-persist-realm';
import settingsReducer from './settings';
import homeReducer from './home';
import memesReducer from './memes';
import tagsReducer from './tags';
import navigationReducer from './navigation';
const rootReducer = combineReducers({
settings: settingsReducer,
home: homeReducer,
memes: memesReducer,
tags: tagsReducer,
navigation: navigationReducer,
});
interface RootState {
settings: ReturnType<typeof settingsReducer>;
home: ReturnType<typeof homeReducer>;
memes: ReturnType<typeof memesReducer>;
tags: ReturnType<typeof tagsReducer>;
navigation: ReturnType<typeof navigationReducer>;
}
@@ -57,16 +57,16 @@ export {
validateSettings,
} from './settings';
export {
type HomeState,
setHomeSort,
setHomeSortDirection,
toggleHomeSortDirection,
setHomeView,
cycleHomeView,
setHomeFavoritesOnly,
toggleHomeFavoritesOnly,
setHomeFilter,
} from './home';
type MemesState,
setMemesSort,
setMemesSortDirection,
toggleMemesSortDirection,
setMemesView,
cycleMemesView,
setMemesFavoritesOnly,
toggleMemesFavoritesOnly,
setMemesFilter,
} from './memes';
export {
type TagsState,
setTagsSort,

83
src/state/memes.ts Normal file
View File

@@ -0,0 +1,83 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { MEME_SORT, SORT_DIRECTION, VIEW } from '../types';
import { MEME_TYPE } from '../database';
interface MemesState {
sort: MEME_SORT;
sortDirection: SORT_DIRECTION;
view: VIEW;
favoritesOnly: boolean;
filter: MEME_TYPE | undefined;
}
const initialState: MemesState = {
sort: MEME_SORT.TITLE,
sortDirection: SORT_DIRECTION.ASCENDING,
view: VIEW.MASONRY,
favoritesOnly: false,
filter: undefined,
};
const memesSlice = createSlice({
name: 'memes',
initialState,
reducers: {
setMemesSort: (state, action: PayloadAction<MEME_SORT>) => {
state.sort = action.payload;
},
setMemesSortDirection: (state, action: PayloadAction<SORT_DIRECTION>) => {
state.sortDirection = action.payload;
},
toggleMemesSortDirection: state => {
state.sortDirection ^= 1;
},
setMemesView: (state, action: PayloadAction<VIEW>) => {
state.view = action.payload;
},
cycleMemesView: state => {
switch (state.view) {
case VIEW.MASONRY: {
state.view = VIEW.LIST;
break;
}
case VIEW.LIST: {
state.view = VIEW.MASONRY;
break;
}
}
},
setMemesFavoritesOnly: (state, action: PayloadAction<boolean>) => {
state.favoritesOnly = action.payload;
},
toggleMemesFavoritesOnly: state => {
state.favoritesOnly = !state.favoritesOnly;
},
setMemesFilter: (state, action: PayloadAction<MEME_TYPE | undefined>) => {
state.filter = action.payload;
},
},
});
const {
setMemesSort,
setMemesSortDirection,
toggleMemesSortDirection,
setMemesView,
cycleMemesView,
setMemesFavoritesOnly,
toggleMemesFavoritesOnly,
setMemesFilter,
} = memesSlice.actions;
export {
type MemesState,
setMemesSort,
setMemesSortDirection,
toggleMemesSortDirection,
setMemesView,
cycleMemesView,
setMemesFavoritesOnly,
toggleMemesFavoritesOnly,
setMemesFilter,
};
export default memesSlice.reducer;

View File

@@ -38,12 +38,18 @@ const styles = StyleSheet.create({
centerText: {
textAlign: 'center',
},
selfCenter: {
alignSelf: 'center',
},
flex: {
flex: 1,
},
flexGrow: {
flexGrow: 1,
},
flexShrink: {
flexShrink: 1,
},
flexRow: {
flexDirection: 'row',
},
@@ -61,6 +67,9 @@ const styles = StyleSheet.create({
flexRowReverse: {
flexDirection: 'row-reverse',
},
flexWrap: {
flexWrap: 'wrap',
},
justifyStart: {
justifyContent: 'flex-start',
},

View File

@@ -1,7 +1,7 @@
export { ROUTE, type RootStackParamList } from './route';
export {
MEME_SORT,
homeSortQuery,
memesSortQuery,
TAG_SORT,
tagSortQuery,
SORT_DIRECTION,

View File

@@ -1,21 +1,39 @@
import { DocumentPickerResponse } from 'react-native-document-picker';
enum ROUTE {
MAIN = 'Main',
HOME = 'Home',
MEMES = 'Memes',
TAGS = 'Tags',
SETTINGS = 'Settings',
ADD_MEME = 'Add Meme',
EDIT_MEME = 'Edit Meme',
ADD_TAG = 'Add Tag',
EDIT_TAG = 'Edit Tag',
}
interface AddMemeRouteParamsFromFiles {
uri: DocumentPickerResponse[];
}
interface EditMemeRouteParams {
id: string;
}
interface EditTagRouteParams {
id: string;
}
interface RootStackParamList {
[key: string]: undefined | EditTagRouteParams;
[key: string]:
| undefined
| AddMemeRouteParamsFromFiles
| EditMemeRouteParams
| EditTagRouteParams;
[ROUTE.MAIN]: undefined;
[ROUTE.EDIT_MEME]: undefined;
[ROUTE.EDIT_TAG]: EditTagRouteParams | undefined;
[ROUTE.ADD_MEME]: AddMemeRouteParamsFromFiles;
[ROUTE.EDIT_MEME]: EditMemeRouteParams;
[ROUTE.ADD_TAG]: undefined;
[ROUTE.EDIT_TAG]: EditTagRouteParams;
}
export { ROUTE, type RootStackParamList };

View File

@@ -7,7 +7,7 @@ enum MEME_SORT {
SIZE = 'Size',
}
const homeSortQuery = (sort: MEME_SORT) => {
const memesSortQuery = (sort: MEME_SORT) => {
switch (sort) {
case MEME_SORT.TITLE: {
return 'title';
@@ -36,6 +36,7 @@ enum TAG_SORT {
MEMES_LENGTH = 'Items',
DATE_CREATED = 'Date Created',
DATE_MODIFIED = 'Date Modified',
DATE_USED = 'Last Used',
TIMES_USED = 'Times Used',
}
@@ -59,6 +60,9 @@ const tagSortQuery = (sort: TAG_SORT) => {
case TAG_SORT.TIMES_USED: {
return 'timesUsed';
}
case TAG_SORT.DATE_USED: {
return 'dateUsed';
}
}
};
@@ -67,4 +71,4 @@ enum SORT_DIRECTION {
DESCENDING = 1,
}
export { MEME_SORT, homeSortQuery, TAG_SORT, tagSortQuery, SORT_DIRECTION };
export { MEME_SORT, memesSortQuery, TAG_SORT, tagSortQuery, SORT_DIRECTION };

View File

@@ -1,6 +1,5 @@
enum VIEW {
MASONRY = 'Masonry',
GRID = 'Grid',
LIST = 'List',
}

View File

@@ -11,8 +11,8 @@ const getContrastColor = (hexColor: string) => {
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness > 128
? lightTheme.colors.onSurface
: darkTheme.colors.onSurface;
? lightTheme.colors.onBackground
: darkTheme.colors.onBackground;
};
const isHexColor = (color: string) => {

View File

@@ -0,0 +1,5 @@
const multipleIdQuery = (ids: string[]) => {
return `id in {${ids.map(id => `uuid(${id})`).join(',')}}`;
};
export { multipleIdQuery };

View File

@@ -0,0 +1,33 @@
import { MEME_TYPE } from '../database';
const allowedImageMimeTypes = [
'image/bmp',
'image/jpeg',
'image/png',
'image/webp',
];
const allowedGifMimeTypes = ['image/gif'];
const allowedMimeTypes = [...allowedImageMimeTypes, ...allowedGifMimeTypes];
const getMemeType = (mimeType: string) => {
switch (mimeType) {
case 'image/bmp':
case 'image/jpeg':
case 'image/png':
case 'image/webp': {
return MEME_TYPE.IMAGE;
}
case 'image/gif': {
return MEME_TYPE.GIF;
}
}
};
export {
allowedImageMimeTypes,
allowedGifMimeTypes,
allowedMimeTypes,
getMemeType,
};

View File

@@ -17,7 +17,8 @@ const getSortIcon = (
case MEME_SORT.DATE_MODIFIED:
case MEME_SORT.DATE_USED:
case TAG_SORT.DATE_CREATED:
case TAG_SORT.DATE_MODIFIED: {
case TAG_SORT.DATE_MODIFIED:
case TAG_SORT.DATE_USED: {
sortIcon = 'sort-calendar';
break;
}
@@ -49,9 +50,6 @@ const getViewIcon = (view: VIEW) => {
case VIEW.MASONRY: {
return 'view-dashboard';
}
case VIEW.GRID: {
return 'view-grid';
}
case VIEW.LIST: {
return 'view-list';
}

View File

@@ -4,8 +4,15 @@ export {
isRgbColor,
isValidColor,
rgbToHex,
generateRandomColor
generateRandomColor,
} from './color';
export { packageName, appName, fileProvider, noOp } from './constants';
export { multipleIdQuery } from './database';
export {
allowedImageMimeTypes,
allowedGifMimeTypes,
allowedMimeTypes,
getMemeType,
} from './filesystem';
export { isPermissionForPath, clearPermissions } from './permissions';
export { getSortIcon, getViewIcon } from './icon';