Add settings page

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-07-09 00:34:31 +03:00
parent 51f98fb71c
commit 2c5bd7cda2
18 changed files with 554 additions and 44 deletions

View File

@@ -1,47 +1,31 @@
import React from 'react';
import React, { JSX } from 'react';
import { Appearance, StatusBar } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createMaterialBottomTabNavigator } from 'react-native-paper/react-navigation';
import { PaperProvider } from 'react-native-paper';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import Home from './screens/home';
import { RealmProvider } from '@realm/react';
import { lightTheme, darkTheme } from './theme';
import { Meme, Tag } from './database';
import { createRealmContext } from '@realm/react';
const TabNavigator = createMaterialBottomTabNavigator();
const { RealmProvider } = createRealmContext({
schema: [Meme, Tag],
});
import NavigationContainer from './navigation';
import { SettingsProvider } from './contexts/settings';
function App(): JSX.Element {
const colorScheme = Appearance.getColorScheme();
const theme = colorScheme === 'dark' ? lightTheme : darkTheme;
return (
<RealmProvider>
<RealmProvider schema={[Meme, Tag]}>
<PaperProvider theme={theme}>
<SafeAreaProvider>
<StatusBar
barStyle={colorScheme === 'dark' ? 'dark-content' : 'light-content'}
backgroundColor={theme.colors.background}
/>
<NavigationContainer>
<TabNavigator.Navigator>
<TabNavigator.Screen
name="Home"
component={Home}
options={{
tabBarIcon: ({ color }) => (
<FontAwesome5 name="home" color={color} size={20} />
),
}}
/>
</TabNavigator.Navigator>
</NavigationContainer>
</SafeAreaProvider>
<SettingsProvider>
<SafeAreaProvider>
<StatusBar
barStyle={
colorScheme === 'dark' ? 'dark-content' : 'light-content'
}
backgroundColor={theme.colors.background}
/>
<NavigationContainer />
</SafeAreaProvider>
</SettingsProvider>
</PaperProvider>
</RealmProvider>
);

2
src/components/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { default as LoadingView } from './loadingView';
export { default as PaddedView } from './paddedView';

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useTheme } from 'react-native-paper';
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
const LoadingView = () => {
const { colors } = useTheme();
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
};
export default LoadingView;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
padding: '5%',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
});
const PaddedView = ({
children,
style,
centered,
}: {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
centered?: boolean;
}): React.JSX.Element => {
return (
<View style={[styles.container, centered && styles.centered, style]}>
{children}
</View>
);
};
export default PaddedView;

1
src/contexts/index.ts Normal file
View File

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

81
src/contexts/settings.tsx Normal file
View File

@@ -0,0 +1,81 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { Settings } from '../types';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { DocumentDirectoryPath } from 'react-native-fs';
import { LoadingView } from '../components';
interface SettingsContextType {
settings: Settings;
setSettings: (newSettings: Partial<Settings>) => void;
}
const SettingsContext = createContext<SettingsContextType | undefined>(
undefined,
);
const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [settings, setSettings] = useState<Settings>({
useInternalStorage: true,
storageUri: '',
addNoMedia: false,
});
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => {
const loadSettings = async () => {
const useInternalStorageValue = await AsyncStorage.getItem(
'useInternalStorage',
);
const storageUriValue = await AsyncStorage.getItem('storageUri');
const addNoMediaValue = await AsyncStorage.getItem('addNoMedia');
setSettings({
useInternalStorage: useInternalStorageValue
? (JSON.parse(useInternalStorageValue) as boolean)
: true,
storageUri: storageUriValue ?? DocumentDirectoryPath,
addNoMedia: addNoMediaValue
? (JSON.parse(addNoMediaValue) as boolean)
: false,
});
setHasLoaded(true);
};
void loadSettings();
}, []);
const updateSettings = (newSettings: Partial<Settings>) => {
const updatedSettings = { ...settings, ...newSettings };
void AsyncStorage.setItem(
'useInternalStorage',
JSON.stringify(updatedSettings.useInternalStorage),
);
void AsyncStorage.setItem('storageUri', updatedSettings.storageUri);
void AsyncStorage.setItem(
'addNoMedia',
JSON.stringify(updatedSettings.addNoMedia),
);
setSettings(updatedSettings);
};
return (
<SettingsContext.Provider value={{ settings, setSettings: updateSettings }}>
{hasLoaded ? children : <LoadingView />}
</SettingsContext.Provider>
);
};
const useSettings = (): SettingsContextType => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};
export { SettingsProvider, useSettings };

86
src/navigation.tsx Normal file
View File

@@ -0,0 +1,86 @@
import React from 'react';
import {
CommonActions,
NavigationContainer as NavigationContainerBase,
} from '@react-navigation/native';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { BottomNavigation, useTheme } from 'react-native-paper';
import { Home, Settings } from './screens';
function NavigationContainer() {
const TabNavigator = createBottomTabNavigator();
const theme = useTheme();
return (
<NavigationContainerBase
theme={{
dark: theme.dark,
colors: {
primary: theme.colors.primary,
background: theme.colors.background,
card: theme.colors.surface,
text: theme.colors.onSurface,
border: theme.colors.outline,
notification: theme.colors.error,
},
}}>
<TabNavigator.Navigator
screenOptions={{
headerShown: false,
}}
tabBar={({ navigation, state, descriptors, insets }) => (
<BottomNavigation.Bar
navigationState={state}
safeAreaInsets={insets}
onTabPress={({ route, preventDefault }) => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (event.defaultPrevented) {
preventDefault();
} else {
navigation.dispatch({
...CommonActions.navigate(route.name, route.params),
target: state.key,
});
}
}}
renderIcon={({ route, focused, color }) => {
const { options } = descriptors[route.key];
if (options.tabBarIcon) {
return options.tabBarIcon({ focused, color, size: 24 });
}
}}
getLabelText={({ route }) => {
const { options } = descriptors[route.key];
return options.title ?? route.name;
}}
/>
)}>
<TabNavigator.Screen
name="Home"
component={Home}
options={{
tabBarIcon: ({ color }) => (
<FontAwesome5 name="home" color={color} size={20} />
),
}}
/>
<TabNavigator.Screen
name="Settings"
component={Settings}
options={{
tabBarIcon: ({ color }) => (
<FontAwesome5 name="cog" color={color} size={20} />
),
}}
/>
</TabNavigator.Navigator>
</NavigationContainerBase>
);
}
export default NavigationContainer;

View File

@@ -1,21 +1,17 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Text } from 'react-native-paper';
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
import { PaddedView } from '../components';
import { useSettings } from '../contexts';
function Home(): JSX.Element {
const { settings } = useSettings();
return (
<View style={styles.container}>
<PaddedView centered>
<Text>Home</Text>
</View>
<Text>Settings: {JSON.stringify(settings)}</Text>
</PaddedView>
);
}

2
src/screens/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { default as Home } from './home';
export { default as Settings } from './settings';

159
src/screens/settings.tsx Normal file
View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { View } from 'react-native';
import {
Button,
Switch,
SegmentedButtons,
Text,
List,
Snackbar,
} from 'react-native-paper';
import { useRealm } from '@realm/react';
import { openDocumentTree } from 'react-native-scoped-storage';
import { DocumentDirectoryPath } from 'react-native-fs';
import { PaddedView } from '../components';
import styles from '../styles';
import { Meme } from '../database';
import { useSettings } from '../contexts';
const SettingsScreen = () => {
const [optimizingDatabase, setOptimizingDatabase] = useState(false);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const { settings, setSettings } = useSettings();
const setUseInternalStorage = (use: boolean) => {
if (settings.useInternalStorage === use) {
return;
}
if (use) {
setSettings({
useInternalStorage: use,
storageUri: DocumentDirectoryPath,
});
} else {
openDocumentTree(true)
.then(uri => {
setSettings({
useInternalStorage: use,
storageUri: uri.uri,
});
})
.catch(() => {
setSnackbarMessage('Failed to select storage path!');
setSnackbarVisible(true);
});
}
};
const setStorageUri = (uri: string) => {
if (settings.storageUri === uri) {
return;
}
setSettings({ storageUri: uri });
};
const setAddNoMedia = (add: boolean) => {
if (settings.addNoMedia === add) {
return;
}
setSettings({ addNoMedia: add });
};
const realm = useRealm();
const optimizeDatabase = () => {
setOptimizingDatabase(true);
const memes = realm.objects<Meme>('Meme');
realm.write(() => {
for (let index = memes.length - 1; index >= 0; index--) {
// TODO: stat the uri to see if it exists and remove entry if it doesn't
}
});
const success = realm.compact();
if (success) {
setSnackbarMessage('Database optimized!');
setSnackbarVisible(true);
} else {
setSnackbarMessage('Database optimization failed!');
setSnackbarVisible(true);
}
setOptimizingDatabase(false);
};
return (
<>
<PaddedView>
<View>
<List.Section>
<List.Subheader>Database</List.Subheader>
<Button
mode="elevated"
style={styles.marginBottom}
loading={optimizingDatabase}
onPress={optimizeDatabase}>
Optimize Database Now
</Button>
</List.Section>
<List.Section>
<List.Subheader>Media Storage</List.Subheader>
<SegmentedButtons
style={styles.marginBottom}
buttons={[
{ label: 'Internal', value: 'interneal' },
{ label: 'External', value: 'external' },
]}
value={settings.useInternalStorage ? 'interneal' : 'external'}
onValueChange={value =>
setUseInternalStorage(value === 'interneal')
}
/>
<Button
mode="elevated"
style={styles.marginBottom}
disabled={settings.useInternalStorage}
onPress={() => {
openDocumentTree(true)
.then(uri => {
setStorageUri(uri.uri);
})
.catch(() => {
setSnackbarMessage('Failed to select storage path!');
setSnackbarVisible(true);
});
}}>
Change External Storage Path
</Button>
<View style={styles.spaceBetweenHorizontal}>
<Text>Hide media from Gallery</Text>
<Switch
value={settings.addNoMedia}
onValueChange={setAddNoMedia}
disabled={settings.useInternalStorage}
/>
</View>
</List.Section>
</View>
</PaddedView>
<Snackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
action={{
label: 'Dismiss',
onPress: () => setSnackbarVisible(false),
}}>
{snackbarMessage}
</Snackbar>
</>
);
};
export default SettingsScreen;

14
src/styles.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
marginBottom: {
marginBottom: 15,
},
spaceBetweenHorizontal: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
});
export default styles;

1
src/types/index.ts Normal file
View File

@@ -0,0 +1 @@
export type { default as Settings } from './settings';

7
src/types/settings.ts Normal file
View File

@@ -0,0 +1,7 @@
interface Settings {
useInternalStorage: boolean;
storageUri: string;
addNoMedia: boolean;
}
export default Settings;