feat: 🎸 add settings screen (#492)

* feat: add first version of settings

* fix: navigate after logout is now working

* Add settings store with valtio

* fix: fix failing tests

* fix: remove unused module `rn-actionsheet-module`

* fix: remove unused packages

* fix: Remove unused AppearanceProvider

* fix: upgrade to correct version of hermes engine

* fix: correct theme name in selection list

* fix: add missing translations

* fix lint errors

* fix failing tests

* fix: simplify login method logic

* simplify cached personalIdNumber storage

* fix: settings is now always pressable on login screen

* fix: app is correctly rendered when language changes

* chore: rename SettingListItemText to SettingListItem

* fix: fix trailing useEffect error message

* fix: better RTL layout in settings

* add back missing hermes reference

* fix: add useTranslation hook instead of translate

* fix separator styling (includes change for bgColor)

* Add missing translation

* fix: 🐛 useTranslation on childListItem

Co-authored-by: Andreas Eriksson <addeman@gmail.com>
Co-authored-by: Kajetan Kazimierczak <kajetan@hotmail.com>
This commit is contained in:
Jonathan Edenström 2021-09-29 17:31:47 +02:00 committed by GitHub
parent fcb8170e24
commit 3b307d25b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1240 additions and 966 deletions

View File

@ -57,3 +57,5 @@ buck-out/
# CocoaPods
/ios/Pods/
libraries.json

View File

@ -6,13 +6,13 @@ import init from '@skolplattformen/embedded-api'
import { ApplicationProvider, IconRegistry } from '@ui-kitten/components'
import { EvaIconsPack } from '@ui-kitten/eva-icons'
import React from 'react'
import { StatusBar } from 'react-native'
import { AppearanceProvider, useColorScheme } from 'react-native-appearance'
import { StatusBar, useColorScheme } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { AppNavigator } from './components/navigation.component'
import { LanguageProvider } from './context/language/languageContext'
import { default as customMapping } from './design/mapping.json'
import { darkTheme, lightTheme } from './design/themes'
import useSettingsStorage from './hooks/useSettingsStorage'
import { translations } from './utils/translation'
const api = init(fetch, CookieManager)
@ -57,28 +57,30 @@ const logAsyncStorage = async () => {
}
export default () => {
const colorScheme = useColorScheme()
const [usingSystemTheme] = useSettingsStorage('usingSystemTheme')
const [theme] = useSettingsStorage('theme')
const systemTheme = useColorScheme()
const colorScheme = usingSystemTheme ? systemTheme : theme
return (
<ApiProvider api={api} storage={AsyncStorage} reporter={reporter}>
<SafeAreaProvider>
<AppearanceProvider>
<StatusBar
backgroundColor={colorScheme === 'dark' ? '#2E3137' : '#FFF'}
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
translucent
/>
<IconRegistry icons={EvaIconsPack} />
<ApplicationProvider
{...eva}
customMapping={customMapping}
theme={colorScheme === 'dark' ? darkTheme : lightTheme}
>
<LanguageProvider cache={true} data={translations}>
<AppNavigator />
</LanguageProvider>
</ApplicationProvider>
</AppearanceProvider>
<StatusBar
backgroundColor={colorScheme === 'dark' ? '#2E3137' : '#FFF'}
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
translucent
/>
<IconRegistry icons={EvaIconsPack} />
<ApplicationProvider
{...eva}
customMapping={customMapping}
theme={colorScheme === 'dark' ? darkTheme : lightTheme}
>
<LanguageProvider cache={true} data={translations}>
<AppNavigator />
</LanguageProvider>
</ApplicationProvider>
</SafeAreaProvider>
</ApiProvider>
)

View File

@ -1 +0,0 @@
export * from 'react-native-appearance/src/mock'

View File

@ -76,9 +76,9 @@ import com.android.build.OutputFile
* extraPackagerArgs: []
* ]
*/
// Crashes the app in RN 0.65.1
project.ext.react = [
enableHermes: false, // clean and rebuild if changing
enableHermes: true, // clean and rebuild if changing
]
apply from: "../../node_modules/react-native/react.gradle"

View File

@ -10,23 +10,24 @@ import {
Image,
ImageStyle,
Keyboard,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import { NativeStackNavigationOptions } from 'react-native-screens/native-stack'
import { LanguageService } from '../services/languageService'
import { useTranslation } from '../hooks/useTranslation'
import { Layout as LayoutStyle, Sizing, Typography } from '../styles'
import { fontSize } from '../styles/typography'
import { KeyboardAvoidingView } from '../ui/keyboardAvoidingView.component'
import { SafeAreaView } from '../ui/safeAreaView.component'
import { SafeAreaViewContainer } from '../ui/safeAreaViewContainer.component'
import { languages, translate } from '../utils/translation'
import { GlobeIcon } from './icon.component'
import { SettingsIcon } from './icon.component'
import { Login } from './login.component'
import { RootStackParamList } from './navigation.component'
const randomWord = () => {
const words = translate('auth.words')
const randomWord = (
t: (scope: I18n.Scope, options?: I18n.TranslateOptions | undefined) => string
) => {
const words = t('auth.words')
const keys = Object.keys(words)
const randomIndex: number = Math.floor(Math.random() * keys.length)
@ -51,73 +52,64 @@ export const authRouteOptions = (): NativeStackNavigationOptions => {
export const Auth: React.FC<AuthProps> = ({ navigation }) => {
const styles = useStyleSheet(themeStyles)
const colors = useTheme()
const currentLanguage = LanguageService.getLanguageCode()
const currentLanguageName = languages.find(
(language) => language.langCode === currentLanguage
)?.languageLocalName
const { t } = useTranslation()
return (
<SafeAreaView>
<SafeAreaViewContainer>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={LayoutStyle.flex.full}>
<TouchableWithoutFeedback
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
onPress={() => navigation.navigate('SetLanguage')}
accessibilityHint={translate(
'auth.a11y_navigate_to_change_language',
{
defaultValue: 'Navigerar till vyn för att byta språk',
}
)}
accessibilityLabel={translate('auth.a11y_change_language', {
defaultValue: 'Byt språk',
})}
>
<View style={styles.language}>
<GlobeIcon
height={24}
width={24}
fill={colors['color-primary-500']}
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={LayoutStyle.flex.full}>
<TouchableOpacity
style={styles.settingsLink}
onPress={() => navigation.navigate('Settings')}
accessibilityHint={t('auth.a11y_navigate_to_settings', {
defaultValue: 'Navigerar till vyn för inställningar',
})}
accessibilityLabel={t('auth.a11y_settings', {
defaultValue: 'Inställningar',
})}
>
<View style={styles.language}>
<SettingsIcon
height={28}
width={28}
fill={colors['color-primary-500']}
/>
<Text style={styles.languageText}>{t('general.settings')}</Text>
</View>
</TouchableOpacity>
<KeyboardAvoidingView>
<View style={styles.content}>
<View style={styles.imageWrapper}>
<Image
source={require('../assets/boys.png')}
style={styles.image as ImageStyle}
accessibilityHint={t('login.a11y_image_two_boys', {
defaultValue: 'Bild på två personer som kollar i mobilen',
})}
resizeMode="contain"
accessibilityIgnoresInvertColors={false}
/>
<Text style={styles.languageText}>{currentLanguageName}</Text>
</View>
</TouchableWithoutFeedback>
<KeyboardAvoidingView>
<View style={styles.content}>
<View style={styles.imageWrapper}>
<Image
source={require('../assets/boys.png')}
style={styles.image as ImageStyle}
accessibilityHint={translate('login.a11y_image_two_boys', {
defaultValue: 'Bild på två personer som kollar i mobilen',
})}
resizeMode="contain"
accessibilityIgnoresInvertColors={false}
/>
</View>
<View style={styles.container}>
<Text
category="h1"
style={styles.header}
adjustsFontSizeToFit
numberOfLines={2}
>
Öppna skolplattformen
</Text>
<Login />
<Text category="c2" style={styles.subtitle}>
{translate('auth.subtitle', {
word: randomWord(),
})}
</Text>
</View>
<View style={styles.container}>
<Text
category="h1"
style={styles.header}
adjustsFontSizeToFit
numberOfLines={2}
>
Öppna skolplattformen
</Text>
<Login />
<Text category="c2" style={styles.subtitle}>
{t('auth.subtitle', {
word: randomWord(t),
})}
</Text>
</View>
</KeyboardAvoidingView>
</View>
</TouchableWithoutFeedback>
</SafeAreaViewContainer>
</View>
</KeyboardAvoidingView>
</View>
</TouchableWithoutFeedback>
</SafeAreaView>
)
}
@ -153,10 +145,15 @@ const themeStyles = StyleService.create({
language: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: Sizing.t4,
padding: Sizing.t3,
paddingLeft: Sizing.t5,
},
languageText: {
...fontSize.xs,
...fontSize.sm,
marginLeft: Sizing.t1,
},
settingsLink: {
alignSelf: 'flex-start',
zIndex: 1,
},
})

View File

@ -21,7 +21,7 @@ import React from 'react'
import { TouchableOpacity, useColorScheme, View } from 'react-native'
import { Colors, Layout, Sizing } from '../styles'
import { studentName } from '../utils/peopleHelpers'
import { translate } from '../utils/translation'
import { useTranslation } from '../hooks/useTranslation'
import { DaySummary } from './daySummary.component'
import { AlertIcon, RightArrowIcon } from './icon.component'
import { RootStackParamList } from './navigation.component'
@ -41,6 +41,7 @@ export const ChildListItem = ({ child, color }: ChildListItemProps) => {
React.useEffect(() => {}, [child.id])
const navigation = useNavigation<ChildListItemNavigationProp>()
const { t } = useTranslation()
const { data: notifications } = useNotifications(child)
const { data: news } = useNews(child)
const { data: classmates } = useClassmates(child)
@ -91,10 +92,10 @@ export const ChildListItem = ({ child, color }: ChildListItemProps) => {
// Taken from Skolverket
// https://www.skolverket.se/skolutveckling/anordna-och-administrera-utbildning/administrera-utbildning/skoltermer-pa-engelska
const abbrevations = {
G: translate('abbrevations.upperSecondarySchool'),
GR: translate('abbrevations.compulsorySchool'),
F: translate('abbrevations.leisureTimeCentre'),
FS: translate('abbrevations.preSchool'),
G: t('abbrevations.upperSecondarySchool'),
GR: t('abbrevations.compulsorySchool'),
F: t('abbrevations.leisureTimeCentre'),
FS: t('abbrevations.preSchool'),
}
return child.status
@ -143,7 +144,7 @@ export const ChildListItem = ({ child, color }: ChildListItemProps) => {
</Text>
))}
<Text category="c2" style={styles.label}>
{translate('navigation.news')}
{t('navigation.news')}
</Text>
{notificationsThisWeek.slice(0, 3).map((notification, i) => (
<Text category="p1" key={i}>
@ -159,14 +160,14 @@ export const ChildListItem = ({ child, color }: ChildListItemProps) => {
notificationsThisWeek.length ||
newsThisWeek.length ? null : (
<Text category="p1" style={styles.noNewNewsItemsText}>
{translate('news.noNewNewsItemsThisWeek')}
{t('news.noNewNewsItemsThisWeek')}
</Text>
)}
{!menu[moment().isoWeekday() - 1] ? null : (
<>
<Text category="c2" style={styles.label}>
{translate('schedule.lunch')}
{t('schedule.lunch')}
</Text>
<Text>{menu[moment().isoWeekday() - 1]?.description}</Text>
</>
@ -175,14 +176,14 @@ export const ChildListItem = ({ child, color }: ChildListItemProps) => {
<Button
accessible
accessibilityRole="button"
accessibilityLabel={`${child.name}, ${translate('abscense.title')}`}
accessibilityLabel={`${child.name}, ${t('abscense.title')}`}
appearance="ghost"
accessoryLeft={AlertIcon}
status="primary"
style={styles.absenceButton}
onPress={() => navigation.navigate('Absence', { child })}
>
{translate('abscense.title')}
{t('abscense.title')}
</Button>
</View>
</View>

View File

@ -1,4 +1,3 @@
import AppStorage from '../services/appStorage'
import { useNavigation } from '@react-navigation/core'
import { useApi, useChildList } from '@skolplattformen/api-hooks'
import { Child } from '@skolplattformen/embedded-api'
@ -11,7 +10,7 @@ import {
TopNavigationAction,
useStyleSheet,
} from '@ui-kitten/components'
import React, { useCallback, useEffect, useMemo } from 'react'
import React, { useCallback, useEffect } from 'react'
import {
Image,
ImageStyle,
@ -20,12 +19,12 @@ import {
View,
} from 'react-native'
import { NativeStackNavigationOptions } from 'react-native-screens/native-stack'
import ActionSheet from 'rn-actionsheet-module'
import { defaultStackStyling } from '../design/navigationThemes'
import AppStorage from '../services/appStorage'
import { Colors, Layout as LayoutStyle, Sizing, Typography } from '../styles'
import { translate } from '../utils/translation'
import { ChildListItem } from './childListItem.component'
import { CloseOutlineIcon } from './icon.component'
import { SettingsIcon } from './icon.component'
const colors = ['primary', 'success', 'info', 'warning', 'danger']
@ -54,56 +53,18 @@ export const Children = () => {
AppStorage.clearTemporaryItems().then(() => api.logout())
}, [api])
const logoutAndClearPersonalData = useCallback(() => {
api
.getUser()
.then((user) => AppStorage.clearPersonalData(user))
.then(() => AppStorage.clearTemporaryItems().then(() => api.logout()))
}, [api])
const logoutAndClearAll = useCallback(() => {
AppStorage.nukeAllStorage().then(() => api.logout())
}, [api])
const settingsOptions = useMemo(() => {
return [
translate('general.logout'),
translate('general.logoutAndClearPersonalData'),
translate('general.logoutAndClearAllDataInclSettings'),
translate('general.cancel'),
]
}, [])
const handleSettingSelection = useCallback(
(index: number) => {
if (index === 0) logout()
if (index === 1) logoutAndClearPersonalData()
if (index === 2) logoutAndClearAll()
},
[logout, logoutAndClearAll, logoutAndClearPersonalData]
)
const settings = useCallback(() => {
const options = {
cancelButtonIndex: settingsOptions.length - 1,
title: translate('general.settings'),
optionsIOS: settingsOptions,
optionsAndroid: settingsOptions,
onCancelAndroidIndex: handleSettingSelection,
}
ActionSheet(options, handleSettingSelection)
}, [handleSettingSelection, settingsOptions])
useEffect(() => {
navigation.setOptions({
headerLeft: () => {
return (
<TopNavigationAction icon={CloseOutlineIcon} onPress={settings} />
<TopNavigationAction
icon={SettingsIcon}
onPress={() => navigation.navigate('Settings')}
/>
)
},
})
}, [navigation, settings])
}, [navigation])
// We need to skip safe area view here, due to the reason that it's adding a white border
// when this view is actually lightgrey. Taking the padding top value from the use inset hook.

View File

@ -6,6 +6,7 @@ const uiIcon = (name: string) => (props: IconProps) =>
export const AlertIcon = uiIcon('alert-circle-outline')
export const BackIcon = uiIcon('arrow-back')
export const BrushIcon = uiIcon('brush')
export const CalendarOutlineIcon = uiIcon('calendar-outline')
export const CallIcon = uiIcon('phone-outline')
export const CheckIcon = uiIcon('checkmark-outline')
@ -29,3 +30,5 @@ export const GlobeIcon = uiIcon('globe-outline')
export const ExternalLinkIcon = uiIcon('external-link-outline')
export const ClipboardIcon = uiIcon('clipboard-outline')
export const RightArrowIcon = uiIcon('arrow-ios-forward-outline')
export const QuestionMarkIcon = uiIcon('question-mark')
export const AwardIcon = uiIcon('award')

View File

@ -0,0 +1,87 @@
import { RouteProp, useRoute } from '@react-navigation/native'
import { StyleService, Text, useStyleSheet } from '@ui-kitten/components'
import React from 'react'
import { Linking, Platform } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { NativeStackNavigationOptions } from 'react-native-screens/native-stack'
import { Layout, Sizing, Typography } from '../styles'
import { fontSize } from '../styles/typography'
import { RootStackParamList } from './navigation.component'
type LibraryRouteProp = RouteProp<RootStackParamList, 'Library'>
export const libraryRouteOptions = (): NativeStackNavigationOptions => {
return {
title: '',
headerLargeTitle: false,
}
}
export const LibraryScreen = () => {
const styles = useStyleSheet(themedStyles)
const route = useRoute<LibraryRouteProp>()
const library = route.params.library
return (
<ScrollView
contentContainerStyle={styles.article}
style={styles.scrollView}
>
<Text style={styles.title}>
{library.libraryName}
<Text style={styles.version}> (v{library.version})</Text>
</Text>
{library._description && (
<Text style={styles.description}>{library._description}</Text>
)}
<Text style={styles.license}>
{library._licenseContent ?? library._license?.toString()}
</Text>
{library.homepage && (
<Text
style={styles.link}
onPress={() => Linking.openURL(library.homepage ?? '')}
>
{library.homepage}
</Text>
)}
</ScrollView>
)
}
const themedStyles = StyleService.create({
title: {
...Typography.fontWeight.bold,
fontSize: 30,
marginBottom: Sizing.t4,
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
fontWeight: '700',
},
version: {
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
...fontSize.lg,
fontWeight: '700',
color: 'text-hint-color',
},
link: {
fontWeight: '700',
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
textDecorationColor: 'text-hint-color',
textDecorationLine: 'underline',
},
description: {
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
fontWeight: '700',
marginBottom: Sizing.t4,
},
license: {
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
marginBottom: Sizing.t4,
},
article: {
padding: Sizing.t5,
},
scrollView: {
...Layout.flex.full,
},
})

View File

@ -0,0 +1,46 @@
import { StyleService, useStyleSheet } from '@ui-kitten/components'
import { Library } from 'libraries.json'
import React, { useCallback } from 'react'
import { FlatList, ListRenderItemInfo } from 'react-native'
import { Layout as LayoutStyle, Sizing } from '../styles'
import { LibraryListItem } from './libraryListItem.component'
import { SettingListSeparator } from './settingsComponents.component'
export const LibraryList = ({ libraries }: { libraries: Library[] }) => {
const styles = useStyleSheet(themedStyles)
const renderItem = useCallback(
({ item: library }: ListRenderItemInfo<Library>) => (
<LibraryListItem library={library} />
),
[]
)
const keyExtractor = useCallback((library: Library) => {
return `${library.libraryName}:${library.version}`
}, [])
return (
<FlatList
data={libraries}
renderItem={renderItem}
keyExtractor={keyExtractor}
ItemSeparatorComponent={SettingListSeparator}
style={styles.list}
contentContainerStyle={styles.container}
initialNumToRender={15}
/>
)
}
const themedStyles = StyleService.create({
list: {
...LayoutStyle.flex.full,
paddingHorizontal: Sizing.t4,
marginBottom: Sizing.t5,
},
container: {
borderRadius: 15,
backgroundColor: 'background-basic-color-1',
overflow: 'hidden',
},
})

View File

@ -0,0 +1,54 @@
import { NavigationProp, useNavigation } from '@react-navigation/core'
import { StyleService, Text, useStyleSheet } from '@ui-kitten/components'
import { Library } from 'libraries.json'
import React from 'react'
import { Platform, View } from 'react-native'
import { fontSize } from '../styles/typography'
import { RootStackParamList } from './navigation.component'
import { SettingListItem } from './settingsComponents.component'
export const LibraryListItem = ({ library }: { library: Library }) => {
const styles = useStyleSheet(themedStyles)
const navigation = useNavigation<NavigationProp<RootStackParamList>>()
return (
<SettingListItem
onNavigate={() => navigation.navigate('Library', { library })}
>
<View style={styles.container}>
<Text style={styles.name}>{library.libraryName}</Text>
<View style={styles.bottomRow}>
<Text style={styles.version}>v{library.version}</Text>
<Text style={styles.license}>
{library._license?.toString() ?? 'Unknown'}
</Text>
</View>
</View>
</SettingListItem>
)
}
const themedStyles = StyleService.create({
container: {},
name: {
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
fontWeight: '700',
},
license: {
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
fontWeight: '700',
marginLeft: 10,
color: 'text-hint-color',
...fontSize.sm,
},
version: {
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
minWidth: 55,
fontWeight: '700',
color: 'text-hint-color',
...fontSize.sm,
},
bottomRow: {
marginTop: 4,
flexDirection: 'row',
},
})

View File

@ -21,11 +21,10 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native'
import useSettingsStorage from '../hooks/useSettingsStorage'
import AppStorage from '../services/appStorage'
import { schema } from '../app.json'
import useSettingsStorage from '../hooks/useSettingsStorage'
import { useTranslation } from '../hooks/useTranslation'
import { Layout } from '../styles'
import { translate } from '../utils/translation'
import {
CheckIcon,
CloseOutlineIcon,
@ -49,63 +48,21 @@ export const Login = () => {
const [visible, showModal] = useState(false)
const [showLoginMethod, setShowLoginMethod] = useState(false)
const [error, setError] = useState<string | null>(null)
const [personalIdNumber, setPersonalIdNumber] = useState('')
const [valid, setValid] = useState(false)
const [loginMethodIndex, setLoginMethodIndex] = useState(0)
const [cachedLoginMethodIndex, setCachedLoginMethodIndex] =
useSettingsStorage('loginMethodIndex', '0')
const [personalIdNumber, setPersonalIdNumber] = useSettingsStorage(
'cachedPersonalIdentityNumber'
)
const [loginMethodIndex, setLoginMethodIndex] =
useSettingsStorage('loginMethodIndex')
const { t } = useTranslation()
const valid = Personnummer.valid(personalIdNumber)
const loginMethods = [
translate('auth.bankid.OpenOnThisDevice'),
translate('auth.bankid.OpenOnAnotherDevice'),
translate('auth.loginAsTestUser'),
t('auth.bankid.OpenOnThisDevice'),
t('auth.bankid.OpenOnAnotherDevice'),
t('auth.loginAsTestUser'),
]
useEffect(() => {
if (loginMethodIndex !== parseInt(cachedLoginMethodIndex, 10)) {
setCachedLoginMethodIndex(loginMethodIndex.toString())
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loginMethodIndex])
useEffect(() => {
if (loginMethodIndex !== parseInt(cachedLoginMethodIndex, 10)) {
setLoginMethodIndex(parseInt(cachedLoginMethodIndex, 10))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedLoginMethodIndex])
useEffect(() => {
setValid(Personnummer.valid(personalIdNumber))
}, [personalIdNumber])
useEffect(() => {
async function SetPersonalIdNumberIfSaved() {
const storedPersonalIdNumber = await AppStorage.getSetting<string>(
'cachedPersonalIdentityNumber'
)
if (storedPersonalIdNumber) {
setPersonalIdNumber(storedPersonalIdNumber)
}
}
SetPersonalIdNumberIfSaved()
}, [])
useEffect(() => {
async function SavePersonalIdNumber(numberToSave: string) {
if (numberToSave) {
await AppStorage.setSetting(
'cachedPersonalIdentityNumber',
numberToSave
)
}
}
SavePersonalIdNumber(personalIdNumber)
}, [personalIdNumber])
const loginHandler = async () => {
showModal(false)
}
@ -119,7 +76,6 @@ export const Login = () => {
/* Helpers */
const handleInput = (text: string) => {
setValid(Personnummer.valid(text))
setPersonalIdNumber(text)
}
@ -132,7 +88,7 @@ export const Login = () => {
: `bankid:///?autostarttoken=${token}&redirect=null`
Linking.openURL(bankIdUrl)
} catch (err) {
setError(translate('auth.bankid.OpenManually'))
setError(t('auth.bankid.OpenManually'))
}
}
@ -154,7 +110,7 @@ export const Login = () => {
status.on('PENDING', () => console.log('BankID app not yet opened'))
status.on('USER_SIGN', () => console.log('BankID app is open'))
status.on('ERROR', () => {
setError(translate('auth.loginFailed'))
setError(t('auth.loginFailed'))
showModal(false)
})
status.on('OK', () => console.log('BankID ok'))
@ -171,7 +127,7 @@ export const Login = () => {
{loginMethodIndex === 1 && (
<Input
accessible={true}
label={translate('general.socialSecurityNumber')}
label={t('general.socialSecurityNumber')}
autoFocus
value={personalIdNumber}
style={styles.pnrInput}
@ -180,7 +136,7 @@ export const Login = () => {
<TouchableWithoutFeedback
accessible={true}
onPress={() => handleInput('')}
accessibilityHint={translate(
accessibilityHint={t(
'login.a11y_clear_social_security_input_field',
{
defaultValue: 'Rensa fältet för personnummer',
@ -194,7 +150,7 @@ export const Login = () => {
onSubmitEditing={(event) => startLogin(event.nativeEvent.text)}
caption={error || ''}
onChangeText={(text) => handleInput(text)}
placeholder={translate('auth.placeholder_SocialSecurityNumber')}
placeholder={t('auth.placeholder_SocialSecurityNumber')}
/>
)}
<ButtonGroup style={styles.loginButtonGroup} status="primary">
@ -220,7 +176,7 @@ export const Login = () => {
status="primary"
accessoryLeft={SelectIcon}
size="medium"
accessibilityHint={translate('login.a11y_select_login_method', {
accessibilityHint={t('login.a11y_select_login_method', {
defaultValue: 'Välj inloggningsmetod',
})}
/>
@ -234,7 +190,7 @@ export const Login = () => {
>
<Card>
<Text category="h5" style={styles.bankIdLoading}>
{translate('auth.chooseLoginMethod')}
{t('auth.chooseLoginMethod')}
</Text>
<List
data={loginMethods}
@ -260,7 +216,7 @@ export const Login = () => {
setShowLoginMethod(false)
}}
>
{translate('general.cancel')}
{t('general.cancel')}
</Button>
</Card>
</Modal>
@ -271,9 +227,7 @@ export const Login = () => {
backdropStyle={styles.backdrop}
>
<Card disabled>
<Text style={styles.bankIdLoading}>
{translate('auth.bankid.Waiting')}
</Text>
<Text style={styles.bankIdLoading}>{t('auth.bankid.Waiting')}</Text>
<Button
status="primary"
@ -283,7 +237,7 @@ export const Login = () => {
showModal(false)
}}
>
{translate('general.cancel')}
{t('general.cancel')}
</Button>
</Card>
</Modal>

View File

@ -5,6 +5,7 @@ import {
NewsItem as NewsItemType,
} from '@skolplattformen/embedded-api'
import { useTheme } from '@ui-kitten/components'
import { Library } from 'libraries.json'
import React, { useEffect } from 'react'
import { StatusBar, useColorScheme } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
@ -14,16 +15,42 @@ import {
lightNavigationTheme,
} from '../design/navigationThemes'
import { useAppState } from '../hooks/useAppState'
import { useLangCode } from '../hooks/useLangCode'
import useSettingsStorage, {
initializeSettingsState,
} from '../hooks/useSettingsStorage'
import { isRTL } from '../services/languageService'
import Absence, { absenceRouteOptions } from './absence.component'
import { Auth, authRouteOptions } from './auth.component'
import { Child, childRouteOptions } from './child.component'
import { childenRouteOptions, Children } from './children.component'
import { libraryRouteOptions, LibraryScreen } from './library.component'
import { NewsItem, newsItemRouteOptions } from './newsItem.component'
import { SetLanguage, setLanguageRouteOptions } from './setLanguage.component'
import { settingsRouteOptions, SettingsScreen } from './settings.component'
import {
settingsAppearanceRouteOptions,
SettingsAppearanceScreen,
} from './settingsAppearance.component'
import {
settingsAppearanceThemeRouteOptions,
SettingsAppearanceThemeScreen,
} from './settingsAppearanceTheme.component'
import {
settingsLicensesRouteOptions,
SettingsLicensesScreen,
} from './settingsLicenses.component'
export type RootStackParamList = {
Login: undefined
Children: undefined
Settings: undefined
SettingsAppearance: undefined
SettingsAppearanceTheme: undefined
SettingsLicenses: undefined
Library: {
library: Library
}
Child: {
child: ChildType
color: string
@ -48,11 +75,20 @@ const linking = {
export const AppNavigator = () => {
const { isLoggedIn, api } = useApi()
const colorScheme = useColorScheme()
const [usingSystemTheme] = useSettingsStorage('usingSystemTheme')
const [theme] = useSettingsStorage('theme')
const systemTheme = useColorScheme()
const colorScheme = usingSystemTheme ? systemTheme : theme
const langCode = useLangCode()
const colors = useTheme()
const currentAppState = useAppState()
useEffect(() => {
initializeSettingsState()
}, [])
useEffect(() => {
const checkUser = async () => {
if (currentAppState === 'active' && isLoggedIn) {
@ -76,9 +112,16 @@ export const AppNavigator = () => {
<StatusBar />
<Navigator
screenOptions={() => ({
headerLargeTitle: false,
headerLargeTitle: true,
headerLargeTitleHideShadow: true,
direction: isRTL(langCode) ? 'rtl' : 'ltr',
headerStyle: {
backgroundColor:
colorScheme === 'dark'
? colors['background-basic-color-2']
: colors['background-basic-color-1'],
},
headerLargeStyle: {
backgroundColor: colors['background-basic-color-2'],
},
headerLargeTitleStyle: {
@ -112,13 +155,38 @@ export const AppNavigator = () => {
) : (
<>
<Screen name="Login" component={Auth} options={authRouteOptions} />
<Screen
name="SetLanguage"
component={SetLanguage}
options={setLanguageRouteOptions}
/>
</>
)}
<Screen
name="SetLanguage"
component={SetLanguage}
options={setLanguageRouteOptions}
/>
<Screen
name="Settings"
component={SettingsScreen}
options={settingsRouteOptions}
/>
<Screen
name="SettingsAppearance"
component={SettingsAppearanceScreen}
options={settingsAppearanceRouteOptions}
/>
<Screen
name="SettingsAppearanceTheme"
component={SettingsAppearanceThemeScreen}
options={settingsAppearanceThemeRouteOptions}
/>
<Screen
name="SettingsLicenses"
component={SettingsLicensesScreen}
options={settingsLicensesRouteOptions}
/>
<Screen
name="Library"
component={LibraryScreen}
options={libraryRouteOptions}
/>
</Navigator>
</NavigationContainer>
)

View File

@ -3,12 +3,10 @@ import {
Button,
ButtonGroup,
StyleService,
Text,
useStyleSheet,
useTheme,
} from '@ui-kitten/components'
import React, { useState } from 'react'
import { View, TouchableOpacity } from 'react-native'
import { View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import RNRestart from 'react-native-restart'
import { SafeAreaView } from 'react-native-safe-area-context'
@ -16,9 +14,11 @@ import { NativeStackNavigationOptions } from 'react-native-screens/native-stack'
import { useLanguage } from '../hooks/useLanguage'
import { isRTL, LanguageService } from '../services/languageService'
import { Layout as LayoutStyle, Sizing } from '../styles'
import { fontSize } from '../styles/typography'
import { languages, translate } from '../utils/translation'
import { CheckIcon } from './icon.component'
import {
SettingGroup,
SettingListItemSelectable,
} from './settingsComponents.component'
export const setLanguageRouteOptions = (): NativeStackNavigationOptions => ({
title: translate('language.changeLanguage'),
@ -27,7 +27,6 @@ export const setLanguageRouteOptions = (): NativeStackNavigationOptions => ({
export const SetLanguage = () => {
const navigation = useNavigation()
const styles = useStyleSheet(themedStyles)
const colors = useTheme()
const currentLanguage = LanguageService.getLanguageCode()
@ -56,41 +55,27 @@ export const SetLanguage = () => {
const goBack = () => {
// Need to reset the view so it updates the language
navigation.navigate('Login', { rand: Math.random() })
navigation.navigate('Settings', { rand: Math.random() })
}
const activeLanguages = languages.filter((language) => language.active)
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView>
<View style={styles.content}>
<ScrollView contentContainerStyle={styles.scrollView}>
<SettingGroup>
<View style={styles.languageList}>
{activeLanguages.map((language) => (
<TouchableOpacity
<SettingListItemSelectable
key={language.langCode}
style={styles.languageButton}
onPress={() => setSelectedLanguage(language.langCode)}
>
<View>
<Text style={styles.languageButtonTitle}>
{language.languageLocalName}
</Text>
<Text style={styles.languageButtonSubtitle}>
{language.languageName}
</Text>
</View>
{isSelected(language.langCode) ? (
<CheckIcon
height={24}
width={24}
fill={colors['color-success-600']}
/>
) : null}
</TouchableOpacity>
title={language.languageLocalName}
subTitle={language.languageName}
isSelected={isSelected(language.langCode)}
/>
))}
</View>
</View>
</SettingGroup>
</ScrollView>
<ButtonGroup style={styles.buttonGroup}>
<Button
@ -114,6 +99,7 @@ const themedStyles = StyleService.create({
alignSelf: 'stretch',
flexDirection: 'column',
marginTop: 8,
paddingHorizontal: Sizing.t4,
},
icon: {
width: 30,
@ -121,32 +107,14 @@ const themedStyles = StyleService.create({
},
container: {
flex: 1,
backgroundColor: 'background-basic-color-2',
},
content: {
...LayoutStyle.center,
...LayoutStyle.flex.full,
margin: Sizing.t5,
paddingBottom: Sizing.t5,
scrollView: {
padding: Sizing.t4,
},
buttonGroup: {
minHeight: 45,
marginTop: 20,
marginHorizontal: Sizing.t5,
},
languageButton: {
minHeight: 45,
marginBottom: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
languageButtonTitle: {
...fontSize.lg,
},
languageButtonSubtitle: {
...fontSize.sm,
color: 'text-hint-color',
},
button: { ...LayoutStyle.flex.full },
})

View File

@ -0,0 +1,85 @@
import { NavigationProp, useNavigation } from '@react-navigation/core'
import { useApi } from '@skolplattformen/api-hooks'
import React, { useCallback } from 'react'
import { ScrollView } from 'react-native'
import { NativeStackNavigationOptions } from 'react-native-screens/native-stack'
import useSettingsStorage from '../hooks/useSettingsStorage'
import AppStorage from '../services/appStorage'
import { LanguageService } from '../services/languageService'
import { Layout as LayoutStyle, Sizing } from '../styles'
import { languages, translate } from '../utils/translation'
import { AwardIcon, BrushIcon, GlobeIcon } from './icon.component'
import { RootStackParamList } from './navigation.component'
import {
SettingGroup,
SettingListItem,
SettingListSeparator,
} from './settingsComponents.component'
import { VersionInfo } from './versionInfo.component'
export const settingsRouteOptions = (): NativeStackNavigationOptions => ({
title: translate('settings.settings'),
})
export const SettingsScreen = () => {
const [isUsingSystemTheme] = useSettingsStorage('usingSystemTheme')
const [settingsTheme] = useSettingsStorage('theme')
const navigation = useNavigation<NavigationProp<RootStackParamList>>()
const langCode = LanguageService.getLanguageCode()
const language = languages.find((l) => l.langCode === langCode)
const { api } = useApi()
const logout = useCallback(async () => {
await AppStorage.clearTemporaryItems()
await api.logout()
navigation.reset({
routes: [{ name: 'Login' }],
})
}, [api, navigation])
return (
<ScrollView
style={LayoutStyle.flex.full}
contentContainerStyle={{
padding: Sizing.t4,
}}
>
<SettingGroup>
<SettingListItem
label={translate('settings.appearance')}
value={
isUsingSystemTheme
? translate('settings.themeAuto')
: translate(`themes.${settingsTheme}`)
}
icon={BrushIcon}
onNavigate={() => navigation.navigate('SettingsAppearance')}
/>
<SettingListSeparator />
<SettingListItem
label={translate('settings.language')}
value={language?.languageLocalName}
icon={GlobeIcon}
onNavigate={() => navigation.navigate('SetLanguage')}
/>
</SettingGroup>
<SettingGroup>
<SettingListItem
label={translate('settings.licenses')}
icon={AwardIcon}
onNavigate={() => navigation.navigate('SettingsLicenses')}
/>
</SettingGroup>
{api.isLoggedIn && (
<SettingGroup>
<SettingListItem
label={translate('general.logout')}
onPress={logout}
/>
</SettingGroup>
)}
<VersionInfo />
</ScrollView>
)
}

View File

@ -0,0 +1,58 @@
import { NavigationProp, useNavigation } from '@react-navigation/core'
import React from 'react'
import { ScrollView, StyleSheet, Switch } from 'react-native'
import { NativeStackNavigationOptions } from 'react-native-screens/native-stack'
import useSettingsStorage from '../hooks/useSettingsStorage'
import { Layout as LayoutStyle, Sizing } from '../styles'
import { translate } from '../utils/translation'
import { RootStackParamList } from './navigation.component'
import {
SettingGroup,
SettingListItem,
SettingListSeparator,
} from './settingsComponents.component'
export const settingsAppearanceRouteOptions =
(): NativeStackNavigationOptions => ({
title: translate('settings.appearance'),
})
export const SettingsAppearanceScreen = () => {
const [isUsingSystemTheme, setUsingSystemTheme] =
useSettingsStorage('usingSystemTheme')
const navigation = useNavigation<NavigationProp<RootStackParamList>>()
const [settingsTheme] = useSettingsStorage('theme')
return (
<ScrollView
style={LayoutStyle.flex.full}
contentContainerStyle={styles.container}
>
<SettingGroup>
<SettingListItem label={translate('settings.useSystemTheme')}>
<Switch
value={isUsingSystemTheme}
onValueChange={setUsingSystemTheme}
/>
</SettingListItem>
{!isUsingSystemTheme && (
<>
<SettingListSeparator />
<SettingListItem
label={translate('settings.theme')}
value={translate(`themes.${settingsTheme}`)}
onNavigate={() => navigation.navigate('SettingsAppearanceTheme')}
/>
</>
)}
</SettingGroup>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
padding: Sizing.t4,
},
})

View File

@ -0,0 +1,52 @@
import React from 'react'
import { ScrollView, StyleSheet, View } from 'react-native'
import { NativeStackNavigationOptions } from 'react-native-screens/native-stack'
import useSettingsStorage from '../hooks/useSettingsStorage'
import { Layout as LayoutStyle, Sizing } from '../styles'
import { translate } from '../utils/translation'
import {
SettingGroup,
SettingListItemSelectable,
} from './settingsComponents.component'
export const settingsAppearanceThemeRouteOptions =
(): NativeStackNavigationOptions => ({
title: translate('settings.theme'),
})
const themes = ['light', 'dark']
export const SettingsAppearanceThemeScreen = () => {
const [settingsTheme, setSettingsTheme] = useSettingsStorage('theme')
return (
<ScrollView
style={LayoutStyle.flex.full}
contentContainerStyle={styles.container}
>
<SettingGroup>
<View style={styles.themeList}>
{themes.map((theme) => {
return (
<SettingListItemSelectable
key={theme}
onPress={() => setSettingsTheme(theme)}
title={translate(`themes.${theme}`)}
isSelected={theme === settingsTheme}
/>
)
})}
</View>
</SettingGroup>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
padding: Sizing.t4,
},
themeList: {
paddingHorizontal: Sizing.t4,
},
})

View File

@ -0,0 +1,194 @@
import {
IconProps,
StyleService,
Text,
useStyleSheet,
useTheme,
} from '@ui-kitten/components'
import React, { useState } from 'react'
import { Pressable, TouchableOpacity, View } from 'react-native'
import { useLangRTL } from '../hooks/useLangRTL'
import { Sizing } from '../styles'
import { fontSize } from '../styles/typography'
import { CheckIcon, RightArrowIcon } from './icon.component'
export const SettingListItem = ({
label,
value,
icon: Icon,
onNavigate,
onPress,
children,
}: {
label?: string
value?: string
icon?: (props: IconProps) => JSX.Element
onNavigate?: () => void
onPress?: () => void
children?: React.ReactNode
}) => {
const textHintColor = useTheme()['text-hint-color']
const styles = useStyleSheet(themedStyles)
const isRTL = useLangRTL()
const [isPressing, setIsPressing] = useState(false)
return (
<Pressable
onPress={onNavigate || onPress}
onPressIn={() => setIsPressing(true)}
onPressOut={() => setIsPressing(false)}
>
<SettingListItemWrapper
isPressing={(onNavigate || onPress) && isPressing}
>
{Icon && (
<View style={styles.icon}>
<Icon width="24" height="24" fill="#fff" />
</View>
)}
<View style={styles.listItemText}>
{label && (
<Text
style={[styles.listItemLabel, onPress && styles.listItemButton]}
numberOfLines={1}
>
{label}
</Text>
)}
{value && <Text style={styles.listItemValue}>{value}</Text>}
{children}
</View>
{onNavigate && (
<View
style={[
styles.arrow,
{ transform: [{ rotateY: isRTL ? '180deg' : '0deg' }] },
]}
>
<RightArrowIcon width="24" height="24" fill={textHintColor} />
</View>
)}
</SettingListItemWrapper>
</Pressable>
)
}
export const SettingListSeparator = () => {
const styles = useStyleSheet(themedStyles)
return <View style={styles.separator} />
}
export const SettingListItemWrapper = ({
children,
isPressing = false,
}: {
isPressing?: boolean
children?: React.ReactNode
}) => {
const styles = useStyleSheet(themedStyles)
return (
<View style={[styles.listItem, isPressing ? styles.listItemPressed : null]}>
{children}
</View>
)
}
export const SettingGroup = ({ children }: { children?: React.ReactNode }) => {
const styles = useStyleSheet(themedStyles)
return <View style={styles.group}>{children}</View>
}
export const SettingListItemSelectable = ({
title,
subTitle,
isSelected,
onPress,
}: {
title: string
subTitle?: string
isSelected?: boolean
onPress: () => void
}) => {
const styles = useStyleSheet(themedStyles)
const colors = useTheme()
return (
<TouchableOpacity style={styles.selectableButton} onPress={onPress}>
<View>
<Text style={styles.selectableButtonTitle}>{title}</Text>
{subTitle && (
<Text style={styles.selectableButtonSubtitle}>{subTitle}</Text>
)}
</View>
{isSelected ? (
<CheckIcon height={24} width={24} fill={colors['color-success-600']} />
) : null}
</TouchableOpacity>
)
}
const themedStyles = StyleService.create({
group: {
backgroundColor: 'background-basic-color-1',
borderRadius: 15,
marginBottom: Sizing.t5,
overflow: 'hidden',
},
listItem: {
paddingHorizontal: Sizing.t4,
paddingVertical: Sizing.t2,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
listItemButton: {
color: 'color-tab-focused',
},
listItemPressed: {
backgroundColor: 'color-separator',
},
listItemText: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
listItemLabel: {
...fontSize.sm,
},
listItemValue: {
...fontSize.xs,
color: 'text-hint-color',
flexShrink: 0,
},
separator: {
height: 1,
marginLeft: Sizing.t4,
backgroundColor: 'color-separator',
},
icon: {
backgroundColor: 'color-primary-500',
borderRadius: 5,
padding: 3,
marginRight: Sizing.t3,
},
arrow: { flexShrink: 0 },
selectableButton: {
paddingVertical: Sizing.t2,
minHeight: 45,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
selectableButtonTitle: {
...fontSize.base,
textAlign: 'left',
},
selectableButtonSubtitle: {
...fontSize.sm,
color: 'text-hint-color',
textAlign: 'left',
},
})

View File

@ -0,0 +1,14 @@
import React from 'react'
import { NativeStackNavigationOptions } from 'react-native-screens/native-stack'
import libraries from '../libraries.json'
import { translate } from '../utils/translation'
import { LibraryList } from './libraryList.component'
export const settingsLicensesRouteOptions =
(): NativeStackNavigationOptions => ({
title: `${translate('settings.licenses')}`,
})
export const SettingsLicensesScreen = () => {
return <LibraryList libraries={libraries} />
}

View File

@ -0,0 +1,20 @@
import { Text } from '@ui-kitten/components'
import React from 'react'
import { StyleSheet, View } from 'react-native'
import { getBuildNumber, getVersion } from 'react-native-device-info'
export const VersionInfo = () => {
return (
<View style={styles.container}>
<Text>
v{getVersion()} ({getBuildNumber()})
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
},
})

View File

@ -66,7 +66,7 @@
"color-danger-transparent-400": "rgba(186, 50, 127, 0.32)",
"color-danger-transparent-500": "rgba(186, 50, 127, 0.4)",
"color-danger-transparent-600": "rgba(186, 50, 127, 0.48)",
"background-basic-color-1": "#150A12",
"background-basic-color-1": "#0F1117",
"background-basic-color-2": "#030200",
"text-hint-color": "#B3BBCB",
"color-control-default": "#E5E7EB",
@ -78,5 +78,6 @@
"color-input-border": "$color-basic-300",
"color-tab-default": "$color-primary-50",
"color-tab-focused": "$color-primary-200",
"color-button-ghost-text": "$color-primary-200"
"color-button-ghost-text": "$color-primary-200",
"color-separator": "$color-basic-900"
}

View File

@ -83,8 +83,9 @@
"color-basic-text": "$color-primary-800",
"color-input-border": "$color-basic-800",
"background-basic-color-1": "#fff",
"background-basic-color-2": "#f7f9fc",
"background-basic-color-2": "#F2F1F6",
"color-tab-default": "$color-basic-700",
"color-tab-focused": "$color-primary-500",
"color-button-ghost-text": "$color-primary-500"
"color-button-ghost-text": "$color-primary-500",
"color-separator": "$color-basic-400"
}

View File

@ -1,59 +1,54 @@
import { renderHook, act } from '@testing-library/react-hooks'
import AsyncStorage from '@react-native-async-storage/async-storage'
import useSettingsStorage from '../useSettingsStorage'
import { act, renderHook } from '@testing-library/react-hooks'
import AppStorage from '../../services/appStorage'
import useSettingsStorage, { settingsState } from '../useSettingsStorage'
beforeEach(() => {
AsyncStorage.clear()
// TODO: This is a bit ugly. Should probably fix that.
settingsState.settings.theme = 'light'
})
const prefix = AppStorage.settingsStorageKeyPrefix
test('use key prefix on set', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useSettingsStorage('key', '')
useSettingsStorage('theme')
)
act(() => {
await act(async () => {
const [, setValue] = result.current
setValue('foo')
setValue('dark')
await waitForNextUpdate()
const data = await AsyncStorage.getItem(prefix + 'SETTINGS')
const parsed = JSON.parse(data ?? '')
expect(parsed.theme).toEqual('dark')
})
await waitForNextUpdate()
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(
JSON.stringify('foo')
)
})
test('return inital value if no set', async () => {
const { result } = renderHook(() => useSettingsStorage('key', 'initialValue'))
const [value] = result.current
expect(value).toEqual('initialValue')
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(null)
})
test('update value', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useSettingsStorage('key', 'initialValue')
useSettingsStorage('theme')
)
const [initValue, setValue] = result.current
act(() => {
setValue('update')
await act(async () => {
setValue('dark')
await waitForNextUpdate()
const [updateValue] = result.current
expect(initValue).toEqual('light')
expect(updateValue).toEqual('dark')
const data = await AsyncStorage.getItem(prefix + 'SETTINGS')
const parsed = JSON.parse(data ?? '')
expect(parsed.theme).toEqual('dark')
})
await waitForNextUpdate()
const [updateValue] = result.current
expect(initValue).toEqual('initialValue')
expect(updateValue).toEqual('update')
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(
JSON.stringify('update')
)
})

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useEffect, useState } from 'react'
export default function useAsyncStorage<T>(
storageKey: string,

View File

@ -0,0 +1,25 @@
import { useEffect, useRef, useState } from 'react'
import { LanguageService } from '../services/languageService'
const generateKey = () => {
return `${Date.now()}-${Math.random() * 1000}`
}
export const useLangCode = () => {
const [langCode, setLangCode] = useState(LanguageService.getLanguageCode())
const key = useRef(generateKey())
useEffect(() => {
const unsubscribe = LanguageService.onChange(
{ key: key.current },
(lang) => {
setLangCode(lang)
}
)
return () => unsubscribe()
}, [])
return langCode
}

View File

@ -0,0 +1,7 @@
import { isRTL } from '../services/languageService'
import { useLangCode } from './useLangCode'
export const useLangRTL = () => {
const langCode = useLangCode()
return isRTL(langCode)
}

View File

@ -1,10 +1,50 @@
import useAsyncStorage from './useAsyncStorage'
import { useCallback } from 'react'
import { proxy, subscribe, useSnapshot } from 'valtio'
import AppStorage from '../services/appStorage'
export default function useSettingsStorage<T>(
storageKey: string,
defaultValue: T
): [T, (val: T) => void] {
const settingsKey = AppStorage.settingsStorageKeyPrefix + storageKey
return useAsyncStorage(settingsKey, defaultValue)
export const settingsState = proxy({
hydrated: false,
settings: {
loginMethodIndex: 0,
usingSystemTheme: true,
theme: 'light',
cachedPersonalIdentityNumber: '',
},
})
export type Settings = typeof settingsState['settings']
const SETTINGS_STORAGE_KEY = 'SETTINGS'
subscribe(settingsState, () => {
AppStorage.setSetting(SETTINGS_STORAGE_KEY, settingsState.settings)
})
export const initializeSettingsState = async () => {
const settings = await AppStorage.getSetting<any>(SETTINGS_STORAGE_KEY)
settingsState.hydrated = true
if (settings) {
settingsState.settings = {
...settingsState.settings,
...settings,
}
}
}
export default function useSettingsStorage<
TKey extends keyof Settings,
TValue = Settings[TKey]
>(key: TKey) {
const { settings } = useSnapshot(settingsState)
const setter = useCallback(
(value: TValue) => {
settingsState.settings[key] = value as any
},
[key]
)
return [settings[key], setter] as const
}

View File

@ -0,0 +1,11 @@
import i18n from 'i18n-js'
import { useMemo } from 'react'
import { useLangCode } from './useLangCode'
export const useTranslation = () => {
const langCode = useLangCode()
const output = useMemo(() => {
return { t: i18n.t, langCode }
}, [langCode])
return output
}

View File

@ -287,8 +287,6 @@ PODS:
- React-jsi (= 0.65.1)
- React-perflogger (= 0.65.1)
- React-jsinspector (0.65.1)
- react-native-appearance (0.3.4):
- React
- react-native-cookies (5.0.1):
- React-Core
- react-native-restart (0.0.22):
@ -372,6 +370,8 @@ PODS:
- React
- RNDateTimePicker (3.4.3):
- React-Core
- RNDeviceInfo (8.3.3):
- React-Core
- RNDevMenu (4.0.2):
- React-Core
- React-Core/DevSupport
@ -461,7 +461,6 @@ DEPENDENCIES:
- React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
- react-native-appearance (from `../node_modules/react-native-appearance`)
- "react-native-cookies (from `../node_modules/@react-native-community/cookies`)"
- react-native-restart (from `../node_modules/react-native-restart`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
@ -483,6 +482,7 @@ DEPENDENCIES:
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)"
- "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
- RNDevMenu (from `../node_modules/react-native-dev-menu`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNLocalize (from `../node_modules/react-native-localize`)
@ -544,8 +544,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/jsiexecutor"
React-jsinspector:
:path: "../node_modules/react-native/ReactCommon/jsinspector"
react-native-appearance:
:path: "../node_modules/react-native-appearance"
react-native-cookies:
:path: "../node_modules/@react-native-community/cookies"
react-native-restart:
@ -588,6 +586,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/masked-view"
RNDateTimePicker:
:path: "../node_modules/@react-native-community/datetimepicker"
RNDeviceInfo:
:path: "../node_modules/react-native-device-info"
RNDevMenu:
:path: "../node_modules/react-native-dev-menu"
RNGestureHandler:
@ -635,7 +635,6 @@ SPEC CHECKSUMS:
React-jsi: 12913c841713a15f64eabf5c9ad98592c0ec5940
React-jsiexecutor: 43f2542aed3c26e42175b339f8d37fe3dd683765
React-jsinspector: 41e58e5b8e3e0bf061fdf725b03f2144014a8fb0
react-native-appearance: 0f0e5fc2fcef70e03d48c8fe6b00b9158c2ba8aa
react-native-cookies: ce50e42ace7cf0dd47769260ca5bbe8eee607e4e
react-native-restart: 733a51ad137f15b0f8dc34c4082e55af7da00979
react-native-safe-area-context: 584dc04881deb49474363f3be89e4ca0e854c057
@ -657,6 +656,7 @@ SPEC CHECKSUMS:
RNCAsyncStorage: 9b7605e899f9acb2fba33e87952c529731265453
RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489
RNDateTimePicker: d943800c936fb01c352fcfb70439550d2cb57092
RNDeviceInfo: cc7de0772378f85d8f36ae439df20f05c590a651
RNDevMenu: fd325b5554b61fe7f48d9205a3877cf5ee88cd7c
RNGestureHandler: a479ebd5ed4221a810967000735517df0d2db211
RNLocalize: 7f1e5792b65a839af55a9552d05b3558b66d017e

View File

@ -10,6 +10,6 @@ module.exports = {
],
testPathIgnorePatterns: ['__tests__/Classmates.test.js'],
transformIgnorePatterns: [
'node_modules/(?!(jest-)?@react-native|react-native|@react-native-community|react-navigation|@react-navigation/.*|@ui-kitten|rn-actionsheet-module/.*)',
'node_modules/(?!(jest-)?@react-native|react-native|@react-native-community|react-navigation|@react-navigation/.*|@ui-kitten/.*)',
],
}

View File

@ -0,0 +1,28 @@
// Filters the output from 'react-native-oss-license'.
const fs = require('fs').promises
const packageJson = require('./package.json')
const rnLicenses = require('./licenses-oss.json')
/**
* TOOD: Make this a bit more testable
*/
async function run() {
try {
const dependencies = Object.keys(packageJson.dependencies)
const result = rnLicenses.filter((pkg) => {
return dependencies.find((name) => pkg.libraryName === name)
})
await fs.writeFile(
'./libraries.json',
JSON.stringify(result, null, 2),
'utf-8'
)
} catch (e) {
console.error(e)
}
}
run()

View File

@ -12,7 +12,9 @@
"test:watch": "jest --watch",
"typecheck": "tsc --watch",
"i18n": "sync-i18n --files '**/translations/*.json' --primary en --languages ar de pl so sv --space 2",
"check-i18n": "npm run i18n -- --check"
"check-i18n": "npm run i18n -- --check",
"extract-licenses": "react-native-oss-license --json > licenses-oss.json && node library-extractor.js && rm licenses-oss.json",
"postinstall": "yarn extract-licenses"
},
"dependencies": {
"@eva-design/eva": "2.0.0",
@ -33,18 +35,16 @@
"deepmerge": "^4.2.2",
"fast-fuzzy": "^1.10.8",
"formik": "2.2.6",
"hermes-engine": "0.7.2",
"hermes-engine": "0.8.1",
"i18n-js": "^3.8.0",
"i18next-json-sync": "^2.3.1",
"jsuri": "1.3.1",
"moment": "^2.29.1",
"personnummer": "3.1.3",
"react": "17.0.2",
"react-native": "0.65.1",
"react-native-animatable": "^1.3.3",
"react-native-appearance": "^0.3.4",
"react-native-calendar-events": "2.2.0",
"react-native-dev-menu": "^4.0.2",
"react-native-device-info": "^8.3.3",
"react-native-fix-image": "2.1.0",
"react-native-gesture-handler": "^1.10.3",
"react-native-localize": "^2.0.2",
@ -56,12 +56,9 @@
"react-native-screens": "^3.3.0",
"react-native-simple-toast": "1.1.3",
"react-native-svg": "12.1.0",
"react-native-svg-transformer": "0.14.3",
"react-native-tab-view": "2.15.2",
"react-native-typography": "1.4.1",
"react-native-webview": "11.4.2",
"react-native-weekly-calendar": "^0.2.0",
"rn-actionsheet-module": "https://github.com/kolplattformen/rn-actionsheet-module.git",
"valtio": "^1.2.3",
"yup": "0.32.9"
},
"devDependencies": {
@ -92,6 +89,7 @@
"prettier": "^2.2.1",
"react-native-clean-project": "^3.6.3",
"react-native-codegen": "^0.0.7",
"react-native-oss-license": "^0.4.0",
"react-test-renderer": "17.0.2",
"typescript": "^4.2.4"
},

View File

@ -1,6 +1,5 @@
import { I18nManager } from 'react-native'
import i18n from 'i18n-js'
import merge from 'deepmerge'
import i18n from 'i18n-js'
import moment from 'moment'
import 'moment/locale/ar'
import 'moment/locale/de'
@ -9,12 +8,13 @@ import 'moment/locale/fi'
import 'moment/locale/fr'
import 'moment/locale/it'
import 'moment/locale/ja'
import 'moment/locale/uz-latn'
import 'moment/locale/nb'
import 'moment/locale/nl'
import 'moment/locale/pl'
import 'moment/locale/ru'
import 'moment/locale/sv'
import 'moment/locale/uz-latn'
import { I18nManager } from 'react-native'
const changeListeners: Record<string, any> = {}
@ -70,7 +70,12 @@ export const LanguageService = {
},
onChange: ({ key }: { key: string }, cb: (langCode: string) => void) => {
const unsubscribe = () => {
delete changeListeners[key]
}
changeListeners[key] = (langCode: string) => cb(langCode)
return unsubscribe
},
}

View File

@ -4,7 +4,7 @@ import { systemWeights } from 'react-native-typography'
type FontSize = 'xxs' | 'xs' | 'sm' | 'base' | 'lg' | 'xl'
export const fontSize: Record<FontSize, TextStyle> = {
xxs: {
fontSize: 8,
fontSize: 10,
},
xs: {
fontSize: 12,

View File

@ -95,6 +95,20 @@
"notifications": "Notifications",
"classmates": "Classmates"
},
"settings": {
"settings": "Settings",
"appearance": "Appearance",
"theme": "Theme",
"licenses": "Licenses",
"language": "Language",
"themeAuto": "Auto",
"useSystemTheme": "Use System Light/Dark Theme"
},
"themes": {
"light": "Light",
"dark": "Dark"
},
"news": {
"backToChild": "Back to child",
"noNewNewsItemsThisWeek": "No news this week.",

View File

@ -95,6 +95,19 @@
"notifications": "Aviseringar",
"classmates": "Klassen"
},
"settings": {
"settings": "Inställningar",
"appearance": "Utseende",
"theme": "Tema",
"licenses": "Licenser",
"language": "Språk",
"themeAuto": "Auto",
"useSystemTheme": "Använd telefonens inställning"
},
"themes": {
"light": "Ljust",
"dark": "Mörkt"
},
"news": {
"backToChild": "Tillbaka till barn",
"noNewNewsItemsThisWeek": "Inga nya inlägg denna vecka.",

View File

@ -1 +1,36 @@
declare module 'rn-actionsheet-module'
declare module 'libraries.json' {
export interface Library {
libraryName: string
version: string
_license?: License | string
_description?: string
homepage?: string
author?: Author | string
repository?: Repository
_licenseContent?: string
}
export interface License {
type: string
url: string
}
export interface Author {
name: string
url?: string
email?: string
}
export interface Repository {
type?: string
url: string
directory?: string
baseUrl?: string
web?: string
dist?: string
}
const libraries: Library[]
export default libraries
}

File diff suppressed because it is too large Load Diff