Refactor to use Redux
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
77
src/app.tsx
77
src/app.tsx
@@ -1,32 +1,77 @@
|
||||
import React from 'react';
|
||||
import { StatusBar, useColorScheme } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { AppState, StatusBar, useColorScheme } from 'react-native';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { RealmProvider } from '@realm/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { openDocumentTree } from 'react-native-scoped-storage';
|
||||
import type {} from 'redux-thunk/extend-redux';
|
||||
import { lightTheme, darkTheme } from './theme';
|
||||
import { Meme, Tag } from './database';
|
||||
import NavigationContainer from './navigation';
|
||||
import { SettingsProvider } from './contexts';
|
||||
import { store, persistor, updateStorageUri, validateSettings } from './redux';
|
||||
import { LoadingView } from './components';
|
||||
import { Welcome } from './screens';
|
||||
import { noOp } from './constants';
|
||||
|
||||
const App = () => {
|
||||
const [showWelcome, setShowWelcome] = useState(false);
|
||||
const colorScheme = useColorScheme();
|
||||
const isDarkMode = colorScheme === 'dark';
|
||||
const theme = isDarkMode ? darkTheme : lightTheme;
|
||||
|
||||
const onBeforeLift = async () => {
|
||||
await store.dispatch(validateSettings());
|
||||
const { settings } = store.getState();
|
||||
if (!settings.storageUri) {
|
||||
setShowWelcome(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', async state => {
|
||||
if (state !== 'active') return;
|
||||
|
||||
await store.dispatch(validateSettings());
|
||||
const { settings } = store.getState();
|
||||
if (!settings.storageUri) {
|
||||
setShowWelcome(true);
|
||||
}
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RealmProvider schema={[Meme, Tag]}>
|
||||
<PaperProvider theme={theme}>
|
||||
<SettingsProvider>
|
||||
<SafeAreaProvider>
|
||||
<StatusBar
|
||||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={theme.colors.background}
|
||||
/>
|
||||
<NavigationContainer />
|
||||
</SafeAreaProvider>
|
||||
</SettingsProvider>
|
||||
</PaperProvider>
|
||||
</RealmProvider>
|
||||
<Provider store={store}>
|
||||
<PersistGate
|
||||
loading={<LoadingView />}
|
||||
persistor={persistor}
|
||||
onBeforeLift={onBeforeLift}>
|
||||
<RealmProvider schema={[Meme, Tag]}>
|
||||
<PaperProvider theme={theme}>
|
||||
<SafeAreaProvider>
|
||||
<StatusBar
|
||||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={theme.colors.background}
|
||||
/>
|
||||
{showWelcome ? (
|
||||
<Welcome
|
||||
selectStorageLocation={async () => {
|
||||
const uri = await openDocumentTree(true).catch(noOp);
|
||||
if (!uri) return;
|
||||
await store.dispatch(updateStorageUri(uri.uri));
|
||||
setShowWelcome(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<NavigationContainer />
|
||||
)}
|
||||
</SafeAreaProvider>
|
||||
</PaperProvider>
|
||||
</RealmProvider>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -1 +0,0 @@
|
||||
export { SettingsProvider, useStorageUri, useNoMedia } from './settings';
|
@@ -1,152 +0,0 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { AndroidScoped, FileSystem } from 'react-native-file-access';
|
||||
import {
|
||||
getPersistedUriPermissions,
|
||||
openDocumentTree,
|
||||
createFile,
|
||||
deleteFile,
|
||||
} from 'react-native-scoped-storage';
|
||||
import { LoadingView } from '../components';
|
||||
import { Welcome } from '../screens';
|
||||
import { clearPermissions, isPermissionForPath } from '../utilities';
|
||||
|
||||
interface SettingsContextType {
|
||||
storageUri: string;
|
||||
noMedia: boolean;
|
||||
setStorageUri: (newStorageUri: string) => Promise<void>;
|
||||
setNoMedia: (newNoMedia: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [storageUri, setStorageUri] = useState('');
|
||||
const [noMedia, setNoMedia] = useState(false);
|
||||
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
const updateStorageUri = async (newStorageUri: string): Promise<void> => {
|
||||
setStorageUri(newStorageUri);
|
||||
|
||||
void clearPermissions([newStorageUri]);
|
||||
void AsyncStorage.setItem('storageUri', newStorageUri);
|
||||
};
|
||||
|
||||
const updateNoMedia = async (newNoMedia: boolean): Promise<void> => {
|
||||
setNoMedia(newNoMedia);
|
||||
|
||||
const noMediaExists = await FileSystem.exists(
|
||||
AndroidScoped.appendPath(storageUri, '.nomedia'),
|
||||
);
|
||||
|
||||
if (newNoMedia && !noMediaExists) {
|
||||
await createFile(storageUri, '.nomedia', 'text/x-unknown');
|
||||
} else if (!newNoMedia && noMediaExists) {
|
||||
await deleteFile(AndroidScoped.appendPath(storageUri, '.nomedia'));
|
||||
}
|
||||
|
||||
void AsyncStorage.setItem('noMedia', newNoMedia.toString());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
let storageUriValue = (await AsyncStorage.getItem('storageUri')) ?? '';
|
||||
let noMediaValue = (await AsyncStorage.getItem('noMedia')) === 'true';
|
||||
|
||||
if (storageUriValue !== '') {
|
||||
const permissions = await getPersistedUriPermissions();
|
||||
if (
|
||||
!permissions.some(permission =>
|
||||
isPermissionForPath(permission, storageUriValue),
|
||||
)
|
||||
) {
|
||||
storageUriValue = '';
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await FileSystem.exists(storageUriValue);
|
||||
if (!exists) {
|
||||
throw new Error('Storage URI does not exist');
|
||||
}
|
||||
|
||||
const isDirectory = await FileSystem.isDir(storageUriValue);
|
||||
if (!isDirectory) {
|
||||
throw new Error('Storage URI is not a directory');
|
||||
}
|
||||
} catch {
|
||||
storageUriValue = '';
|
||||
}
|
||||
|
||||
noMediaValue = await FileSystem.exists(
|
||||
AndroidScoped.appendPath(storageUriValue, '.nomedia'),
|
||||
);
|
||||
}
|
||||
|
||||
setStorageUri(storageUriValue);
|
||||
setNoMedia(noMediaValue);
|
||||
setHasLoaded(true);
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener('change', () => {
|
||||
if (AppState.currentState === 'active') void loadSettings();
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider
|
||||
value={{
|
||||
storageUri,
|
||||
noMedia,
|
||||
setStorageUri: updateStorageUri,
|
||||
setNoMedia: updateNoMedia,
|
||||
}}>
|
||||
{hasLoaded ? (
|
||||
storageUri === '' ? (
|
||||
<Welcome
|
||||
selectStorageLocation={async () => {
|
||||
const { uri } = await openDocumentTree(true);
|
||||
await updateStorageUri(uri);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
) : (
|
||||
<LoadingView />
|
||||
)}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useStorageUri = (): [
|
||||
string,
|
||||
(newStorageUri: string) => Promise<void>,
|
||||
] => {
|
||||
const context = useContext(SettingsContext);
|
||||
if (!context) {
|
||||
throw new Error('useStorageUri must be used within a SettingsProvider');
|
||||
}
|
||||
return [context.storageUri, context.setStorageUri];
|
||||
};
|
||||
|
||||
const useNoMedia = (): [boolean, (newNoMedia: boolean) => Promise<void>] => {
|
||||
const context = useContext(SettingsContext);
|
||||
if (!context) {
|
||||
throw new Error('useNoMedia must be used within a SettingsProvider');
|
||||
}
|
||||
return [context.noMedia, context.setNoMedia];
|
||||
};
|
||||
|
||||
export { SettingsProvider, useStorageUri, useNoMedia };
|
5
src/index.d.ts
vendored
Normal file
5
src/index.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '@bankify/redux-persist-realm' {
|
||||
function createRealmPersistStorage(options?: {
|
||||
path: string;
|
||||
}): import('redux-persist').Storage;
|
||||
}
|
48
src/redux/index.ts
Normal file
48
src/redux/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { configureStore, combineReducers } from '@reduxjs/toolkit';
|
||||
import {
|
||||
persistReducer,
|
||||
persistStore,
|
||||
FLUSH,
|
||||
REHYDRATE,
|
||||
PAUSE,
|
||||
PERSIST,
|
||||
PURGE,
|
||||
REGISTER,
|
||||
} from 'redux-persist';
|
||||
import { createRealmPersistStorage } from '@bankify/redux-persist-realm';
|
||||
import settingsReducer from './settings';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
settings: settingsReducer,
|
||||
});
|
||||
|
||||
interface RootState {
|
||||
settings: ReturnType<typeof settingsReducer>;
|
||||
}
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage: createRealmPersistStorage({ path: 'redux.realm' }),
|
||||
};
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
||||
|
||||
const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const persistor = persistStore(store);
|
||||
|
||||
export { type RootState, store, persistor };
|
||||
export {
|
||||
type SettingsState,
|
||||
updateStorageUri,
|
||||
updateNoMedia,
|
||||
validateSettings,
|
||||
} from './settings';
|
114
src/redux/settings.ts
Normal file
114
src/redux/settings.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import {
|
||||
createFile,
|
||||
deleteFile,
|
||||
getPersistedUriPermissions,
|
||||
} from 'react-native-scoped-storage';
|
||||
import { FileSystem, AndroidScoped } from 'react-native-file-access';
|
||||
import { clearPermissions, isPermissionForPath } from '../utilities';
|
||||
import { RootState } from '.';
|
||||
|
||||
interface SettingsState {
|
||||
storageUri: string | undefined;
|
||||
noMedia: boolean;
|
||||
}
|
||||
|
||||
const initialState: SettingsState = {
|
||||
storageUri: undefined,
|
||||
noMedia: false,
|
||||
};
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
name: 'settings',
|
||||
initialState,
|
||||
reducers: {
|
||||
setStorageUri: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.storageUri = action.payload;
|
||||
},
|
||||
setNoMedia: (state, action: PayloadAction<boolean>) => {
|
||||
state.noMedia = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { setStorageUri, setNoMedia } = settingsSlice.actions;
|
||||
|
||||
const updateStorageUri = createAsyncThunk(
|
||||
'settings/updateStorageUri',
|
||||
async (newStorageUri: string, { dispatch }) => {
|
||||
await clearPermissions([newStorageUri]);
|
||||
dispatch(setStorageUri(newStorageUri));
|
||||
},
|
||||
);
|
||||
|
||||
const updateNoMedia = createAsyncThunk(
|
||||
'settings/updateNoMedia',
|
||||
async (newNoMedia: boolean, { dispatch, getState }) => {
|
||||
const state = getState() as RootState;
|
||||
const { storageUri } = state.settings;
|
||||
if (!storageUri) return;
|
||||
|
||||
const noMediaExists = await FileSystem.exists(
|
||||
AndroidScoped.appendPath(storageUri, '.nomedia'),
|
||||
);
|
||||
if (newNoMedia && !noMediaExists) {
|
||||
await createFile(storageUri, '.nomedia', 'text/x-unknown');
|
||||
} else if (!newNoMedia && noMediaExists) {
|
||||
await deleteFile(AndroidScoped.appendPath(storageUri, '.nomedia'));
|
||||
}
|
||||
dispatch(setNoMedia(newNoMedia));
|
||||
},
|
||||
);
|
||||
|
||||
const validateSettings = createAsyncThunk(
|
||||
'settings/validateSettings',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
async (_, { dispatch, getState }) => {
|
||||
const state = getState() as RootState;
|
||||
const { storageUri, noMedia } = state.settings;
|
||||
|
||||
if (!storageUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
const permissions = await getPersistedUriPermissions();
|
||||
if (
|
||||
!permissions.some(permission =>
|
||||
isPermissionForPath(permission, storageUri),
|
||||
)
|
||||
) {
|
||||
dispatch(setStorageUri());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await FileSystem.exists(storageUri);
|
||||
if (!exists) {
|
||||
throw new Error('Storage URI does not exist');
|
||||
}
|
||||
|
||||
const isDirectory = await FileSystem.isDir(storageUri);
|
||||
if (!isDirectory) {
|
||||
throw new Error('Storage URI is not a directory');
|
||||
}
|
||||
} catch {
|
||||
dispatch(setStorageUri());
|
||||
return;
|
||||
}
|
||||
|
||||
const isNoMedia = await FileSystem.exists(
|
||||
AndroidScoped.appendPath(storageUri, '.nomedia'),
|
||||
);
|
||||
if (noMedia !== isNoMedia) {
|
||||
dispatch(setNoMedia(isNoMedia));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export {
|
||||
type SettingsState,
|
||||
updateStorageUri,
|
||||
updateNoMedia,
|
||||
validateSettings,
|
||||
};
|
||||
export default settingsSlice.reducer;
|
@@ -3,20 +3,22 @@ import { View } from 'react-native';
|
||||
import { Button, List, Snackbar, Switch, Text } from 'react-native-paper';
|
||||
import { useRealm } from '@realm/react';
|
||||
import { openDocumentTree } from 'react-native-scoped-storage';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { PaddedView } from '../components';
|
||||
import styles from '../styles';
|
||||
import { Meme } from '../database';
|
||||
import { useStorageUri, useNoMedia } from '../contexts';
|
||||
import { RootState, updateNoMedia, updateStorageUri } from '../redux';
|
||||
import type {} from 'redux-thunk/extend-redux';
|
||||
|
||||
const SettingsScreen = () => {
|
||||
const [optimizingDatabase, setOptimizingDatabase] = useState(false);
|
||||
|
||||
const noMedia = useSelector((state: RootState) => state.settings.noMedia);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [snackbarVisible, setSnackbarVisible] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
|
||||
const [, setStorageUri] = useStorageUri();
|
||||
const [noMedia, setNoMedia] = useNoMedia();
|
||||
|
||||
const realm = useRealm();
|
||||
|
||||
const optimizeDatabase = () => {
|
||||
@@ -62,7 +64,7 @@ const SettingsScreen = () => {
|
||||
style={styles.marginBottom}
|
||||
onPress={async () => {
|
||||
const { uri } = await openDocumentTree(true);
|
||||
void setStorageUri(uri);
|
||||
void dispatch(updateStorageUri(uri));
|
||||
}}>
|
||||
Change External Storage Path
|
||||
</Button>
|
||||
@@ -76,7 +78,7 @@ const SettingsScreen = () => {
|
||||
<Switch
|
||||
value={noMedia}
|
||||
onValueChange={value => {
|
||||
void setNoMedia(value);
|
||||
void dispatch(updateNoMedia(value));
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
@@ -3,7 +3,9 @@ import { Button, Text } from 'react-native-paper';
|
||||
import { PaddedView } from '../components';
|
||||
import styles from '../styles';
|
||||
|
||||
const Welcome = (properties: { selectStorageLocation: () => void }) => {
|
||||
const Welcome = (properties: {
|
||||
selectStorageLocation: () => Promise<void>;
|
||||
}) => {
|
||||
return (
|
||||
<PaddedView centered>
|
||||
<Text
|
||||
@@ -11,7 +13,10 @@ const Welcome = (properties: { selectStorageLocation: () => void }) => {
|
||||
style={[styles.bigMarginBottom, styles.centerText]}>
|
||||
Welcome to Terminally Online!
|
||||
</Text>
|
||||
<Button mode="contained" onPress={properties.selectStorageLocation} style={styles.extremeMarginBottom}>
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={properties.selectStorageLocation}
|
||||
style={styles.extremeMarginBottom}>
|
||||
Select Storage Location
|
||||
</Button>
|
||||
</PaddedView>
|
||||
|
Reference in New Issue
Block a user