Refactor to use Redux

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-07-11 20:54:15 +03:00
parent 2e147060c0
commit 99195fe481
10 changed files with 467 additions and 246 deletions

View File

@@ -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>
);
};

View File

@@ -1 +0,0 @@
export { SettingsProvider, useStorageUri, useNoMedia } from './settings';

View File

@@ -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
View 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
View 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
View 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;

View File

@@ -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>

View File

@@ -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>