Add variable storage locations & batch adding
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
@@ -79,7 +79,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "0.0.3"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@karaolidis/terminally-online",
|
"name": "@karaolidis/terminally-online",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@karaolidis/terminally-online",
|
"name": "@karaolidis/terminally-online",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bankify/redux-persist-realm": "^0.1.3",
|
"@bankify/redux-persist-realm": "^0.1.3",
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@karaolidis/terminally-online",
|
"name": "@karaolidis/terminally-online",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
22
src/app.tsx
22
src/app.tsx
@@ -54,13 +54,13 @@ const App = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaperProvider theme={theme}>
|
<ReduxProvider store={store}>
|
||||||
<ReduxProvider store={store}>
|
<RealmProvider schema={[Meme, Tag]}>
|
||||||
<PersistGate
|
<PaperProvider theme={theme}>
|
||||||
loading={<LoadingView />}
|
<PersistGate
|
||||||
persistor={persistor}
|
loading={<LoadingView />}
|
||||||
onBeforeLift={onBeforeLift}>
|
persistor={persistor}
|
||||||
<RealmProvider schema={[Meme, Tag]}>
|
onBeforeLift={onBeforeLift}>
|
||||||
<GestureHandlerRootView style={appStyles.gestureHandler}>
|
<GestureHandlerRootView style={appStyles.gestureHandler}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
@@ -74,10 +74,10 @@ const App = () => {
|
|||||||
)}
|
)}
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</RealmProvider>
|
</PersistGate>
|
||||||
</PersistGate>
|
</PaperProvider>
|
||||||
</ReduxProvider>
|
</RealmProvider>
|
||||||
</PaperProvider>
|
</ReduxProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -3,10 +3,10 @@ import { Keyboard, StyleSheet } from 'react-native';
|
|||||||
import { FAB } from 'react-native-paper';
|
import { FAB } from 'react-native-paper';
|
||||||
import { ParamListBase, useNavigation } from '@react-navigation/native';
|
import { ParamListBase, useNavigation } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { pickSingle } from 'react-native-document-picker';
|
import { pick } from 'react-native-document-picker';
|
||||||
|
import { useDeviceOrientation } from '@react-native-community/hooks';
|
||||||
import { ROUTE } from '../types';
|
import { ROUTE } from '../types';
|
||||||
import { allowedMimeTypes, noOp } from '../utilities';
|
import { allowedMimeTypes, noOp } from '../utilities';
|
||||||
import { useDeviceOrientation } from '@react-native-community/hooks';
|
|
||||||
|
|
||||||
const floatingActionButtonStyles = StyleSheet.create({
|
const floatingActionButtonStyles = StyleSheet.create({
|
||||||
fab: {
|
fab: {
|
||||||
@@ -65,9 +65,12 @@ const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => {
|
|||||||
onStateChange={({ open }) => setState(open)}
|
onStateChange={({ open }) => setState(open)}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
|
const files = await pick({
|
||||||
if (!file) return;
|
type: allowedMimeTypes,
|
||||||
navigate(ROUTE.ADD_MEME, { file });
|
allowMultiSelection: true,
|
||||||
|
}).catch(noOp);
|
||||||
|
if (!files) return;
|
||||||
|
navigate(ROUTE.ADD_MEME, { files });
|
||||||
}}
|
}}
|
||||||
style={
|
style={
|
||||||
orientation === 'portrait'
|
orientation === 'portrait'
|
||||||
|
@@ -10,3 +10,4 @@ export { default as FloatingActionButton } from './floatingActionButton';
|
|||||||
export { default as HideableBottomNavigationBar } from './hideableBottomNavigationBar';
|
export { default as HideableBottomNavigationBar } from './hideableBottomNavigationBar';
|
||||||
export { default as HideableHeader } from './hideableHeader';
|
export { default as HideableHeader } from './hideableHeader';
|
||||||
export { default as LoadingView } from './loadingView';
|
export { default as LoadingView } from './loadingView';
|
||||||
|
export { default as storageLocationChangeDialog } from './storageLocationChangeDialog';
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { HelperText, TextInput } from 'react-native-paper';
|
import { HelperText, Text, TextInput, useTheme } from 'react-native-paper';
|
||||||
import { Image } from 'react-native';
|
import { Image } from 'react-native';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { useImageDimensions } from '@react-native-community/hooks/lib/useImageDimensions';
|
import { useImageDimensions } from '@react-native-community/hooks/lib/useImageDimensions';
|
||||||
@@ -13,6 +13,10 @@ const memeEditorStyles = {
|
|||||||
marginBottom: 15,
|
marginBottom: 15,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
},
|
},
|
||||||
|
uri: {
|
||||||
|
marginBottom: 15,
|
||||||
|
marginHorizontal: 5,
|
||||||
|
},
|
||||||
memeTagSelector: {
|
memeTagSelector: {
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
@@ -23,27 +27,30 @@ const memeEditorStyles = {
|
|||||||
|
|
||||||
const MemeEditor = ({
|
const MemeEditor = ({
|
||||||
memeUri,
|
memeUri,
|
||||||
memeUriError,
|
memeFilename,
|
||||||
setMemeUriError,
|
memeError,
|
||||||
|
setMemeError,
|
||||||
memeTitle,
|
memeTitle,
|
||||||
setMemeTitle,
|
setMemeTitle,
|
||||||
memeTags,
|
memeTags,
|
||||||
setMemeTags,
|
setMemeTags,
|
||||||
}: {
|
}: {
|
||||||
memeUri: string;
|
memeUri: string;
|
||||||
memeUriError: Error | undefined;
|
memeFilename?: string;
|
||||||
setMemeUriError: (error: Error | undefined) => void;
|
memeError: Error | undefined;
|
||||||
|
setMemeError: (error: Error | undefined) => void;
|
||||||
memeTitle: StringValidationResult;
|
memeTitle: StringValidationResult;
|
||||||
setMemeTitle: (name: StringValidationResult) => void;
|
setMemeTitle: (name: StringValidationResult) => void;
|
||||||
memeTags: Map<string, Tag>;
|
memeTags: Map<string, Tag>;
|
||||||
setMemeTags: (tags: Map<string, Tag>) => void;
|
setMemeTags: (tags: Map<string, Tag>) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { width } = useSafeAreaFrame();
|
const { width } = useSafeAreaFrame();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
const { dimensions, loading, error } = useImageDimensions({ uri: memeUri });
|
const { dimensions, loading, error } = useImageDimensions({ uri: memeUri });
|
||||||
useEffect(() => setMemeUriError(error), [error, setMemeUriError]);
|
useEffect(() => setMemeError(error), [error, setMemeError]);
|
||||||
|
|
||||||
if (!memeUriError && (loading || !dimensions)) return <LoadingView />;
|
if (!memeError && (loading || !dimensions)) return <LoadingView />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -58,7 +65,7 @@ const MemeEditor = ({
|
|||||||
<HelperText type="error" visible={!memeTitle.valid}>
|
<HelperText type="error" visible={!memeTitle.valid}>
|
||||||
{memeTitle.error}
|
{memeTitle.error}
|
||||||
</HelperText>
|
</HelperText>
|
||||||
{memeUriError || !dimensions ? (
|
{memeError || !dimensions ? (
|
||||||
<MemeFail
|
<MemeFail
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
@@ -88,6 +95,12 @@ const MemeEditor = ({
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Text
|
||||||
|
variant="bodySmall"
|
||||||
|
style={[memeEditorStyles.uri, { color: colors.onSurfaceDisabled }]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{memeFilename}
|
||||||
|
</Text>
|
||||||
<MemeTagSelector
|
<MemeTagSelector
|
||||||
memeTags={memeTags}
|
memeTags={memeTags}
|
||||||
setMemeTags={setMemeTags}
|
setMemeTags={setMemeTags}
|
||||||
|
@@ -3,9 +3,12 @@ import { ImageZoom } from '@likashefqet/react-native-image-zoom';
|
|||||||
import { StyleSheet, View } from 'react-native';
|
import { StyleSheet, View } from 'react-native';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { useImageDimensions } from '@react-native-community/hooks';
|
import { useImageDimensions } from '@react-native-community/hooks';
|
||||||
|
import { AndroidScoped } from 'react-native-file-access';
|
||||||
import LoadingView from '../loadingView';
|
import LoadingView from '../loadingView';
|
||||||
import { Meme } from '../../database';
|
import { Meme } from '../../database';
|
||||||
import MemeFail from './memeFail';
|
import MemeFail from './memeFail';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { RootState } from '../../state';
|
||||||
|
|
||||||
const memeViewItemStyles = StyleSheet.create({
|
const memeViewItemStyles = StyleSheet.create({
|
||||||
view: {
|
view: {
|
||||||
@@ -16,8 +19,14 @@ const memeViewItemStyles = StyleSheet.create({
|
|||||||
|
|
||||||
const MemeViewItem = ({ meme }: { meme: Meme }) => {
|
const MemeViewItem = ({ meme }: { meme: Meme }) => {
|
||||||
const { height, width } = useSafeAreaFrame();
|
const { height, width } = useSafeAreaFrame();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const storageUri = useSelector(
|
||||||
|
(state: RootState) => state.settings.storageUri,
|
||||||
|
)!;
|
||||||
|
|
||||||
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
|
const { dimensions, loading, error } = useImageDimensions({ uri });
|
||||||
|
|
||||||
if (!error && (loading || !dimensions)) {
|
if (!error && (loading || !dimensions)) {
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +48,7 @@ const MemeViewItem = ({ meme }: { meme: Meme }) => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ImageZoom
|
<ImageZoom
|
||||||
source={{ uri: meme.uri }}
|
source={{ uri }}
|
||||||
style={
|
style={
|
||||||
dimensions.aspectRatio > width / (height - 128)
|
dimensions.aspectRatio > width / (height - 128)
|
||||||
? {
|
? {
|
||||||
|
@@ -3,6 +3,7 @@ import { Image, TouchableHighlight } from 'react-native';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { useImageDimensions } from '@react-native-community/hooks';
|
import { useImageDimensions } from '@react-native-community/hooks';
|
||||||
|
import { AndroidScoped } from 'react-native-file-access';
|
||||||
import { Meme } from '../../../database';
|
import { Meme } from '../../../database';
|
||||||
import { RootState } from '../../../state';
|
import { RootState } from '../../../state';
|
||||||
import { MemeFail } from '..';
|
import { MemeFail } from '..';
|
||||||
@@ -21,8 +22,14 @@ const MemesGridItem = ({
|
|||||||
const gridColumns = useSelector(
|
const gridColumns = useSelector(
|
||||||
(state: RootState) => state.settings.gridColumns,
|
(state: RootState) => state.settings.gridColumns,
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const storageUri = useSelector(
|
||||||
|
(state: RootState) => state.settings.storageUri,
|
||||||
|
)!;
|
||||||
|
|
||||||
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
|
const { dimensions, loading, error } = useImageDimensions({ uri });
|
||||||
|
|
||||||
if (!error && (loading || !dimensions)) return <></>;
|
if (!error && (loading || !dimensions)) return <></>;
|
||||||
|
|
||||||
@@ -38,7 +45,7 @@ const MemesGridItem = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: meme.uri }}
|
source={{ uri }}
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
width: (width * 0.92 - 5) / gridColumns,
|
width: (width * 0.92 - 5) / gridColumns,
|
||||||
|
@@ -3,8 +3,11 @@ import { Image, StyleSheet, View } from 'react-native';
|
|||||||
import { Text, TouchableRipple } from 'react-native-paper';
|
import { Text, TouchableRipple } from 'react-native-paper';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { useImageDimensions } from '@react-native-community/hooks';
|
import { useImageDimensions } from '@react-native-community/hooks';
|
||||||
|
import { AndroidScoped } from 'react-native-file-access';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import { Meme } from '../../../database';
|
import { Meme } from '../../../database';
|
||||||
import { MemeFail } from '..';
|
import { MemeFail } from '..';
|
||||||
|
import { RootState } from '../../../state';
|
||||||
|
|
||||||
const memesListItemStyles = StyleSheet.create({
|
const memesListItemStyles = StyleSheet.create({
|
||||||
view: {
|
view: {
|
||||||
@@ -40,8 +43,14 @@ const MemesListItem = ({
|
|||||||
focusMeme: (index: number) => void;
|
focusMeme: (index: number) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { width } = useSafeAreaFrame();
|
const { width } = useSafeAreaFrame();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const storageUri = useSelector(
|
||||||
|
(state: RootState) => state.settings.storageUri,
|
||||||
|
)!;
|
||||||
|
|
||||||
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
|
const { dimensions, loading, error } = useImageDimensions({ uri });
|
||||||
|
|
||||||
if (!error && (loading || !dimensions)) return <></>;
|
if (!error && (loading || !dimensions)) return <></>;
|
||||||
|
|
||||||
@@ -53,7 +62,7 @@ const MemesListItem = ({
|
|||||||
{error ? (
|
{error ? (
|
||||||
<MemeFail style={memesListItemStyles.image} />
|
<MemeFail style={memesListItemStyles.image} />
|
||||||
) : (
|
) : (
|
||||||
<Image source={{ uri: meme.uri }} style={memesListItemStyles.image} />
|
<Image source={{ uri }} style={memesListItemStyles.image} />
|
||||||
)}
|
)}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@@ -2,9 +2,10 @@ import React from 'react';
|
|||||||
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
|
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
|
import { AndroidScoped } from 'react-native-file-access';
|
||||||
|
import { useImageDimensions } from '@react-native-community/hooks';
|
||||||
import { Meme } from '../../../database';
|
import { Meme } from '../../../database';
|
||||||
import { RootState } from '../../../state';
|
import { RootState } from '../../../state';
|
||||||
import { useImageDimensions } from '@react-native-community/hooks';
|
|
||||||
import { MemeFail } from '..';
|
import { MemeFail } from '..';
|
||||||
import { getFontAwesome5IconSize } from '../../../utilities';
|
import { getFontAwesome5IconSize } from '../../../utilities';
|
||||||
|
|
||||||
@@ -31,8 +32,14 @@ const MemesMasonryItem = ({
|
|||||||
const masonryColumns = useSelector(
|
const masonryColumns = useSelector(
|
||||||
(state: RootState) => state.settings.masonryColumns,
|
(state: RootState) => state.settings.masonryColumns,
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const storageUri = useSelector(
|
||||||
|
(state: RootState) => state.settings.storageUri,
|
||||||
|
)!;
|
||||||
|
|
||||||
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
|
const { dimensions, loading, error } = useImageDimensions({ uri });
|
||||||
|
|
||||||
if (!error && (loading || !dimensions)) return <></>;
|
if (!error && (loading || !dimensions)) return <></>;
|
||||||
|
|
||||||
@@ -53,7 +60,7 @@ const MemesMasonryItem = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: meme.uri }}
|
source={{ uri }}
|
||||||
style={[
|
style={[
|
||||||
memeMasonryItemStyles.image,
|
memeMasonryItemStyles.image,
|
||||||
{
|
{
|
||||||
|
100
src/components/storageLocationChangeDialog.tsx
Normal file
100
src/components/storageLocationChangeDialog.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import { Dialog, ProgressBar, Text } from 'react-native-paper';
|
||||||
|
import { openDocumentTree } from 'react-native-scoped-storage';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { AndroidScoped, FileSystem } from 'react-native-file-access';
|
||||||
|
import { RootState, setStorageUri } from '../state';
|
||||||
|
import { clearPermissions, isPermissionForPath, noOp } from '../utilities';
|
||||||
|
|
||||||
|
const storageLocationChangeDialogStyles = StyleSheet.create({
|
||||||
|
progressBar: {
|
||||||
|
marginVertical: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const StorageLocationChangeDialog = ({
|
||||||
|
visible,
|
||||||
|
setVisible,
|
||||||
|
setSnackbarVisible,
|
||||||
|
setSnackbarMessage,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
setSnackbarVisible: (visible: boolean) => void;
|
||||||
|
setSnackbarMessage: (message: string) => void;
|
||||||
|
}) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const storageUri = useSelector(
|
||||||
|
(state: RootState) => state.settings.storageUri,
|
||||||
|
)!;
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectNewStorageUri = async () => {
|
||||||
|
const uri = await openDocumentTree(true).catch(noOp);
|
||||||
|
|
||||||
|
if (!uri) {
|
||||||
|
setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStorageUri = uri.uri;
|
||||||
|
|
||||||
|
if (isPermissionForPath(storageUri, newStorageUri)) {
|
||||||
|
setSnackbarMessage('Folder already selected.');
|
||||||
|
setSnackbarVisible(true);
|
||||||
|
setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await FileSystem.ls(storageUri);
|
||||||
|
let filesCopied = 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
files.map(async file => {
|
||||||
|
const oldUri = AndroidScoped.appendPath(storageUri, file);
|
||||||
|
const newUri = AndroidScoped.appendPath(newStorageUri, file);
|
||||||
|
|
||||||
|
// You may be wondering, why cp and unlink instead of mv?
|
||||||
|
// That's because Android is a fuck and does not allow moving across different scoped storage paths.
|
||||||
|
await FileSystem.cp(oldUri, newUri).catch(noOp);
|
||||||
|
await FileSystem.unlink(oldUri).catch(noOp);
|
||||||
|
|
||||||
|
filesCopied++;
|
||||||
|
setProgress(filesCopied / files.length);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await dispatch(setStorageUri(newStorageUri));
|
||||||
|
await clearPermissions([newStorageUri]);
|
||||||
|
|
||||||
|
setVisible(false);
|
||||||
|
setProgress(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (visible) void selectNewStorageUri();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
visible={visible}
|
||||||
|
onDismiss={() => setVisible(false)}
|
||||||
|
dismissable={false}
|
||||||
|
dismissableBackButton={false}>
|
||||||
|
<Dialog.Title>Change Storage Location</Dialog.Title>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Text>Copying files. Do not close the app.</Text>
|
||||||
|
<ProgressBar
|
||||||
|
animatedValue={progress}
|
||||||
|
style={storageLocationChangeDialogStyles.progressBar}
|
||||||
|
/>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StorageLocationChangeDialog;
|
@@ -21,7 +21,7 @@ const memeTypePlural = {
|
|||||||
class Meme extends Object<Meme> {
|
class Meme extends Object<Meme> {
|
||||||
id!: BSON.UUID;
|
id!: BSON.UUID;
|
||||||
type!: MEME_TYPE;
|
type!: MEME_TYPE;
|
||||||
uri!: string;
|
filename!: string;
|
||||||
mimeType!: string;
|
mimeType!: string;
|
||||||
size!: number;
|
size!: number;
|
||||||
title!: string;
|
title!: string;
|
||||||
@@ -39,7 +39,7 @@ class Meme extends Object<Meme> {
|
|||||||
properties: {
|
properties: {
|
||||||
id: { type: 'uuid', default: () => new BSON.UUID() },
|
id: { type: 'uuid', default: () => new BSON.UUID() },
|
||||||
type: { type: 'string', indexed: true },
|
type: { type: 'string', indexed: true },
|
||||||
uri: 'string',
|
filename: 'string',
|
||||||
mimeType: 'string',
|
mimeType: 'string',
|
||||||
size: 'int',
|
size: 'int',
|
||||||
title: 'string',
|
title: 'string',
|
||||||
|
@@ -9,10 +9,7 @@ import { AndroidScoped, FileSystem } from 'react-native-file-access';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { extension } from 'react-native-mime-types';
|
import { extension } from 'react-native-mime-types';
|
||||||
import { useDeviceOrientation } from '@react-native-community/hooks';
|
import { useDeviceOrientation } from '@react-native-community/hooks';
|
||||||
import {
|
import { DocumentPickerResponse, pick } from 'react-native-document-picker';
|
||||||
DocumentPickerResponse,
|
|
||||||
pickSingle,
|
|
||||||
} from 'react-native-document-picker';
|
|
||||||
import { ROUTE, RootStackParamList } from '../../types';
|
import { ROUTE, RootStackParamList } from '../../types';
|
||||||
import { Meme, Tag } from '../../database';
|
import { Meme, Tag } from '../../database';
|
||||||
import { RootState } from '../../state';
|
import { RootState } from '../../state';
|
||||||
@@ -36,19 +33,24 @@ const AddMeme = ({
|
|||||||
(state: RootState) => state.settings.storageUri,
|
(state: RootState) => state.settings.storageUri,
|
||||||
)!;
|
)!;
|
||||||
|
|
||||||
const file = useRef(route.params.file);
|
const [index, setIndex] = useState(0);
|
||||||
|
const files = useRef(route.params.files);
|
||||||
|
const file = useRef(files.current[index]);
|
||||||
|
const isLastFile = index === files.current.length - 1;
|
||||||
|
|
||||||
const [memeUri, setMemeUri] = useState(file.current.uri);
|
const [memeUri, setMemeUri] = useState(file.current.uri);
|
||||||
const [memeUriError, setMemeUriError] = useState<Error>();
|
const [memeFilename, setMemeFilename] = useState(
|
||||||
|
file.current.name ?? undefined,
|
||||||
|
);
|
||||||
|
const [memeError, setMemeError] = useState<Error>();
|
||||||
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
|
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
|
||||||
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
|
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
|
||||||
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
|
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
|
const [isSavingAndAddingMore, setIsSavingAndAddingMore] = useState(false);
|
||||||
useState(false);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const saveMeme = useCallback(async () => {
|
||||||
const uuid = new BSON.UUID();
|
const uuid = new BSON.UUID();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const mimeType = file.current.type!;
|
const mimeType = file.current.type!;
|
||||||
@@ -58,10 +60,10 @@ const AddMeme = ({
|
|||||||
const fileExtension = extension(mimeType) as string;
|
const fileExtension = extension(mimeType) as string;
|
||||||
if (!fileExtension) goBack();
|
if (!fileExtension) goBack();
|
||||||
|
|
||||||
const uri = AndroidScoped.appendPath(
|
const filename = `${uuid.toHexString()}-${Math.round(
|
||||||
storageUri,
|
Date.now() / 1000,
|
||||||
`${uuid.toHexString()}-${Math.round(Date.now() / 1000)}.${fileExtension}`,
|
)}.${fileExtension}`;
|
||||||
);
|
const uri = AndroidScoped.appendPath(storageUri, filename);
|
||||||
|
|
||||||
await FileSystem.cp(file.current.uri, uri);
|
await FileSystem.cp(file.current.uri, uri);
|
||||||
const { size } = await FileSystem.stat(uri);
|
const { size } = await FileSystem.stat(uri);
|
||||||
@@ -70,7 +72,7 @@ const AddMeme = ({
|
|||||||
const meme: Meme | undefined = realm.create<Meme>(Meme.schema.name, {
|
const meme: Meme | undefined = realm.create<Meme>(Meme.schema.name, {
|
||||||
id: uuid,
|
id: uuid,
|
||||||
type: memeType,
|
type: memeType,
|
||||||
uri,
|
filename,
|
||||||
mimeType,
|
mimeType,
|
||||||
size,
|
size,
|
||||||
title: memeTitle.parsed,
|
title: memeTitle.parsed,
|
||||||
@@ -87,6 +89,47 @@ const AddMeme = ({
|
|||||||
});
|
});
|
||||||
}, [goBack, memeIsFavorite, memeTags, memeTitle.parsed, realm, storageUri]);
|
}, [goBack, memeIsFavorite, memeTags, memeTitle.parsed, realm, storageUri]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
await saveMeme();
|
||||||
|
setIsSaving(false);
|
||||||
|
goBack();
|
||||||
|
}, [goBack, saveMeme]);
|
||||||
|
|
||||||
|
const handleSaveAndNext = useCallback(async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
await saveMeme();
|
||||||
|
setIsSaving(false);
|
||||||
|
|
||||||
|
setIndex(index + 1);
|
||||||
|
file.current = files.current[index + 1];
|
||||||
|
|
||||||
|
setMemeUri(file.current.uri);
|
||||||
|
setMemeFilename(file.current.name ?? undefined);
|
||||||
|
setMemeTitle(validateMemeTitle('New Meme'));
|
||||||
|
setMemeIsFavorite(false);
|
||||||
|
setMemeTags(new Map<string, Tag>());
|
||||||
|
}, [index, saveMeme]);
|
||||||
|
|
||||||
|
const handleSaveAndAddMore = useCallback(async () => {
|
||||||
|
setIsSavingAndAddingMore(true);
|
||||||
|
await saveMeme();
|
||||||
|
setIsSavingAndAddingMore(false);
|
||||||
|
|
||||||
|
setIndex(0);
|
||||||
|
files.current = (await pick({
|
||||||
|
type: allowedMimeTypes,
|
||||||
|
allowMultiSelection: true,
|
||||||
|
}).catch(goBack)) as DocumentPickerResponse[];
|
||||||
|
file.current = files.current[0];
|
||||||
|
|
||||||
|
setMemeUri(file.current.uri);
|
||||||
|
setMemeFilename(file.current.name ?? undefined);
|
||||||
|
setMemeTitle(validateMemeTitle('New Meme'));
|
||||||
|
setMemeIsFavorite(false);
|
||||||
|
setMemeTags(new Map<string, Tag>());
|
||||||
|
}, [goBack, saveMeme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Appbar.Header>
|
<Appbar.Header>
|
||||||
@@ -98,7 +141,7 @@ const AddMeme = ({
|
|||||||
/>
|
/>
|
||||||
</Appbar.Header>
|
</Appbar.Header>
|
||||||
<Banner
|
<Banner
|
||||||
visible={!!memeUriError}
|
visible={!!memeError}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
label: 'Cancel',
|
label: 'Cancel',
|
||||||
@@ -119,8 +162,9 @@ const AddMeme = ({
|
|||||||
<View style={editorStyles.editorView}>
|
<View style={editorStyles.editorView}>
|
||||||
<MemeEditor
|
<MemeEditor
|
||||||
memeUri={memeUri}
|
memeUri={memeUri}
|
||||||
memeUriError={memeUriError}
|
memeFilename={memeFilename}
|
||||||
setMemeUriError={setMemeUriError}
|
memeError={memeError}
|
||||||
|
setMemeError={setMemeError}
|
||||||
memeTitle={memeTitle}
|
memeTitle={memeTitle}
|
||||||
setMemeTitle={setMemeTitle}
|
setMemeTitle={setMemeTitle}
|
||||||
memeTags={memeTags}
|
memeTags={memeTags}
|
||||||
@@ -131,46 +175,31 @@ const AddMeme = ({
|
|||||||
<Button
|
<Button
|
||||||
mode="contained-tonal"
|
mode="contained-tonal"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
onPress={async () => {
|
onPress={handleSaveAndAddMore}
|
||||||
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={
|
disabled={
|
||||||
!memeTitle.valid ||
|
!memeTitle.valid ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
isSavingAndAddingAnother ||
|
isSavingAndAddingMore ||
|
||||||
!!memeUriError
|
!!memeError ||
|
||||||
|
!isLastFile
|
||||||
}
|
}
|
||||||
loading={isSavingAndAddingAnother}
|
loading={isSavingAndAddingMore}
|
||||||
style={editorStyles.saveAndAddButton}>
|
style={editorStyles.saveAndAddButton}>
|
||||||
Save & Add
|
Save & Add More
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
mode="contained"
|
mode="contained"
|
||||||
icon="floppy"
|
icon="floppy"
|
||||||
onPress={async () => {
|
onPress={isLastFile ? handleSave : handleSaveAndNext}
|
||||||
setIsSaving(true);
|
|
||||||
await handleSave();
|
|
||||||
setIsSaving(false);
|
|
||||||
goBack();
|
|
||||||
}}
|
|
||||||
disabled={
|
disabled={
|
||||||
!memeTitle.valid ||
|
!memeTitle.valid ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
isSavingAndAddingAnother ||
|
isSavingAndAddingMore ||
|
||||||
!!memeUriError
|
!!memeError
|
||||||
}
|
}
|
||||||
loading={isSaving}
|
loading={isSaving}
|
||||||
style={editorStyles.saveButton}>
|
style={editorStyles.saveButton}>
|
||||||
Save
|
{isLastFile ? 'Save' : 'Save & Next'}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
@@ -26,10 +26,9 @@ const AddTag = () => {
|
|||||||
|
|
||||||
// Although saving tags is instantaneous, we still want to show a loading
|
// Although saving tags is instantaneous, we still want to show a loading
|
||||||
// indicator to prevent the user from spamming the save button.
|
// indicator to prevent the user from spamming the save button.
|
||||||
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
|
const [isSavingAndAddingMore, setIsSavingAndAddingMore] = useState(false);
|
||||||
useState(false);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const saveTag = useCallback(() => {
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
realm.create(Tag.schema.name, {
|
realm.create(Tag.schema.name, {
|
||||||
name: tagName.parsed,
|
name: tagName.parsed,
|
||||||
@@ -38,6 +37,19 @@ const AddTag = () => {
|
|||||||
});
|
});
|
||||||
}, [realm, tagColor.parsed, tagName.parsed]);
|
}, [realm, tagColor.parsed, tagName.parsed]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
saveTag();
|
||||||
|
goBack();
|
||||||
|
}, [goBack, saveTag]);
|
||||||
|
|
||||||
|
const handleSaveAndAddMore = useCallback(() => {
|
||||||
|
setIsSavingAndAddingMore(true);
|
||||||
|
saveTag();
|
||||||
|
setTimeout(() => setIsSavingAndAddingMore(false), 250);
|
||||||
|
setTagName(validateTagName('newTag'));
|
||||||
|
setTagColor(validateColor(generateRandomColor()));
|
||||||
|
}, [saveTag]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Appbar.Header>
|
<Appbar.Header>
|
||||||
@@ -64,26 +76,17 @@ const AddTag = () => {
|
|||||||
<Button
|
<Button
|
||||||
mode="contained-tonal"
|
mode="contained-tonal"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
onPress={() => {
|
onPress={handleSaveAndAddMore}
|
||||||
setIsSavingAndAddingAnother(true);
|
disabled={!tagName.valid || isSavingAndAddingMore}
|
||||||
handleSave();
|
loading={isSavingAndAddingMore}
|
||||||
setTimeout(() => setIsSavingAndAddingAnother(false), 250);
|
|
||||||
setTagName(validateTagName('newTag'));
|
|
||||||
setTagColor(validateColor(generateRandomColor()));
|
|
||||||
}}
|
|
||||||
disabled={!tagName.valid || isSavingAndAddingAnother}
|
|
||||||
loading={isSavingAndAddingAnother}
|
|
||||||
style={editorStyles.saveAndAddButton}>
|
style={editorStyles.saveAndAddButton}>
|
||||||
Save & Add
|
Save & Add
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
mode="contained"
|
mode="contained"
|
||||||
icon="floppy"
|
icon="floppy"
|
||||||
onPress={() => {
|
onPress={handleSave}
|
||||||
handleSave();
|
disabled={!tagName.valid || isSavingAndAddingMore}
|
||||||
goBack();
|
|
||||||
}}
|
|
||||||
disabled={!tagName.valid || isSavingAndAddingAnother}
|
|
||||||
style={editorStyles.saveButton}>
|
style={editorStyles.saveButton}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
@@ -9,6 +9,8 @@ import { BSON } from 'realm';
|
|||||||
import { RootStackParamList, ROUTE } from '../../types';
|
import { RootStackParamList, ROUTE } from '../../types';
|
||||||
import { pickSingle } from 'react-native-document-picker';
|
import { pickSingle } from 'react-native-document-picker';
|
||||||
import { AndroidScoped, FileSystem } from 'react-native-file-access';
|
import { AndroidScoped, FileSystem } from 'react-native-file-access';
|
||||||
|
import { extension } from 'react-native-mime-types';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import { Tag, Meme } from '../../database';
|
import { Tag, Meme } from '../../database';
|
||||||
import {
|
import {
|
||||||
StringValidationResult,
|
StringValidationResult,
|
||||||
@@ -21,8 +23,6 @@ import {
|
|||||||
} from '../../utilities';
|
} from '../../utilities';
|
||||||
import { MemeEditor } from '../../components';
|
import { MemeEditor } from '../../components';
|
||||||
import editorStyles from './editorStyles';
|
import editorStyles from './editorStyles';
|
||||||
import { extension } from 'react-native-mime-types';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { RootState } from '../../state';
|
import { RootState } from '../../state';
|
||||||
|
|
||||||
const EditMeme = ({
|
const EditMeme = ({
|
||||||
@@ -42,10 +42,11 @@ const EditMeme = ({
|
|||||||
Meme.schema.name,
|
Meme.schema.name,
|
||||||
BSON.UUID.createFromHexString(route.params.id),
|
BSON.UUID.createFromHexString(route.params.id),
|
||||||
)!;
|
)!;
|
||||||
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
const [memeUriError, setMemeUriError] = useState<Error>();
|
const [memeError, setMemeError] = useState<Error>();
|
||||||
const [memeTitle, setMemeTitle] = useState(validateMemeTitle(meme.title));
|
const [memeTitle, setMemeTitle] = useState(validateMemeTitle(meme.title));
|
||||||
const [memeTags, setMemeTags] = useState(
|
const [memeTags, setMemeTags] = useState(
|
||||||
new Map<string, Tag>(meme.tags.map(tag => [tag.id.toHexString(), tag])),
|
new Map<string, Tag>(meme.tags.map(tag => [tag.id.toHexString(), tag])),
|
||||||
@@ -64,6 +65,8 @@ const EditMeme = ({
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
meme.tags.forEach(tag => {
|
meme.tags.forEach(tag => {
|
||||||
if (!memeTags.has(tag.id.toHexString())) {
|
if (!memeTags.has(tag.id.toHexString())) {
|
||||||
@@ -87,7 +90,17 @@ const EditMeme = ({
|
|||||||
meme.tagsLength = memeTags.size;
|
meme.tagsLength = memeTags.size;
|
||||||
meme.dateModified = new Date();
|
meme.dateModified = new Date();
|
||||||
});
|
});
|
||||||
}, [meme, memeTags, memeTitle.parsed, realm]);
|
|
||||||
|
setIsSaving(false);
|
||||||
|
goBack();
|
||||||
|
}, [goBack, meme, memeTags, memeTitle.parsed, realm]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
await deleteMeme(realm, storageUri, meme);
|
||||||
|
setIsSaving(false);
|
||||||
|
goBack();
|
||||||
|
}, [goBack, meme, realm, storageUri]);
|
||||||
|
|
||||||
const handleFixUri = useCallback(async () => {
|
const handleFixUri = useCallback(async () => {
|
||||||
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
|
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
|
||||||
@@ -101,16 +114,16 @@ const EditMeme = ({
|
|||||||
const fileExtension = extension(mimeType) as string;
|
const fileExtension = extension(mimeType) as string;
|
||||||
if (!fileExtension) return;
|
if (!fileExtension) return;
|
||||||
|
|
||||||
const uri = AndroidScoped.appendPath(
|
const filename = `${meme.id.toHexString()}-${
|
||||||
storageUri,
|
Date.now() / 1000
|
||||||
`${meme.id.toHexString()}-${Date.now() / 1000}.${fileExtension}`,
|
}.${fileExtension}`;
|
||||||
);
|
const newUri = AndroidScoped.appendPath(storageUri, filename);
|
||||||
|
|
||||||
await FileSystem.cp(file.uri, uri);
|
await FileSystem.cp(file.uri, newUri);
|
||||||
const { size } = await FileSystem.stat(uri);
|
const { size } = await FileSystem.stat(newUri);
|
||||||
|
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
meme.uri = uri;
|
meme.filename = filename;
|
||||||
meme.type = memeType;
|
meme.type = memeType;
|
||||||
meme.mimeType = mimeType;
|
meme.mimeType = mimeType;
|
||||||
meme.size = size;
|
meme.size = size;
|
||||||
@@ -126,18 +139,10 @@ const EditMeme = ({
|
|||||||
icon={meme.isFavorite ? 'heart' : 'heart-outline'}
|
icon={meme.isFavorite ? 'heart' : 'heart-outline'}
|
||||||
onPress={() => favoriteMeme(realm, meme)}
|
onPress={() => favoriteMeme(realm, meme)}
|
||||||
/>
|
/>
|
||||||
<Appbar.Action
|
<Appbar.Action icon="delete" onPress={handleDelete} />
|
||||||
icon="delete"
|
|
||||||
onPress={async () => {
|
|
||||||
setIsSaving(true);
|
|
||||||
await deleteMeme(realm, meme);
|
|
||||||
setIsSaving(false);
|
|
||||||
goBack();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Appbar.Header>
|
</Appbar.Header>
|
||||||
<Banner
|
<Banner
|
||||||
visible={!!memeUriError}
|
visible={!!memeError}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
label: 'Fix URI',
|
label: 'Fix URI',
|
||||||
@@ -145,12 +150,7 @@ const EditMeme = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete Meme',
|
label: 'Delete Meme',
|
||||||
onPress: async () => {
|
onPress: handleDelete,
|
||||||
setIsSaving(true);
|
|
||||||
await deleteMeme(realm, meme);
|
|
||||||
setIsSaving(false);
|
|
||||||
goBack();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
The URI for this meme appears to be broken. This may have been caused by
|
The URI for this meme appears to be broken. This may have been caused by
|
||||||
@@ -166,9 +166,10 @@ const EditMeme = ({
|
|||||||
]}>
|
]}>
|
||||||
<View style={editorStyles.editorView}>
|
<View style={editorStyles.editorView}>
|
||||||
<MemeEditor
|
<MemeEditor
|
||||||
memeUri={meme.uri}
|
memeUri={uri}
|
||||||
memeUriError={memeUriError}
|
memeFilename={meme.filename}
|
||||||
setMemeUriError={setMemeUriError}
|
memeError={memeError}
|
||||||
|
setMemeError={setMemeError}
|
||||||
memeTitle={memeTitle}
|
memeTitle={memeTitle}
|
||||||
setMemeTitle={handleMemeTitleChange}
|
setMemeTitle={handleMemeTitleChange}
|
||||||
memeTags={memeTags}
|
memeTags={memeTags}
|
||||||
@@ -179,14 +180,9 @@ const EditMeme = ({
|
|||||||
<Button
|
<Button
|
||||||
mode="contained"
|
mode="contained"
|
||||||
icon="floppy"
|
icon="floppy"
|
||||||
onPress={() => {
|
onPress={handleSave}
|
||||||
setIsSaving(true);
|
|
||||||
handleSave();
|
|
||||||
setIsSaving(false);
|
|
||||||
goBack();
|
|
||||||
}}
|
|
||||||
disabled={
|
disabled={
|
||||||
!memeTitle.valid || !hasChanges || isSaving || !!memeUriError
|
!memeTitle.valid || !hasChanges || isSaving || !!memeError
|
||||||
}
|
}
|
||||||
loading={isSaving}
|
loading={isSaving}
|
||||||
style={editorStyles.soloSaveButton}>
|
style={editorStyles.soloSaveButton}>
|
||||||
|
@@ -51,7 +51,9 @@ const EditTag = ({
|
|||||||
tag.color = tagColor.parsed;
|
tag.color = tagColor.parsed;
|
||||||
tag.dateModified = new Date();
|
tag.dateModified = new Date();
|
||||||
});
|
});
|
||||||
}, [realm, tag, tagColor.parsed, tagName.parsed]);
|
|
||||||
|
goBack();
|
||||||
|
}, [goBack, realm, tag, tagColor.parsed, tagName.parsed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -87,10 +89,7 @@ const EditTag = ({
|
|||||||
<Button
|
<Button
|
||||||
mode="contained"
|
mode="contained"
|
||||||
icon="floppy"
|
icon="floppy"
|
||||||
onPress={() => {
|
onPress={handleSave}
|
||||||
handleSave();
|
|
||||||
goBack();
|
|
||||||
}}
|
|
||||||
disabled={!tagName.valid || !tagColor.valid || !hasChanges}
|
disabled={!tagName.valid || !tagColor.valid || !hasChanges}
|
||||||
style={editorStyles.soloSaveButton}>
|
style={editorStyles.soloSaveButton}>
|
||||||
Save
|
Save
|
||||||
|
@@ -17,6 +17,8 @@ import {
|
|||||||
multipleIdQuery,
|
multipleIdQuery,
|
||||||
shareMeme,
|
shareMeme,
|
||||||
} from '../utilities';
|
} from '../utilities';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { RootState } from '../state';
|
||||||
|
|
||||||
const memeViewStyles = StyleSheet.create({
|
const memeViewStyles = StyleSheet.create({
|
||||||
// eslint-disable-next-line react-native/no-color-literals
|
// eslint-disable-next-line react-native/no-color-literals
|
||||||
@@ -48,6 +50,10 @@ const MemeView = ({
|
|||||||
}: NativeStackScreenProps<RootStackParamList, ROUTE.MEME_VIEW>) => {
|
}: NativeStackScreenProps<RootStackParamList, ROUTE.MEME_VIEW>) => {
|
||||||
const { height, width } = useSafeAreaFrame();
|
const { height, width } = useSafeAreaFrame();
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const storageUri = useSelector(
|
||||||
|
(state: RootState) => state.settings.storageUri,
|
||||||
|
)!;
|
||||||
const realm = useRealm();
|
const realm = useRealm();
|
||||||
|
|
||||||
const { ids } = route.params;
|
const { ids } = route.params;
|
||||||
@@ -97,7 +103,7 @@ const MemeView = ({
|
|||||||
<Appbar.Action
|
<Appbar.Action
|
||||||
icon="share"
|
icon="share"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
shareMeme(memes[index]).catch(() => {
|
shareMeme(realm, storageUri, memes[index]).catch(() => {
|
||||||
setSnackbarMessage('Failed to share meme!');
|
setSnackbarMessage('Failed to share meme!');
|
||||||
setSnackbarVisible(true);
|
setSnackbarVisible(true);
|
||||||
});
|
});
|
||||||
@@ -106,7 +112,7 @@ const MemeView = ({
|
|||||||
<Appbar.Action
|
<Appbar.Action
|
||||||
icon="content-copy"
|
icon="content-copy"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await copyMeme(memes[index])
|
await copyMeme(realm, storageUri, memes[index])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSnackbarMessage('Meme copied!');
|
setSnackbarMessage('Meme copied!');
|
||||||
setSnackbarVisible(true);
|
setSnackbarVisible(true);
|
||||||
@@ -132,7 +138,7 @@ const MemeView = ({
|
|||||||
index: index - 1,
|
index: index - 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
void deleteMeme(realm, memes[index]);
|
void deleteMeme(realm, storageUri, memes[index]);
|
||||||
if (memes.length === 1) navigation.goBack();
|
if (memes.length === 1) navigation.goBack();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@@ -10,7 +10,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from 'react-native-paper';
|
} from 'react-native-paper';
|
||||||
import { openDocumentTree } from 'react-native-scoped-storage';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import type {} from 'redux-thunk/extend-redux';
|
import type {} from 'redux-thunk/extend-redux';
|
||||||
import {
|
import {
|
||||||
@@ -18,8 +17,11 @@ import {
|
|||||||
setGridColumns,
|
setGridColumns,
|
||||||
setMasonryColumns,
|
setMasonryColumns,
|
||||||
setNoMedia,
|
setNoMedia,
|
||||||
setStorageUri,
|
|
||||||
} from '../state';
|
} from '../state';
|
||||||
|
import StorageLocationChangeDialog from '../components/storageLocationChangeDialog';
|
||||||
|
import { useRealm } from '@realm/react';
|
||||||
|
import { FileSystem, FileStat } from 'react-native-file-access';
|
||||||
|
import { Meme } from '../database';
|
||||||
|
|
||||||
const settingsStyles = StyleSheet.create({
|
const settingsStyles = StyleSheet.create({
|
||||||
scrollView: {
|
scrollView: {
|
||||||
@@ -51,18 +53,38 @@ const Settings = () => {
|
|||||||
const gridColumns = useSelector(
|
const gridColumns = useSelector(
|
||||||
(state: RootState) => state.settings.gridColumns,
|
(state: RootState) => state.settings.gridColumns,
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const storageUri = useSelector(
|
||||||
|
(state: RootState) => state.settings.storageUri,
|
||||||
|
)!;
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const realm = useRealm();
|
||||||
|
|
||||||
const [isOptimizingDatabase, setIsOptimizingDatabase] = useState(false);
|
|
||||||
const [snackbarVisible, setSnackbarVisible] = useState(false);
|
const [snackbarVisible, setSnackbarVisible] = useState(false);
|
||||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||||
|
|
||||||
const optimizeDatabase = () => {
|
const [
|
||||||
setIsOptimizingDatabase(true);
|
storageLocationChangeDialogVisible,
|
||||||
// TODO: clean up missing / extra files
|
setStorageLocationChangeDialogVisible,
|
||||||
setSnackbarMessage('Database optimized!');
|
] = useState(false);
|
||||||
|
|
||||||
|
const refreshMemeMetadata = async () => {
|
||||||
|
const stat = await FileSystem.statDir(storageUri);
|
||||||
|
|
||||||
|
const statMap = new Map<string, FileStat>();
|
||||||
|
stat.forEach(s => statMap.set(s.filename, s));
|
||||||
|
|
||||||
|
const memes = realm.objects<Meme>(Meme.schema.name);
|
||||||
|
|
||||||
|
realm.write(() => {
|
||||||
|
memes.forEach(meme => {
|
||||||
|
const fileStat = statMap.get(meme.filename);
|
||||||
|
meme.size = fileStat?.size ?? 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setSnackbarMessage('Meme metadata refreshed.');
|
||||||
setSnackbarVisible(true);
|
setSnackbarVisible(true);
|
||||||
setIsOptimizingDatabase(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,15 +133,18 @@ const Settings = () => {
|
|||||||
/>
|
/>
|
||||||
</List.Section>
|
</List.Section>
|
||||||
<List.Section>
|
<List.Section>
|
||||||
<List.Subheader>Media Storage</List.Subheader>
|
<List.Subheader>Storage</List.Subheader>
|
||||||
<Button
|
<Button
|
||||||
mode="elevated"
|
mode="elevated"
|
||||||
style={settingsStyles.marginBottom}
|
style={settingsStyles.marginBottom}
|
||||||
onPress={async () => {
|
onPress={() => setStorageLocationChangeDialogVisible(true)}>
|
||||||
const { uri } = await openDocumentTree(true);
|
Change Storage Location
|
||||||
void dispatch(setStorageUri(uri));
|
</Button>
|
||||||
}}>
|
<Button
|
||||||
Change External Storage Path
|
mode="elevated"
|
||||||
|
style={settingsStyles.marginBottom}
|
||||||
|
onPress={refreshMemeMetadata}>
|
||||||
|
Refresh Meme Metadata
|
||||||
</Button>
|
</Button>
|
||||||
<View style={settingsStyles.hideMediaSwitch}>
|
<View style={settingsStyles.hideMediaSwitch}>
|
||||||
<Text>Hide media from gallery</Text>
|
<Text>Hide media from gallery</Text>
|
||||||
@@ -131,17 +156,16 @@ const Settings = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</List.Section>
|
</List.Section>
|
||||||
<List.Section>
|
|
||||||
<List.Subheader>Database</List.Subheader>
|
|
||||||
<Button
|
|
||||||
mode="elevated"
|
|
||||||
loading={isOptimizingDatabase}
|
|
||||||
onPress={optimizeDatabase}>
|
|
||||||
Optimize Database Now
|
|
||||||
</Button>
|
|
||||||
</List.Section>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<Portal>
|
<Portal>
|
||||||
|
<Portal>
|
||||||
|
<StorageLocationChangeDialog
|
||||||
|
visible={storageLocationChangeDialogVisible}
|
||||||
|
setVisible={setStorageLocationChangeDialogVisible}
|
||||||
|
setSnackbarVisible={setSnackbarVisible}
|
||||||
|
setSnackbarMessage={setSnackbarMessage}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
<Snackbar
|
<Snackbar
|
||||||
visible={snackbarVisible}
|
visible={snackbarVisible}
|
||||||
onDismiss={() => setSnackbarVisible(false)}
|
onDismiss={() => setSnackbarVisible(false)}
|
||||||
|
@@ -18,7 +18,7 @@ interface MemeViewRouteParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AddMemeRouteParams {
|
interface AddMemeRouteParams {
|
||||||
file: DocumentPickerResponse;
|
files: DocumentPickerResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditMemeRouteParams {
|
interface EditMemeRouteParams {
|
||||||
|
@@ -23,7 +23,10 @@ export {
|
|||||||
editMeme,
|
editMeme,
|
||||||
deleteMeme,
|
deleteMeme,
|
||||||
} from './meme';
|
} from './meme';
|
||||||
export { isPermissionForPath, clearPermissions } from './permissions';
|
export {
|
||||||
|
isPermissionForPath,
|
||||||
|
clearPermissions,
|
||||||
|
} from './permissions';
|
||||||
export { deleteTag } from './tag';
|
export { deleteTag } from './tag';
|
||||||
export {
|
export {
|
||||||
type StringValidationResult,
|
type StringValidationResult,
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { Dirs, FileSystem } from 'react-native-file-access';
|
import { AndroidScoped, Dirs, FileSystem } from 'react-native-file-access';
|
||||||
import { extension } from 'react-native-mime-types';
|
|
||||||
import Share from 'react-native-share';
|
import Share from 'react-native-share';
|
||||||
import Clipboard from '@react-native-clipboard/clipboard';
|
import Clipboard from '@react-native-clipboard/clipboard';
|
||||||
import { Meme } from '../database';
|
import { Meme } from '../database';
|
||||||
@@ -13,10 +12,20 @@ const favoriteMeme = (realm: Realm, meme: Meme) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareMeme = async (meme: Meme) => {
|
const shareMeme = async (realm: Realm, storageUri: string, meme: Meme) => {
|
||||||
const fileExtension = extension(meme.mimeType) as string;
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
const cacheUri = `${Dirs.CacheDir}/${meme.id.toHexString()}.${fileExtension}`;
|
const cacheUri = `${Dirs.CacheDir}/${meme.filename}`;
|
||||||
await FileSystem.cp(meme.uri, cacheUri);
|
|
||||||
|
realm.write(() => {
|
||||||
|
meme.dateUsed = new Date();
|
||||||
|
meme.timesUsed += 1;
|
||||||
|
meme.tags.forEach(tag => {
|
||||||
|
tag.dateUsed = new Date();
|
||||||
|
tag.timesUsed += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await FileSystem.cp(uri, cacheUri);
|
||||||
await Share.open({
|
await Share.open({
|
||||||
url: `file://${cacheUri}`,
|
url: `file://${cacheUri}`,
|
||||||
type: meme.mimeType,
|
type: meme.mimeType,
|
||||||
@@ -24,10 +33,22 @@ const shareMeme = async (meme: Meme) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyMeme = async (meme: Meme) => {
|
const copyMeme = async (realm: Realm, storageUri: string, meme: Meme) => {
|
||||||
const exists = await FileSystem.exists(meme.uri);
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
|
const exists = await FileSystem.exists(uri);
|
||||||
if (!exists) throw new Error('File does not exist');
|
if (!exists) throw new Error('File does not exist');
|
||||||
Clipboard.setURI(meme.uri);
|
|
||||||
|
realm.write(() => {
|
||||||
|
meme.dateUsed = new Date();
|
||||||
|
meme.timesUsed += 1;
|
||||||
|
meme.tags.forEach(tag => {
|
||||||
|
tag.dateUsed = new Date();
|
||||||
|
tag.timesUsed += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Clipboard.setURI(uri);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editMeme = (
|
const editMeme = (
|
||||||
@@ -37,8 +58,10 @@ const editMeme = (
|
|||||||
navigation.navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() });
|
navigation.navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() });
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMeme = async (realm: Realm, meme: Meme) => {
|
const deleteMeme = async (realm: Realm, storageUri: string, meme: Meme) => {
|
||||||
await FileSystem.unlink(meme.uri).catch(noOp);
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
|
await FileSystem.unlink(uri).catch(noOp);
|
||||||
|
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
for (const tag of meme.tags) {
|
for (const tag of meme.tags) {
|
||||||
|
@@ -4,7 +4,7 @@ import {
|
|||||||
} from 'react-native-scoped-storage';
|
} from 'react-native-scoped-storage';
|
||||||
|
|
||||||
const isPermissionForPath = (permission: string, path: string) => {
|
const isPermissionForPath = (permission: string, path: string) => {
|
||||||
return path.startsWith(permission + '/');
|
return path.startsWith(permission + '/') || path === permission;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearPermissions = async (excepts: string[] = []) => {
|
const clearPermissions = async (excepts: string[] = []) => {
|
||||||
|
Reference in New Issue
Block a user