Add meme view & sharing

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-07-24 21:55:36 +03:00
parent 04661ca356
commit e479e3c0ad
33 changed files with 724 additions and 482 deletions

View File

@@ -12,10 +12,8 @@ build:
- package-lock.json
paths:
- node_modules
policy: pull
before_script:
- npm install
- npm run postinstall
script:
- npm run build
rules:
@@ -30,10 +28,8 @@ compile:
- package-lock.json
paths:
- node_modules
policy: pull
before_script:
- npm install
- npm run postinstall
script:
- npm run compile
after_script:
@@ -56,10 +52,8 @@ test:
- package-lock.json
paths:
- node_modules
policy: pull
before_script:
- npm install
- npm run postinstall
script:
- npm run test
rules:
@@ -77,7 +71,6 @@ lint:
policy: pull
before_script:
- npm install
- npm run postinstall
script:
- npm run lint
rules:
@@ -90,12 +83,12 @@ release:
- job: compile
artifacts: true
script:
- echo "Create Release $CI_COMMIT_SHA"
- echo "Create Release $CI_COMMIT_TAG"
release:
name: "Release $CI_COMMIT_SHORT_SHA"
tag_name: "$CI_COMMIT_SHORT_SHA"
ref: "$CI_COMMIT_SHORT_SHA"
description: "Release $CI_COMMIT_SHORT_SHA"
name: "Release $CI_COMMIT_TAG"
tag_name: "$CI_COMMIT_TAG"
ref: "$CI_COMMIT_TAG"
description: "Release $CI_COMMIT_SHA"
assets:
links:
- name: "app-release.apk"

View File

@@ -1,9 +1,6 @@
import { AppRegistry } from 'react-native';
import 'react-native-get-random-values';
import { enableFreeze } from 'react-native-screens';
import App from './src/app';
import { name as appName } from './app.json';
enableFreeze(true);
AppRegistry.registerComponent(appName, () => App);

54
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@bankify/redux-persist-realm": "^0.1.3",
"@likashefqet/react-native-image-zoom": "^1.3.0",
"@react-native-clipboard/clipboard": "^1.11.2",
"@react-navigation/bottom-tabs": "^6.5.8",
"@react-navigation/native": "^6.1.7",
@@ -23,7 +24,6 @@
"react-native-file-access": "^3.0.4",
"react-native-gesture-handler": "^2.12.0",
"react-native-get-random-values": "^1.9.0",
"react-native-image-zoom-viewer": "^3.0.1",
"react-native-mime-types": "^2.4.0",
"react-native-paper": "^5.9.1",
"react-native-reanimated": "^3.3.0",
@@ -2988,6 +2988,17 @@
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"node_modules/@likashefqet/react-native-image-zoom": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@likashefqet/react-native-image-zoom/-/react-native-image-zoom-1.3.0.tgz",
"integrity": "sha512-PLRd1hNMHe9LUn8b4rmLt86282geuaqP4Qd2rFWIloxMS2ePNTIaNlEUu3T3LaO8Pg9vhVV97TxfFeU8F+tcYQ==",
"peerDependencies": {
"react": ">=16.x.x",
"react-native": ">=0.62.x",
"react-native-gesture-handler": ">=2.x.x",
"react-native-reanimated": ">=2.x.x"
}
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -13414,27 +13425,6 @@
"react-native": ">=0.56"
}
},
"node_modules/react-native-image-pan-zoom": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/react-native-image-pan-zoom/-/react-native-image-pan-zoom-2.1.12.tgz",
"integrity": "sha512-BF66XeP6dzuANsPmmFsJshM2Jyh/Mo1t8FsGc1L9Q9/sVP8MJULDabB1hms+eAoqgtyhMr5BuXV3E1hJ5U5H6Q==",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-image-zoom-viewer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/react-native-image-zoom-viewer/-/react-native-image-zoom-viewer-3.0.1.tgz",
"integrity": "sha512-la6s5DNSuq4GCRLsi5CZ29FPjgTpdCuGIRdO5T9rUrAtxrlpBPhhSnHrbmPVxsdtOUvxHacTh2Gfa9+RraMZQA==",
"dependencies": {
"react-native-image-pan-zoom": "^2.1.12"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"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",
@@ -17927,6 +17917,12 @@
}
}
},
"@likashefqet/react-native-image-zoom": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@likashefqet/react-native-image-zoom/-/react-native-image-zoom-1.3.0.tgz",
"integrity": "sha512-PLRd1hNMHe9LUn8b4rmLt86282geuaqP4Qd2rFWIloxMS2ePNTIaNlEUu3T3LaO8Pg9vhVV97TxfFeU8F+tcYQ==",
"requires": {}
},
"@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -25811,20 +25807,6 @@
"fast-base64-decode": "^1.0.0"
}
},
"react-native-image-pan-zoom": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/react-native-image-pan-zoom/-/react-native-image-pan-zoom-2.1.12.tgz",
"integrity": "sha512-BF66XeP6dzuANsPmmFsJshM2Jyh/Mo1t8FsGc1L9Q9/sVP8MJULDabB1hms+eAoqgtyhMr5BuXV3E1hJ5U5H6Q==",
"requires": {}
},
"react-native-image-zoom-viewer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/react-native-image-zoom-viewer/-/react-native-image-zoom-viewer-3.0.1.tgz",
"integrity": "sha512-la6s5DNSuq4GCRLsi5CZ29FPjgTpdCuGIRdO5T9rUrAtxrlpBPhhSnHrbmPVxsdtOUvxHacTh2Gfa9+RraMZQA==",
"requires": {
"react-native-image-pan-zoom": "^2.1.12"
}
},
"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",

View File

@@ -15,6 +15,7 @@
},
"dependencies": {
"@bankify/redux-persist-realm": "^0.1.3",
"@likashefqet/react-native-image-zoom": "^1.3.0",
"@react-native-clipboard/clipboard": "^1.11.2",
"@react-navigation/bottom-tabs": "^6.5.8",
"@react-navigation/native": "^6.1.7",
@@ -28,7 +29,6 @@
"react-native-file-access": "^3.0.4",
"react-native-gesture-handler": "^2.12.0",
"react-native-get-random-values": "^1.9.0",
"react-native-image-zoom-viewer": "^3.0.1",
"react-native-mime-types": "^2.4.0",
"react-native-paper": "^5.9.1",
"react-native-reanimated": "^3.3.0",

View File

@@ -1,11 +1,10 @@
export {
MemesGridView,
MemesListView,
MemesMasonryView,
MemesList,
MemeEditor,
MemesHeader,
MemeTagSearchModal,
MemeTagSelector,
MemeViewItem,
} from './memes';
export { TagChip, TagEditor, TagPreview, TagRow, TagsHeader } from './tags';
export { default as FloatingActionButton } from './floatingActionButton';

View File

@@ -1,74 +0,0 @@
import React, { RefObject } from 'react';
import { Meme } from '../../../database';
import { FlashList } from '@shopify/flash-list';
import { HelperText } from 'react-native-paper';
import {
NativeScrollEvent,
NativeSyntheticEvent,
StyleSheet,
} from 'react-native';
import { useSelector } from 'react-redux';
import styles from '../../../styles';
import { RootState } from '../../../state';
import { ORIENTATION, useDimensions } from '../../../contexts';
import { getFlashListItemHeight } from '../../../utilities';
import MemesGridItem from './memesGridItem';
const gridViewStyles = StyleSheet.create({
helperText: {
marginVertical: 10,
},
flashList: {
paddingBottom: 100,
paddingHorizontal: 2.5,
},
});
const MemesGridView = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}) => {
const { orientation, dimensions } = useDimensions();
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
return (
<FlashList
ref={flashListRef}
data={memes}
estimatedItemSize={getFlashListItemHeight(gridColumns)}
estimatedListSize={{
height: dimensions.height,
width: dimensions.width * 0.92,
}}
numColumns={gridColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme }) => <MemesGridItem meme={meme} />}
contentContainerStyle={{
paddingTop:
flashListPadding +
dimensions.height *
(orientation === ORIENTATION.PORTRAIT ? 0.02 : 0.04),
...gridViewStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[gridViewStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/>
);
};
export default MemesGridView;

View File

@@ -1,7 +1,6 @@
export { default as MemesGridView } from './gridView/memesGridView';
export { default as MemesListView } from './listView/memesListView';
export { default as MemesMasonryView } from './masonryView/memesMasonryView';
export { default as MemesList } from './memesList/memesList';
export { default as MemeEditor } from './memeEditor';
export { default as MemesHeader } from './memesHeader';
export { default as MemeTagSearchModal } from './memeTagSearchModal';
export { default as MemeTagSelector } from './memeTagSelector';
export { default as MemeViewItem } from './memeViewItem';

View File

@@ -1,68 +0,0 @@
import React, { RefObject } from 'react';
import { Meme } from '../../../database';
import { FlashList } from '@shopify/flash-list';
import { Divider, HelperText } from 'react-native-paper';
import {
NativeScrollEvent,
NativeSyntheticEvent,
StyleSheet,
} from 'react-native';
import styles from '../../../styles';
import { ORIENTATION, useDimensions } from '../../../contexts';
import MemesListItem from './memesListItem';
const gridViewStyles = StyleSheet.create({
helperText: {
marginVertical: 10,
},
flashList: {
paddingBottom: 100,
paddingHorizontal: 5,
},
});
const MemesListView = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}) => {
const { orientation, dimensions } = useDimensions();
return (
<FlashList
ref={flashListRef}
data={memes}
estimatedItemSize={50}
estimatedListSize={{
height: dimensions.height,
width: dimensions.width * 0.92,
}}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme }) => <MemesListItem meme={meme} />}
ItemSeparatorComponent={() => <Divider />}
contentContainerStyle={{
paddingTop:
flashListPadding +
dimensions.height *
(orientation === ORIENTATION.PORTRAIT ? 0.02 : 0.04),
...gridViewStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[gridViewStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/>
);
};
export default MemesListView;

View File

@@ -1,75 +0,0 @@
import React, { RefObject } from 'react';
import { Meme } from '../../../database';
import { FlashList, MasonryFlashList } from '@shopify/flash-list';
import { HelperText } from 'react-native-paper';
import {
NativeScrollEvent,
NativeSyntheticEvent,
StyleSheet,
} from 'react-native';
import { useSelector } from 'react-redux';
import styles from '../../../styles';
import { RootState } from '../../../state';
import { ORIENTATION, useDimensions } from '../../../contexts';
import { getFlashListItemHeight } from '../../../utilities';
import MemesMasonryItem from './memesMasonryItem';
const memeMasonryViewStyles = StyleSheet.create({
helperText: {
marginVertical: 10,
},
flashList: {
paddingBottom: 100,
// Needed to prevent fucky MasonryFlashList, see https://github.com/Shopify/flash-list/issues/876
paddingHorizontal: 0.1,
},
});
const MemesMasonryView = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}) => {
const { orientation, dimensions } = useDimensions();
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
return (
<MasonryFlashList
ref={flashListRef}
data={memes}
estimatedItemSize={getFlashListItemHeight(masonryColumns)}
estimatedListSize={{
height: dimensions.height,
width: dimensions.width * 0.92,
}}
numColumns={masonryColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme }) => <MemesMasonryItem meme={meme} />}
contentContainerStyle={{
paddingTop:
flashListPadding +
dimensions.height *
(orientation === ORIENTATION.PORTRAIT ? 0.02 : 0.04),
...memeMasonryViewStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[memeMasonryViewStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/>
);
};
export default MemesMasonryView;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { HelperText, TextInput } from 'react-native-paper';
import { Image } from 'react-native';
import { useDimensions } from '../../contexts';
@@ -21,13 +21,13 @@ const memeEditorStyles = {
};
const MemeEditor = ({
imageUri,
memeUri,
memeTitle,
setMemeTitle,
memeTags,
setMemeTags,
}: {
imageUri: string;
memeUri: string;
memeTitle: StringValidationResult;
setMemeTitle: (name: StringValidationResult) => void;
memeTags: Map<string, Tag>;
@@ -38,15 +38,23 @@ const MemeEditor = ({
const [imageWidth, setImageWidth] = useState<number>();
const [imageHeight, setImageHeight] = useState<number>();
Image.getSize(imageUri, (width, height) => {
const paddedWidth = dimensions.width * 0.92;
const paddedHeight = Math.max(
Math.min((paddedWidth / width) * height, 500),
100,
);
setImageWidth(paddedWidth);
setImageHeight(paddedHeight);
});
useEffect(() => {
// eslint-disable-next-line unicorn/no-useless-undefined
setImageWidth(undefined);
// eslint-disable-next-line unicorn/no-useless-undefined
setImageHeight(undefined);
Image.getSize(memeUri, (width, height) => {
const paddedWidth = dimensions.width * 0.92;
const paddedHeight = Math.max(
Math.min((paddedWidth / width) * height, 500),
100,
);
setImageWidth(paddedWidth);
setImageHeight(paddedHeight);
});
}, [memeUri, dimensions.width]);
if (!imageWidth || !imageHeight) return <LoadingView />;
@@ -64,7 +72,7 @@ const MemeEditor = ({
{memeTitle.error}
</HelperText>
<Image
source={{ uri: imageUri }}
source={{ uri: memeUri }}
style={[
{
width: imageWidth,

View File

@@ -0,0 +1,53 @@
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, useTheme } from 'react-native-paper';
import { ImageZoom } from '@likashefqet/react-native-image-zoom';
import { Meme } from '../../database';
import { useDimensions } from '../../contexts';
import { Image, View } from 'react-native';
import styles from '../../styles';
const MemeViewItem = ({ meme }: { meme: Meme }) => {
const { dimensions } = useDimensions();
const { colors } = useTheme();
const [imageWidth, setImageWidth] = useState<number>();
const [imageHeight, setImageHeight] = useState<number>();
useEffect(() => {
Image.getSize(meme.uri, (width, height) => {
const ratio = width / height;
const screenRatio = dimensions.width / dimensions.height - 160;
if (ratio > screenRatio) {
setImageWidth(dimensions.width);
setImageHeight(dimensions.width / ratio);
} else {
setImageWidth(dimensions.height * ratio);
setImageHeight(dimensions.height);
}
});
}, [meme.uri, dimensions.width, dimensions.height]);
return (
<View
style={[
{
width: dimensions.width,
height: dimensions.height - 160,
backgroundColor: colors.background,
},
styles.centered,
]}>
{imageWidth && imageHeight ? (
<ImageZoom
source={{ uri: meme.uri }}
style={{ width: imageWidth, height: imageHeight }}
/>
) : (
<ActivityIndicator size="large" color={colors.primary} />
)}
</View>
);
};
export default MemeViewItem;

View File

@@ -1,14 +1,19 @@
import React, { useState } from 'react';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { Image, TouchableHighlight, View } from 'react-native';
import { useSelector } from 'react-redux';
import { Meme } from '../../../database';
import { ROUTE, RootStackParamList } from '../../../types';
import { useDimensions } from '../../../contexts';
import { RootState } from '../../../state';
const MemesGridItem = ({ meme }: { meme: Meme }) => {
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
const MemesGridItem = ({
meme,
index,
focusMeme,
}: {
meme: Meme;
index: number;
focusMeme: (index: number) => void;
}) => {
const { dimensions } = useDimensions();
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
@@ -27,10 +32,7 @@ const MemesGridItem = ({ meme }: { meme: Meme }) => {
<>
{imageWidth && imageHeight && (
<View>
<TouchableHighlight
onPress={() =>
navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() })
}>
<TouchableHighlight onPress={() => focusMeme(index)}>
<Image
source={{ uri: meme.uri }}
style={[{ width: imageWidth, height: imageHeight }]}

View File

@@ -0,0 +1,167 @@
import React, { RefObject } from 'react';
import { FlashList, MasonryFlashList } from '@shopify/flash-list';
import {
NativeSyntheticEvent,
NativeScrollEvent,
StyleSheet,
} from 'react-native';
import { useSelector } from 'react-redux';
import { Divider, HelperText } from 'react-native-paper';
import { useDimensions, ORIENTATION } from '../../../contexts';
import { Meme } from '../../../database';
import { RootState } from '../../../state';
import { VIEW } from '../../../types';
import { getFlashListItemHeight } from '../../../utilities';
import styles from '../../../styles';
import MemesMasonryItem from './memesMasonryItem';
import MemesGridItem from './memesGridItem';
import MemesListItem from './memesListItem';
const memesMasonryListStyles = StyleSheet.create({
flashList: {
paddingBottom: 100,
// Needed to prevent fucky MasonryFlashList, see https://github.com/Shopify/flash-list/issues/876
paddingHorizontal: 0.1,
},
helperText: {
marginVertical: 15,
},
});
const memesGridListStyles = StyleSheet.create({
flashList: {
paddingBottom: 100,
paddingHorizontal: 2.5,
},
helperText: {
marginVertical: 12.5,
},
});
const memesListListStyles = StyleSheet.create({
flashList: {
paddingBottom: 100,
paddingHorizontal: 5,
},
helperText: {
marginVertical: 15,
},
});
const MemesList = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
focusMeme,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
focusMeme: (index: number) => void;
}) => {
const { dimensions, orientation } = useDimensions();
const view = useSelector((state: RootState) => state.memes.view);
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
const extraFlashListPadding =
flashListPadding +
dimensions.height * (orientation === ORIENTATION.PORTRAIT ? 0.02 : 0.04);
return (
<>
{view === VIEW.MASONRY && (
<MasonryFlashList
ref={flashListRef}
data={memes}
estimatedItemSize={getFlashListItemHeight(masonryColumns)}
estimatedListSize={{
height: dimensions.height,
width: dimensions.width * 0.92,
}}
numColumns={masonryColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme, index }) => (
<MemesMasonryItem meme={meme} index={index} focusMeme={focusMeme} />
)}
contentContainerStyle={{
paddingTop: extraFlashListPadding,
...memesMasonryListStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[memesMasonryListStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/>
)}
{view === VIEW.GRID && (
<FlashList
ref={flashListRef}
data={memes}
estimatedItemSize={getFlashListItemHeight(gridColumns)}
estimatedListSize={{
height: dimensions.height,
width: dimensions.width * 0.92,
}}
numColumns={gridColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme, index }) => (
<MemesGridItem meme={meme} index={index} focusMeme={focusMeme} />
)}
contentContainerStyle={{
paddingTop: extraFlashListPadding + 2.5,
...memesGridListStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[memesGridListStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/>
)}
{view === VIEW.LIST && (
<FlashList
ref={flashListRef}
data={memes}
estimatedItemSize={50}
estimatedListSize={{
height: dimensions.height,
width: dimensions.width * 0.92,
}}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme, index }) => (
<MemesListItem meme={meme} index={index} focusMeme={focusMeme} />
)}
ItemSeparatorComponent={() => <Divider />}
contentContainerStyle={{
paddingTop: extraFlashListPadding,
...memesListListStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[memesListListStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/>
)}
</>
);
};
export default MemesList;

View File

@@ -1,9 +1,7 @@
import React, { useState } from 'react';
import { Image, StyleSheet, View } from 'react-native';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { Text, TouchableRipple } from 'react-native-paper';
import { Meme } from '../../../database';
import { ROUTE, RootStackParamList } from '../../../types';
import styles from '../../../styles';
import { useDimensions } from '../../../contexts';
@@ -23,8 +21,15 @@ const memesListItemStyles = StyleSheet.create({
},
});
const MemesListItem = ({ meme }: { meme: Meme }) => {
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
const MemesListItem = ({
meme,
index,
focusMeme,
}: {
meme: Meme;
index: number;
focusMeme: (index: number) => void;
}) => {
const { dimensions } = useDimensions();
const [imageWidth, setImageWidth] = useState<number>();
@@ -40,9 +45,7 @@ const MemesListItem = ({ meme }: { meme: Meme }) => {
<>
{imageWidth && imageHeight && (
<TouchableRipple
onPress={() =>
navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() })
}
onPress={() => focusMeme(index)}
style={[memesListItemStyles.view, styles.flexRow]}>
<>
<View style={{ width: imageWidth, height: imageHeight }}>

View File

@@ -1,9 +1,7 @@
import React, { useState } from 'react';
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import { Meme } from '../../../database';
import { ROUTE, RootStackParamList } from '../../../types';
import { useDimensions } from '../../../contexts';
import { RootState } from '../../../state';
@@ -17,8 +15,15 @@ const memeMasonryItemStyles = StyleSheet.create({
},
});
const MemesMasonryItem = ({ meme }: { meme: Meme }) => {
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
const MemesMasonryItem = ({
meme,
index,
focusMeme,
}: {
meme: Meme;
index: number;
focusMeme: (index: number) => void;
}) => {
const { dimensions } = useDimensions();
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
@@ -37,9 +42,7 @@ const MemesMasonryItem = ({ meme }: { meme: Meme }) => {
<>
{imageWidth && imageHeight && (
<TouchableHighlight
onPress={() =>
navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() })
}
onPress={() => focusMeme(index)}
style={memeMasonryItemStyles.view}>
<Image
source={{ uri: meme.uri }}

View File

@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { HelperText, TextInput } from 'react-native-paper';
import TagPreview from './tagPreview';
import {
@@ -21,15 +21,16 @@ const TagEditor = ({
}) => {
const lastValidTagColor = useRef(tagColor.parsed);
const handleTagColorChange = (color: string) => {
const result = validateColor(color);
setTagColor(result);
if (result.valid) lastValidTagColor.current = result.parsed;
};
useEffect(() => {
if (tagColor.valid) lastValidTagColor.current = tagColor.parsed;
}, [tagColor]);
return (
<>
<TagPreview name={tagName.parsed} color={lastValidTagColor.current} />
<TagPreview
name={tagName.parsed}
color={tagColor.valid ? tagColor.parsed : lastValidTagColor.current}
/>
<TextInput
mode="outlined"
label="Name"
@@ -45,13 +46,13 @@ const TagEditor = ({
mode="outlined"
label="Color"
value={tagColor.raw}
onChangeText={handleTagColorChange}
onChangeText={color => setTagColor(validateColor(color))}
error={!tagColor.valid}
autoCorrect={false}
right={
<TextInput.Icon
icon="palette"
onPress={() => handleTagColorChange(generateRandomColor())}
onPress={() => setTagColor(validateColor(generateRandomColor()))}
/>
}
/>

View File

@@ -22,6 +22,7 @@ class Meme extends Object<Meme> {
id!: BSON.UUID;
type!: MEME_TYPE;
uri!: string;
mimeType!: string;
size!: number;
title!: string;
isFavorite!: boolean;
@@ -39,6 +40,7 @@ class Meme extends Object<Meme> {
id: { type: 'uuid', default: () => new BSON.UUID() },
type: { type: 'string', indexed: true },
uri: 'string',
mimeType: 'string',
size: 'int',
title: 'string',
isFavorite: { type: 'bool', indexed: true, default: false },

View File

@@ -13,6 +13,7 @@ import {
EditTag,
AddMeme,
AddTag,
MemeView,
} from './screens';
import { darkNavigationTheme, lightNavigationTheme } from './theme';
import {
@@ -91,10 +92,14 @@ const NavigationContainer = () => {
<StackNavigatorBase.Navigator
screenOptions={{
headerShown: false,
freezeOnBlur: true,
animation: 'slide_from_bottom',
}}>
<StackNavigatorBase.Screen name={ROUTE.MAIN} component={TabNavigator} />
<StackNavigatorBase.Screen
name={ROUTE.MEME_VIEW}
component={MemeView}
options={{ presentation: 'modal', animation: 'default' }}
/>
<StackNavigatorBase.Screen name={ROUTE.ADD_MEME} component={AddMeme} />
<StackNavigatorBase.Screen
name={ROUTE.EDIT_MEME}

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { ORIENTATION, useDimensions } from '../contexts';
import { ScrollView, View } from 'react-native';
import { ScrollView, StyleSheet, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useRealm } from '@realm/react';
import { BSON } from 'realm';
@@ -13,8 +13,23 @@ import styles from '../styles';
import { ROUTE, RootStackParamList } from '../types';
import { Meme, Tag } from '../database';
import { RootState } from '../state';
import { getMemeType, validateMemeTitle } from '../utilities';
import { allowedMimeTypes, getMemeType, validateMemeTitle } from '../utilities';
import { MemeEditor } from '../components';
import {
DocumentPickerResponse,
pickSingle,
} from 'react-native-document-picker';
const addMemeStyles = StyleSheet.create({
saveAndAddButton: {
flex: 1,
marginRight: 5,
},
saveButton: {
flex: 1,
marginLeft: 5,
},
});
const AddMeme = ({
route,
@@ -28,31 +43,32 @@ const AddMeme = ({
(state: RootState) => state.settings.storageUri,
)!;
const { file } = route.params;
const file = useRef(route.params.file);
const [memeUri, setMemeUri] = useState(file.current.uri);
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
const [isSaving, setIsSaving] = useState(false);
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
useState(false);
const handleSave = async () => {
setIsSaving(true);
const handleSave = useCallback(async () => {
const uuid = new BSON.UUID();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mimeType = file.type!;
const mimeType = file.current.type!;
const memeType = getMemeType(mimeType);
const fileExtension = extension(mimeType);
const fileExtension = extension(mimeType) as string;
if (!fileExtension) goBack();
const uri = AndroidScoped.appendPath(
storageUri,
`${uuid.toHexString()}.${fileExtension as string}`,
`${uuid.toHexString()}.${fileExtension}`,
);
await FileSystem.cp(file.uri, uri);
await FileSystem.cp(file.current.uri, uri);
const { size } = await FileSystem.stat(uri);
realm.write(() => {
@@ -60,6 +76,7 @@ const AddMeme = ({
id: uuid,
type: memeType,
uri,
mimeType,
size,
title: memeTitle.parsed,
isFavorite: memeIsFavorite,
@@ -73,9 +90,7 @@ const AddMeme = ({
tag.memesLength = tag.memes.length;
});
});
goBack();
};
}, [goBack, memeIsFavorite, memeTags, memeTitle.parsed, realm, storageUri]);
return (
<>
@@ -99,20 +114,46 @@ const AddMeme = ({
]}>
<View style={[styles.flex, styles.justifyStart]}>
<MemeEditor
imageUri={file.uri}
memeUri={memeUri}
memeTitle={memeTitle}
setMemeTitle={setMemeTitle}
memeTags={memeTags}
setMemeTags={setMemeTags}
/>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
<View style={[styles.flexRow, styles.fullWidth]}>
<Button
mode="contained-tonal"
icon="plus"
onPress={async () => {
setIsSavingAndAddingAnother(true);
await handleSave();
setIsSavingAndAddingAnother(false);
file.current = (await pickSingle({
type: allowedMimeTypes,
}).catch(goBack)) as DocumentPickerResponse;
setMemeUri(file.current.uri);
setMemeTitle(validateMemeTitle('New Meme'));
setMemeIsFavorite(false);
setMemeTags(new Map<string, Tag>());
}}
disabled={!memeTitle.valid || isSaving || isSavingAndAddingAnother}
loading={isSavingAndAddingAnother}
style={addMemeStyles.saveAndAddButton}>
Save & Add
</Button>
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={!memeTitle.valid || isSaving}
loading={isSaving}>
onPress={async () => {
setIsSaving(true);
await handleSave();
setIsSaving(false);
goBack();
}}
disabled={!memeTitle.valid || isSaving || isSavingAndAddingAnother}
loading={isSaving}
style={addMemeStyles.saveButton}>
Save
</Button>
</View>

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ScrollView, View } from 'react-native';
import React, { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { useRealm } from '@realm/react';
@@ -13,6 +13,17 @@ import { ORIENTATION, useDimensions } from '../contexts';
import { Tag } from '../database';
import { TagEditor } from '../components';
const addTagStyles = StyleSheet.create({
saveAndAddButton: {
flex: 1,
marginRight: 5,
},
saveButton: {
flex: 1,
marginLeft: 5,
},
});
const AddTag = () => {
const { goBack } = useNavigation();
const { colors } = useTheme();
@@ -24,16 +35,19 @@ const AddTag = () => {
validateColor(generateRandomColor()),
);
const handleSave = () => {
// Although saving tags is instantaneous, we still want to show a loading
// indicator to prevent the user from spamming the save button.
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
useState(false);
const handleSave = useCallback(() => {
realm.write(() => {
realm.create(Tag.schema.name, {
name: tagName.parsed,
color: tagColor.parsed,
});
});
goBack();
};
}, [realm, tagColor.parsed, tagName.parsed]);
return (
<>
@@ -59,12 +73,31 @@ const AddTag = () => {
setTagColor={setTagColor}
/>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
<View style={[styles.flexRow, styles.fullWidth]}>
<Button
mode="contained-tonal"
icon="plus"
onPress={() => {
setIsSavingAndAddingAnother(true);
handleSave();
setTimeout(() => setIsSavingAndAddingAnother(false), 250);
setTagName(validateTagName('newTag'));
setTagColor(validateColor(generateRandomColor()));
}}
disabled={!tagName.valid || isSavingAndAddingAnother}
loading={isSavingAndAddingAnother}
style={addTagStyles.saveAndAddButton}>
Save & Add
</Button>
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={!tagName.valid || !tagColor.valid}>
onPress={() => {
handleSave();
goBack();
}}
disabled={!tagName.valid || isSavingAndAddingAnother}
style={addTagStyles.saveButton}>
Save
</Button>
</View>

View File

@@ -1,16 +1,15 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useObject, useRealm } from '@realm/react';
import { FileSystem } from 'react-native-file-access';
import { BSON } from 'realm';
import { ORIENTATION, useDimensions } from '../contexts';
import styles from '../styles';
import { RootStackParamList, ROUTE } from '../types';
import { Tag, Meme } from '../database';
import { validateMemeTitle } from '../utilities';
import { deleteMeme, favoriteMeme, validateMemeTitle } from '../utilities';
import { MemeEditor } from '../components';
const EditMeme = ({
@@ -34,9 +33,7 @@ const EditMeme = ({
const [isSaving, setIsSaving] = useState(false);
const handleSave = () => {
setIsSaving(true);
const handleSave = useCallback(() => {
realm.write(() => {
meme.tags.forEach(tag => {
if (!memeTags.has(tag.id.toHexString())) {
@@ -60,32 +57,7 @@ const EditMeme = ({
meme.tagsLength = memeTags.size;
meme.dateModified = new Date();
});
goBack();
};
const handleFavorite = () => {
realm.write(() => {
meme.isFavorite = !meme.isFavorite;
});
};
const handleDelete = async () => {
setIsSaving(true);
await FileSystem.unlink(meme.uri);
realm.write(() => {
for (const tag of meme.tags) {
tag.dateModified = new Date();
tag.memes.slice(tag.memes.indexOf(meme), 1);
tag.memesLength -= 1;
}
realm.delete(meme);
});
goBack();
};
}, [meme, memeTags, memeTitle.parsed, realm]);
return (
<>
@@ -94,9 +66,17 @@ const EditMeme = ({
<Appbar.Content title={'Edit Meme'} />
<Appbar.Action
icon={meme.isFavorite ? 'heart' : 'heart-outline'}
onPress={handleFavorite}
onPress={() => favoriteMeme(realm, meme)}
/>
<Appbar.Action
icon="delete"
onPress={async () => {
setIsSaving(true);
await deleteMeme(realm, meme);
setIsSaving(false);
goBack();
}}
/>
<Appbar.Action icon="delete" onPress={handleDelete} />
</Appbar.Header>
<ScrollView
contentContainerStyle={[
@@ -110,7 +90,7 @@ const EditMeme = ({
]}>
<View style={[styles.flex, styles.justifyStart]}>
<MemeEditor
imageUri={meme.uri}
memeUri={meme.uri}
memeTitle={memeTitle}
setMemeTitle={setMemeTitle}
memeTags={memeTags}
@@ -121,7 +101,12 @@ const EditMeme = ({
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
onPress={() => {
setIsSaving(true);
handleSave();
setIsSaving(false);
goBack();
}}
disabled={!memeTitle.valid || isSaving}
loading={isSaving}>
Save

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
@@ -10,7 +10,7 @@ import styles from '../styles';
import { ORIENTATION, useDimensions } from '../contexts';
import { ROUTE, RootStackParamList } from '../types';
import { Tag } from '../database';
import { validateColor, validateTagName } from '../utilities';
import { deleteTag, validateColor, validateTagName } from '../utilities';
const EditTag = ({
route,
@@ -29,36 +29,26 @@ const EditTag = ({
const [tagName, setTagName] = useState(validateTagName(tag.name));
const [tagColor, setTagColor] = useState(validateColor(tag.color));
const handleSave = () => {
const handleSave = useCallback(() => {
realm.write(() => {
tag.name = tagName.parsed;
tag.color = tagColor.parsed;
tag.dateModified = new Date();
});
goBack();
};
const handleDelete = () => {
realm.write(() => {
for (const meme of tag.memes) {
meme.dateModified = new Date();
meme.tags.slice(meme.tags.indexOf(tag), 1);
meme.tagsLength -= 1;
}
realm.delete(tag);
});
goBack();
};
}, [realm, tag, tagColor.parsed, tagName.parsed]);
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => goBack()} />
<Appbar.Content title={'Edit Tag'} />
<Appbar.Action icon="delete" onPress={handleDelete} />
<Appbar.Action
icon="delete"
onPress={() => {
deleteTag(realm, tag);
goBack();
}}
/>
</Appbar.Header>
<ScrollView
contentContainerStyle={[
@@ -83,7 +73,10 @@ const EditTag = ({
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
onPress={() => {
handleSave();
goBack();
}}
disabled={!tagName.valid || !tagColor.valid}>
Save
</Button>

View File

@@ -3,6 +3,7 @@ export { default as AddTag } from './addTag';
export { default as EditMeme } from './editMeme';
export { default as EditTag } from './editTag';
export { default as Memes } from './memes';
export { default as MemeView } from './memeView';
export { default as Settings } from './settings';
export { default as Tags } from './tags';
export { default as Welcome } from './welcome';

136
src/screens/memeView.tsx Normal file
View File

@@ -0,0 +1,136 @@
import React, { useRef, useState } from 'react';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { StyleSheet, View } from 'react-native';
import { useQuery, useRealm } from '@realm/react';
import { FlashList } from '@shopify/flash-list';
import { Appbar, Portal, Snackbar } from 'react-native-paper';
import { RootStackParamList, ROUTE } from '../types';
import { Meme } from '../database';
import { useDimensions } from '../contexts';
import { MemeViewItem } from '../components';
import {
copyMeme,
deleteMeme,
editMeme,
favoriteMeme,
multipleIdQuery,
shareMeme,
} from '../utilities';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import styles from '../styles';
const memeViewStyles = StyleSheet.create({
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 80,
},
snackbar: {
marginBottom: 90,
},
});
const MemeView = ({
route,
}: NativeStackScreenProps<RootStackParamList, ROUTE.MEME_VIEW>) => {
const { orientation, dimensions } = useDimensions();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const realm = useRealm();
const { ids } = route.params;
const [index, setIndex] = useState(route.params.index);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const flashListRef = useRef<FlashList<Meme>>(null);
const memes = useQuery<Meme>(Meme.schema.name, collectionIn => {
return collectionIn.filtered(multipleIdQuery(ids));
});
if (memes.length === 0) return <></>;
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => navigation.goBack()} />
<Appbar.Content title={memes[index].title} />
</Appbar.Header>
<View>
<FlashList
ref={flashListRef}
key={orientation}
data={memes}
initialScrollIndex={index}
onScroll={event => {
const newIndex = Math.round(
event.nativeEvent.contentOffset.x /
event.nativeEvent.layoutMeasurement.width,
);
if (newIndex !== index) setIndex(newIndex);
}}
estimatedItemSize={dimensions.width}
pagingEnabled
horizontal
showsHorizontalScrollIndicator={false}
estimatedListSize={{
height: dimensions.height - 160,
width: dimensions.width,
}}
renderItem={({ item: meme }) => <MemeViewItem meme={meme} />}
/>
</View>
<Appbar style={[memeViewStyles.footer, styles.flexRowSpaceEvenly]}>
<Appbar.Action
icon={memes[index].isFavorite ? 'heart' : 'heart-outline'}
onPress={() => favoriteMeme(realm, memes[index])}
/>
<Appbar.Action icon="share" onPress={() => shareMeme(memes[index])} />
<Appbar.Action
icon="content-copy"
onPress={() => {
copyMeme(memes[index]);
setSnackbarMessage('Meme copied!');
setSnackbarVisible(true);
}}
/>
<Appbar.Action
icon="pencil"
onPress={() => {
editMeme(navigation, memes[index]);
}}
/>
<Appbar.Action
icon="delete"
onPress={() => {
if (index === memes.length - 1) {
setIndex(index - 1);
flashListRef.current?.scrollToIndex({
index: index - 1,
});
}
void deleteMeme(realm, memes[index]);
if (memes.length === 1) navigation.goBack();
}}
/>
</Appbar>
<Portal>
<Snackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
style={memeViewStyles.snackbar}
action={{
label: 'Dismiss',
onPress: () => setSnackbarVisible(false),
}}>
{snackbarMessage}
</Snackbar>
</Portal>
</>
);
};
export default MemeView;

View File

@@ -1,4 +1,4 @@
import React, { RefObject, useCallback, useRef, useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import {
BackHandler,
NativeScrollEvent,
@@ -9,71 +9,22 @@ import { useQuery } from '@realm/react';
import { useTheme } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import { FlashList } from '@shopify/flash-list';
import { useFocusEffect } from '@react-navigation/native';
import {
ParamListBase,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import styles from '../styles';
import { SORT_DIRECTION, VIEW, memesSortQuery } from '../types';
import { ROUTE, SORT_DIRECTION, memesSortQuery } from '../types';
import { RootState, setNavVisible } from '../state';
import { Meme } from '../database';
import {
HideableHeader,
MemesHeader,
MemesMasonryView,
MemesGridView,
MemesListView,
} from '../components';
const MemesView = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}) => {
const view = useSelector((state: RootState) => state.memes.view);
switch (view) {
case VIEW.MASONRY: {
return (
<MemesMasonryView
memes={memes}
flashListRef={flashListRef}
flashListPadding={flashListPadding}
handleScroll={handleScroll}
/>
);
}
case VIEW.GRID: {
return (
<MemesGridView
memes={memes}
flashListRef={flashListRef}
flashListPadding={flashListPadding}
handleScroll={handleScroll}
/>
);
}
case VIEW.LIST: {
return (
<MemesListView
memes={memes}
flashListRef={flashListRef}
flashListPadding={flashListPadding}
handleScroll={handleScroll}
/>
);
}
default: {
return <></>;
}
}
};
import { HideableHeader, MemesHeader, MemesList } from '../components';
const Memes = () => {
const { colors } = useTheme();
const { navigate } =
useNavigation<NativeStackNavigationProp<ParamListBase>>();
const sort = useSelector((state: RootState) => state.memes.sort);
const sortDirection = useSelector(
(state: RootState) => state.memes.sortDirection,
@@ -154,11 +105,9 @@ const Memes = () => {
useFocusEffect(
useCallback(() => {
const handleBackPress = () => {
if (scrollOffset > 0) {
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
return true;
}
return false;
if (scrollOffset <= 0) return false;
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
return true;
};
BackHandler.addEventListener('hardwareBackPress', handleBackPress);
@@ -190,11 +139,17 @@ const Memes = () => {
}}
/>
</HideableHeader>
<MemesView
<MemesList
memes={memes}
flashListRef={flashListRef}
flashListPadding={flashListPadding}
handleScroll={handleScroll}
focusMeme={(index: number) => {
navigate(ROUTE.MEME_VIEW, {
ids: memes.map(meme => meme.id.toHexString()),
index,
});
}}
/>
</View>
);

View File

@@ -19,12 +19,12 @@ import { SORT_DIRECTION, tagSortQuery } from '../types';
import { ORIENTATION, useDimensions } from '../contexts';
const tagsStyles = StyleSheet.create({
helperText: {
marginVertical: 10,
},
flashList: {
paddingBottom: 100,
},
helperText: {
marginVertical: 15,
},
});
const Tags = () => {

View File

@@ -57,6 +57,10 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
},
flexRowSpaceEvenly: {
flexDirection: 'row',
justifyContent: 'space-evenly',
},
flexColumn: {
flexDirection: 'column',
},

View File

@@ -1,4 +1,7 @@
import { DefaultTheme as DefaultNavigationTheme } from '@react-navigation/native';
import {
DarkTheme,
DefaultTheme as LightTheme,
} from '@react-navigation/native';
import {
MD3LightTheme,
MD3DarkTheme,
@@ -98,12 +101,12 @@ const darkTheme = {
};
const { LightTheme: lightNavigationTheme } = adaptNavigationTheme({
reactNavigationLight: DefaultNavigationTheme,
reactNavigationLight: LightTheme,
materialLight: lightTheme,
});
const { DarkTheme: darkNavigationTheme } = adaptNavigationTheme({
reactNavigationDark: DefaultNavigationTheme,
reactNavigationDark: DarkTheme,
materialDark: darkTheme,
});

View File

@@ -5,13 +5,19 @@ enum ROUTE {
MEMES = 'Memes',
TAGS = 'Tags',
SETTINGS = 'Settings',
MEME_VIEW = 'Meme View',
ADD_MEME = 'Add Meme',
EDIT_MEME = 'Edit Meme',
ADD_TAG = 'Add Tag',
EDIT_TAG = 'Edit Tag',
}
interface AddMemeRouteParamsFromFiles {
interface MemeViewRouteParams {
ids: string[];
index: number;
}
interface AddMemeRouteParams {
file: DocumentPickerResponse;
}
@@ -26,11 +32,13 @@ interface EditTagRouteParams {
interface RootStackParamList {
[key: string]:
| undefined
| AddMemeRouteParamsFromFiles
| MemeViewRouteParams
| AddMemeRouteParams
| EditMemeRouteParams
| EditTagRouteParams;
[ROUTE.MAIN]: undefined;
[ROUTE.ADD_MEME]: AddMemeRouteParamsFromFiles;
[ROUTE.MEME_VIEW]: MemeViewRouteParams;
[ROUTE.ADD_MEME]: AddMemeRouteParams;
[ROUTE.EDIT_MEME]: EditMemeRouteParams;
[ROUTE.ADD_TAG]: undefined;
[ROUTE.EDIT_TAG]: EditTagRouteParams;

View File

@@ -32,6 +32,15 @@ const rgbToHex = (rgb: string) => {
return `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`;
};
const rgbToRgba = (rgb: string, alpha: number) => {
const [r, g, b] = rgb
.replaceAll(/[^\d,]/g, '')
.split(',')
.map(value => Number.parseInt(value, 10));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
const generateRandomColor = () => {
const r = Math.floor(Math.random() * 256)
.toString(16)
@@ -50,5 +59,6 @@ export {
isHexColor,
isRgbColor,
rgbToHex,
rgbToRgba,
generateRandomColor,
};

View File

@@ -3,6 +3,7 @@ export {
isHexColor,
isRgbColor,
rgbToHex,
rgbToRgba,
generateRandomColor,
} from './color';
export { packageName, appName, fileProvider, noOp } from './constants';
@@ -14,8 +15,16 @@ export {
allowedMimeTypes,
getMemeType,
} from './filesystem';
export { isPermissionForPath, clearPermissions } from './permissions';
export { getSortIcon, getViewIcon } from './icon';
export {
favoriteMeme,
shareMeme,
copyMeme,
editMeme,
deleteMeme,
} from './meme';
export { isPermissionForPath, clearPermissions } from './permissions';
export { deleteTag } from './tag';
export {
type StringValidationResult,
validateMemeTitle,

51
src/utilities/meme.ts Normal file
View File

@@ -0,0 +1,51 @@
import { NavigationProp } from '@react-navigation/native';
import { Dirs, FileSystem } from 'react-native-file-access';
import { extension } from 'react-native-mime-types';
import Share from 'react-native-share';
import Clipboard from '@react-native-clipboard/clipboard';
import { Meme } from '../database';
import { ROUTE, RootStackParamList } from '../types';
const favoriteMeme = (realm: Realm, meme: Meme) => {
realm.write(() => {
meme.isFavorite = !meme.isFavorite;
});
};
const shareMeme = async (meme: Meme) => {
const fileExtension = extension(meme.mimeType) as string;
const cacheUri = `${Dirs.CacheDir}/${meme.id.toHexString()}.${fileExtension}`;
await FileSystem.cp(meme.uri, cacheUri);
await Share.open({
url: `file://${cacheUri}`,
type: meme.mimeType,
failOnCancel: false,
});
};
const copyMeme = (meme: Meme) => {
Clipboard.setURI(meme.uri);
};
const editMeme = (
navigation: NavigationProp<RootStackParamList>,
meme: Meme,
) => {
navigation.navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() });
};
const deleteMeme = async (realm: Realm, meme: Meme) => {
await FileSystem.unlink(meme.uri);
realm.write(() => {
for (const tag of meme.tags) {
tag.dateModified = new Date();
tag.memes.slice(tag.memes.indexOf(meme), 1);
tag.memesLength -= 1;
}
realm.delete(meme);
});
};
export { favoriteMeme, shareMeme, copyMeme, editMeme, deleteMeme };

16
src/utilities/tag.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Realm } from '@realm/react';
import { Tag } from '../database';
const deleteTag = (realm: Realm, tag: Tag) => {
realm.write(() => {
for (const meme of tag.memes) {
meme.dateModified = new Date();
meme.tags.slice(meme.tags.indexOf(tag), 1);
meme.tagsLength -= 1;
}
realm.delete(tag);
});
};
export { deleteTag };