Add settings page
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
48
src/app.tsx
48
src/app.tsx
@@ -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
2
src/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LoadingView } from './loadingView';
|
||||
export { default as PaddedView } from './paddedView';
|
23
src/components/loadingView.tsx
Normal file
23
src/components/loadingView.tsx
Normal 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;
|
31
src/components/paddedView.tsx
Normal file
31
src/components/paddedView.tsx
Normal 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
1
src/contexts/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useSettings, SettingsProvider } from './settings';
|
81
src/contexts/settings.tsx
Normal file
81
src/contexts/settings.tsx
Normal 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
86
src/navigation.tsx
Normal 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;
|
@@ -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
2
src/screens/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Home } from './home';
|
||||
export { default as Settings } from './settings';
|
159
src/screens/settings.tsx
Normal file
159
src/screens/settings.tsx
Normal 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
14
src/styles.tsx
Normal 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
1
src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { default as Settings } from './settings';
|
7
src/types/settings.ts
Normal file
7
src/types/settings.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
interface Settings {
|
||||
useInternalStorage: boolean;
|
||||
storageUri: string;
|
||||
addNoMedia: boolean;
|
||||
}
|
||||
|
||||
export default Settings;
|
Reference in New Issue
Block a user