diff --git a/android/app/src/main/java/com/lunesappui/MainActivity.java b/android/app/src/main/java/app/lunes/MainActivity.java
similarity index 100%
rename from android/app/src/main/java/com/lunesappui/MainActivity.java
rename to android/app/src/main/java/app/lunes/MainActivity.java
diff --git a/android/app/src/main/java/com/lunesappui/MainApplication.java b/android/app/src/main/java/app/lunes/MainApplication.java
similarity index 99%
rename from android/app/src/main/java/com/lunesappui/MainApplication.java
rename to android/app/src/main/java/app/lunes/MainApplication.java
index 1f57ddeb2..77b1e028c 100644
--- a/android/app/src/main/java/com/lunesappui/MainApplication.java
+++ b/android/app/src/main/java/app/lunes/MainApplication.java
@@ -4,6 +4,7 @@
import android.content.Context;
import com.facebook.react.PackageList;
import com.facebook.react.ReactApplication;
+import com.rnfs.RNFSPackage;
import com.facebook.react.modules.i18nmanager.I18nUtil;
import com.reactnativecommunity.asyncstorage.AsyncStoragePackage;
import com.johnsonsu.rnsoundplayer.RNSoundPlayerPackage;
diff --git a/android/settings.gradle b/android/settings.gradle
index 8df3f753f..8e018b658 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -1,4 +1,6 @@
rootProject.name = 'lunes-app-android'
+include ':react-native-fs'
+project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android')
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
diff --git a/assets/images/circle-icon-white.svg b/assets/images/circle-icon-white.svg
new file mode 100644
index 000000000..90217c461
--- /dev/null
+++ b/assets/images/circle-icon-white.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/image-icon.svg b/assets/images/image-icon.svg
new file mode 100644
index 000000000..1bf2bc4f1
--- /dev/null
+++ b/assets/images/image-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/index.ts b/assets/images/index.ts
index 2a4bd8d3b..ef188ebca 100644
--- a/assets/images/index.ts
+++ b/assets/images/index.ts
@@ -19,6 +19,7 @@ import CheckCloseCircleIconBold from './check-close-circle-icon-bold.svg'
import CheckCloseCircleIcon from './check-close-circle-icon.svg'
import ChevronRight from './chevron-right.svg'
import CircleIconBlue from './circle-icon-blue.svg'
+import CircleIconWhite from './circle-icon-white.svg'
import CloseCircleIconBlue from './close-circle-icon-blue.svg'
import CloseCircleIconBold from './close-circle-icon-bold.svg'
import CloseCircleIconWhite from './close-circle-icon-white.svg'
@@ -41,6 +42,7 @@ import HomeCircleIconWhite from './home-circle-icon-white.svg'
import HomeIconGrey from './home-icon-grey.svg'
import HomeIconWhite from './home-icon-white.svg'
import ImageCircleIcon from './image-circle-icon.svg'
+import ImageIcon from './image-icon.svg'
import InfoCircleIcon from './info-circle-icon.svg'
import ListIcon from './list-icon.svg'
import LockIcon from './lock-icon.svg'
@@ -70,6 +72,8 @@ import VolumeUpCircleIcon from './volume-up-circle-icon.svg'
export {
AddCircleIcon,
+ CircleIconWhite,
+ ImageIcon,
AddIconWhite,
ArrowLeftCircleIconBlue,
ArrowLeftCircleIconWhite,
diff --git a/ios/Podfile b/ios/Podfile
index aedbd555a..835d0740e 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -25,6 +25,8 @@ target 'Lunes' do
permissions_path = '../node_modules/react-native-permissions/ios'
pod 'Permission-Camera', :path => "#{permissions_path}/Camera"
+ pod 'RNFS', :path => '../node_modules/react-native-fs'
+
target 'LunesTests' do
inherit! :complete
# Pods for testing
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 6d9acaae6..69b6909d5 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -360,6 +360,8 @@ PODS:
- React-Core
- RNDeviceInfo (9.0.2):
- React-Core
+ - RNFS (2.20.0):
+ - React-Core
- RNGestureHandler (2.5.0):
- React-Core
- RNPermissions (3.4.0):
@@ -450,6 +452,7 @@ DEPENDENCIES:
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
+ - RNFS (from `../node_modules/react-native-fs`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNPermissions (from `../node_modules/react-native-permissions`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
@@ -552,6 +555,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-masked-view/masked-view"
RNDeviceInfo:
:path: "../node_modules/react-native-device-info"
+ RNFS:
+ :path: "../node_modules/react-native-fs"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNPermissions:
@@ -624,6 +629,7 @@ SPEC CHECKSUMS:
RNCAsyncStorage: d81ee5c3db1060afd49ea7045ad460eff82d2b7d
RNCMaskedView: c298b644a10c0c142055b3ae24d83879ecb13ccd
RNDeviceInfo: 1e3f62b9ec32f7754fac60bd06b8f8a27124e7f0
+ RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: bad495418bcbd3ab47017a38d93d290ebd406f50
RNPermissions: eac721f71748c4472d6e876038270b75397d6ee7
RNReanimated: 11e6e30bbc49079b7f0c321ac5830e6d113d0224
@@ -636,6 +642,6 @@ SPEC CHECKSUMS:
TextToSpeech: b3aa777ff5585705f179c0a2436bfd0926d1716e
Yoga: e7dc4e71caba6472ff48ad7d234389b91dadc280
-PODFILE CHECKSUM: f0c4013aeb23d2c0b2b385496c8d12dc3493083b
+PODFILE CHECKSUM: 5ce2b856afff013c4abae6206a72de31a844f8af
COCOAPODS: 1.11.3
diff --git a/package.json b/package.json
index 5fb95dc08..420896c47 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"react-native-camera": "^4.2.1",
"react-native-device-info": "^9.0.2",
"react-native-dropdown-picker": "^5.4.2",
+ "react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.5.0",
"react-native-image-zoom-viewer": "^3.0.1",
"react-native-keyboard-aware-scroll-view": "^0.9.5",
diff --git a/src/__mocks__/react-native-fs.js b/src/__mocks__/react-native-fs.js
new file mode 100644
index 000000000..37b8960b1
--- /dev/null
+++ b/src/__mocks__/react-native-fs.js
@@ -0,0 +1 @@
+module.exports = { readFile: jest.fn(() => Promise.resolve('image')) }
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index dcae8b0d7..876abdd77 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -74,7 +74,7 @@ const Button = (props: ButtonProps): ReactElement => {
const getBackgroundColor = (): Color | 'transparent' => {
if (disabled) {
- return theme.colors.disabled
+ return buttonTheme === BUTTONS_THEME.text ? 'transparent' : theme.colors.disabled
}
if (isPressed) {
if (buttonTheme === BUTTONS_THEME.text) {
diff --git a/src/components/CameraOverlay.tsx b/src/components/CameraOverlay.tsx
new file mode 100644
index 000000000..9adfefa15
--- /dev/null
+++ b/src/components/CameraOverlay.tsx
@@ -0,0 +1,75 @@
+import React, { ReactElement, useEffect, useRef, useState } from 'react'
+import { AppState, Modal as RNModal, Platform } from 'react-native'
+import { PERMISSIONS, request, RESULTS } from 'react-native-permissions'
+import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
+import styled from 'styled-components/native'
+
+import { CloseCircleIconBlue, CloseCircleIconWhite } from '../../assets/images'
+import { reportError } from '../services/sentry'
+import NotAuthorisedView from './NotAuthorisedView'
+
+const Container = styled.SafeAreaView`
+ flex: 1;
+ background-color: ${props => props.theme.colors.background};
+`
+
+const Icon = styled.Pressable`
+ align-self: flex-end;
+ margin: ${props => `${props.theme.spacings.xs} ${props.theme.spacings.sm}`};
+ width: ${wp('7%')}px;
+ height: ${wp('7%')}px;
+`
+
+interface Props {
+ setVisible: (visible: boolean) => void
+ children: ReactElement
+}
+
+const CameraOverlay = ({ setVisible, children }: Props): ReactElement => {
+ const appState = useRef(AppState.currentState)
+
+ const [isPressed, setIsPressed] = useState(false)
+ const [permissionRequested, setPermissionRequested] = useState(false)
+ const [permissionGranted, setPermissionGranted] = useState(false)
+
+ // Needed when navigating back from settings, when users selected "ask every time" as camera permission option
+ useEffect(() => {
+ if (!permissionRequested) {
+ request(Platform.OS === 'ios' ? PERMISSIONS.IOS.CAMERA : PERMISSIONS.ANDROID.CAMERA)
+ .then(result => setPermissionGranted(result === RESULTS.GRANTED))
+ .catch(reportError)
+ .finally(() => setPermissionRequested(true))
+ }
+ }, [permissionRequested])
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', nextAppState => {
+ if ((appState.current === 'inactive' || appState.current === 'background') && nextAppState === 'active') {
+ setPermissionRequested(false)
+ }
+ appState.current = nextAppState
+ })
+ return subscription.remove
+ }, [])
+
+ return (
+ setVisible(false)}>
+
+ setVisible(false)}
+ onPressIn={() => setIsPressed(true)}
+ onPressOut={() => setIsPressed(false)}>
+ {isPressed ? (
+
+ ) : (
+
+ )}
+
+ {permissionGranted && children}
+ {permissionRequested && !permissionGranted && }
+
+
+ )
+}
+
+export default CameraOverlay
diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx
index 02cf68717..96c999260 100644
--- a/src/components/Dropdown.tsx
+++ b/src/components/Dropdown.tsx
@@ -78,6 +78,7 @@ const Dropdown = ({
fontFamily: theme.fonts.contentFontRegular,
paddingHorizontal: wp('2%'),
}}
+ listMode='SCROLLVIEW'
/>
{showErrorValidation && (
{errorMessage.length > 0 && {errorMessage}}
diff --git a/src/components/ListItem.tsx b/src/components/ListItem.tsx
index d47a6d5ad..2063b48ad 100644
--- a/src/components/ListItem.tsx
+++ b/src/components/ListItem.tsx
@@ -21,6 +21,7 @@ const Container = styled(GenericListItemContainer)<{
feedback: EXERCISE_FEEDBACK
}>`
min-height: ${hp('12%')}px;
+ width: 100%;
justify-content: center;
flex-direction: column;
border-top-width: 1px;
diff --git a/src/routes/add-custom-discipline/components/NotAuthorisedView.tsx b/src/components/NotAuthorisedView.tsx
similarity index 65%
rename from src/routes/add-custom-discipline/components/NotAuthorisedView.tsx
rename to src/components/NotAuthorisedView.tsx
index 312513871..47ce761c4 100644
--- a/src/routes/add-custom-discipline/components/NotAuthorisedView.tsx
+++ b/src/components/NotAuthorisedView.tsx
@@ -2,11 +2,11 @@ import React, { ReactElement } from 'react'
import { Linking } from 'react-native'
import styled from 'styled-components/native'
-import Button from '../../../components/Button'
-import { ContentSecondary } from '../../../components/text/Content'
-import { BUTTONS_THEME } from '../../../constants/data'
-import { getLabels } from '../../../services/helpers'
-import { reportError } from '../../../services/sentry'
+import { BUTTONS_THEME } from '../constants/data'
+import { getLabels } from '../services/helpers'
+import { reportError } from '../services/sentry'
+import Button from './Button'
+import { ContentSecondary } from './text/Content'
const Container = styled.View`
display: flex;
@@ -30,12 +30,12 @@ const NotAuthorisedView = ({ setVisible }: NotAuthorizedViewProps): ReactElement
}
return (
-
- {getLabels().addCustomDiscipline.qrCodeScanner.noAuthorization.description}
+
+ {getLabels().general.camera.noAuthorization.description}
diff --git a/src/components/__tests__/CameraOverlay.spec.tsx b/src/components/__tests__/CameraOverlay.spec.tsx
new file mode 100644
index 000000000..47defd67b
--- /dev/null
+++ b/src/components/__tests__/CameraOverlay.spec.tsx
@@ -0,0 +1,37 @@
+import { fireEvent } from '@testing-library/react-native'
+import React from 'react'
+import { Text } from 'react-native'
+
+import render from '../../testing/render'
+import CameraOverlay from '../CameraOverlay'
+
+jest.mock('react-native-permissions', () => require('react-native-permissions/mock'))
+
+describe('CameraOverlay', () => {
+ const setVisible = jest.fn()
+
+ it('should show close header with correct icon', async () => {
+ const { getByTestId, queryByTestId, findByTestId } = render(
+
+ Children
+
+ )
+ const closeIcon = await findByTestId('close-circle-icon-white')
+ expect(closeIcon).toBeDefined()
+ fireEvent(closeIcon, 'onPressIn')
+ expect(getByTestId('close-circle-icon-blue')).toBeDefined()
+ expect(queryByTestId('close-circle-icon-white')).toBeNull()
+ })
+
+ it('should close overlay on icon press', async () => {
+ const { findByTestId } = render(
+
+ Children
+
+ )
+ const closeIcon = await findByTestId('close-circle-icon-white')
+ expect(closeIcon).toBeDefined()
+ fireEvent.press(closeIcon)
+ expect(setVisible).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/src/constants/labels.json b/src/constants/labels.json
index ce245a0d4..10e43312e 100644
--- a/src/constants/labels.json
+++ b/src/constants/labels.json
@@ -42,12 +42,6 @@
"alreadyAdded": "Dieser Code wurde bereits erfolgreich hinzufügt.",
"wrongCode": "Dieser Code existiert nicht (ungültiger Code).",
"technical": "Ein technischer Fehler ist aufgetreten. Prüfen Sie Ihre Internetverbindung."
- },
- "qrCodeScanner": {
- "noAuthorization": {
- "description": "Kamerazugriff zum Scannen von QR-Codes erforderlich. Öffnen Sie die Einstellungen, tippen Sie auf Berechtigungen und tippen Sie auf Erlauben.",
- "settings": "Einstellungen"
- }
}
},
"exercises": {
@@ -164,7 +158,13 @@
"discipline": "Modul",
"disciplines": "Module",
"word": "Wort",
- "words": "Wörter"
+ "words": "Wörter",
+ "camera": {
+ "noAuthorization": {
+ "description": "Kamerazugriff erforderlich. Öffnen Sie die Einstellungen, tippen Sie auf Berechtigungen und tippen Sie auf Erlauben.",
+ "settings": "Einstellungen"
+ }
+ }
},
"favorites": "Favoriten",
"dictionary": {
@@ -199,11 +199,12 @@
"wordPlaceholder": "Wort eingeben*",
"articlePlaceholder": "Artikel auswählen*",
"saveButton": "Speichern",
- "addImage": "Bild hochladen*",
+ "addImage": "Bild hinzufügen*",
"addAudio": "Audio aufnehmen",
- "maxPictureUpload": "Du kannst bis zu 3 Bilder hochladen",
+ "maxPictureUpload": "Du kannst bis zu 3 Bilder hinzufügen",
"requiredFields": "*Pflichtfelder",
- "errorMessage": "Dies ist ein Pflichtfeld."
+ "errorMessage": "Dies ist ein Pflichtfeld.",
+ "imageErrorMessage": "Du musst 1 bis 3 Bilder auswählen"
},
"list": {
"edit": "Bearbeiten",
diff --git a/src/routes/add-custom-discipline/components/QRCodeReaderOverlay.tsx b/src/routes/add-custom-discipline/components/QRCodeReaderOverlay.tsx
index 7f4b973c2..a55e4fdfb 100644
--- a/src/routes/add-custom-discipline/components/QRCodeReaderOverlay.tsx
+++ b/src/routes/add-custom-discipline/components/QRCodeReaderOverlay.tsx
@@ -1,25 +1,8 @@
-import React, { ReactElement, useEffect, useRef, useState } from 'react'
-import { AppState, Modal as RNModal, Platform } from 'react-native'
+import React, { ReactElement } from 'react'
import { BarCodeReadEvent, RNCamera } from 'react-native-camera'
-import { PERMISSIONS, request, RESULTS } from 'react-native-permissions'
-import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
import styled from 'styled-components/native'
-import { CloseCircleIconBlue, CloseCircleIconWhite } from '../../../../assets/images'
-import { reportError } from '../../../services/sentry'
-import NotAuthorisedView from './NotAuthorisedView'
-
-const Container = styled.SafeAreaView`
- flex: 1;
- background-color: ${props => props.theme.colors.background};
-`
-
-const Icon = styled.Pressable`
- align-self: flex-end;
- margin: ${props => `${props.theme.spacings.xs} ${props.theme.spacings.sm}`};
- width: ${wp('7%')}px;
- height: ${wp('7%')}px;
-`
+import CameraOverlay from '../../../components/CameraOverlay'
const Camera = styled(RNCamera)`
flex: 1;
@@ -32,54 +15,15 @@ interface AddCustomDisciplineScreenProps {
}
const AddCustomDisciplineScreen = ({ setVisible, setCode }: AddCustomDisciplineScreenProps): ReactElement => {
- const appState = useRef(AppState.currentState)
-
- const [isPressed, setIsPressed] = useState(false)
- const [permissionRequested, setPermissionRequested] = useState(false)
- const [permissionGranted, setPermissionGranted] = useState(false)
-
const onBarCodeRead = (scanResult: BarCodeReadEvent) => {
setCode(scanResult.data)
setVisible(false)
}
- // Needed when navigating back from settings, when users selected "ask every time" as camera permission option
- useEffect(() => {
- if (!permissionRequested) {
- request(Platform.OS === 'ios' ? PERMISSIONS.IOS.CAMERA : PERMISSIONS.ANDROID.CAMERA)
- .then(result => setPermissionGranted(result === RESULTS.GRANTED))
- .catch(reportError)
- .finally(() => setPermissionRequested(true))
- }
- }, [permissionRequested])
-
- useEffect(() => {
- const subscription = AppState.addEventListener('change', nextAppState => {
- if ((appState.current === 'inactive' || appState.current === 'background') && nextAppState === 'active') {
- setPermissionRequested(false)
- }
- appState.current = nextAppState
- })
- return subscription.remove
- }, [])
-
return (
- setVisible(false)}>
-
- setVisible(false)}
- onPressIn={() => setIsPressed(true)}
- onPressOut={() => setIsPressed(false)}>
- {isPressed ? (
-
- ) : (
-
- )}
-
- {permissionGranted && }
- {permissionRequested && !permissionGranted && }
-
-
+
+
+
)
}
diff --git a/src/routes/add-custom-discipline/components/__tests__/QRCodeReaderOverlay.spec.tsx b/src/routes/add-custom-discipline/components/__tests__/QRCodeReaderOverlay.spec.tsx
index bfa8eb832..867f65f38 100644
--- a/src/routes/add-custom-discipline/components/__tests__/QRCodeReaderOverlay.spec.tsx
+++ b/src/routes/add-custom-discipline/components/__tests__/QRCodeReaderOverlay.spec.tsx
@@ -21,25 +21,6 @@ describe('QRCodeReaderOverlay', () => {
const setVisible = jest.fn()
const setCode = jest.fn()
- it('should show close header with correct icon', async () => {
- const { getByTestId, queryByTestId, findByTestId } = render(
-
- )
- const closeIcon = await findByTestId('close-circle-icon-white')
- expect(closeIcon).toBeDefined()
- fireEvent(closeIcon, 'onPressIn')
- expect(getByTestId('close-circle-icon-blue')).toBeDefined()
- expect(queryByTestId('close-circle-icon-white')).toBeNull()
- })
-
- it('should close overlay on icon press', async () => {
- const { findByTestId } = render()
- const closeIcon = await findByTestId('close-circle-icon-white')
- expect(closeIcon).toBeDefined()
- fireEvent.press(closeIcon)
- expect(setVisible).toHaveBeenCalledWith(false)
- })
-
it('should set text, when qr code is scanned', async () => {
const { findByLabelText } = render()
const camera = await findByLabelText('mockOnBarCodeRead')
diff --git a/src/routes/process-user-vocabulary/UserVocabularyProcessScreen.tsx b/src/routes/process-user-vocabulary/UserVocabularyProcessScreen.tsx
index 258595604..c5f4b58fb 100644
--- a/src/routes/process-user-vocabulary/UserVocabularyProcessScreen.tsx
+++ b/src/routes/process-user-vocabulary/UserVocabularyProcessScreen.tsx
@@ -1,24 +1,32 @@
+import { StackNavigationProp } from '@react-navigation/stack'
import React, { ReactElement, useState } from 'react'
+import { DocumentDirectoryPath, writeFile } from 'react-native-fs'
import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
import styled from 'styled-components/native'
-import { MicrophoneCircleIcon, ImageCircleIcon } from '../../../assets/images'
+import { ImageCircleIcon, MicrophoneCircleIcon } from '../../../assets/images'
import Button from '../../components/Button'
import CustomTextInput from '../../components/CustomTextInput'
import Dropdown from '../../components/Dropdown'
import RouteWrapper from '../../components/RouteWrapper'
import { TitleWithSpacing } from '../../components/Title'
+import { ContentError } from '../../components/text/Content'
import { HintText } from '../../components/text/Hint'
-import { BUTTONS_THEME, getArticleWithLabel } from '../../constants/data'
+import { ARTICLES, BUTTONS_THEME, getArticleWithLabel } from '../../constants/data'
+import { RoutesParams } from '../../navigation/NavigationTypes'
+import { addUserDocument, getNextUserVocabularyId, incrementNextUserVocabularyId } from '../../services/AsyncStorage'
import { getLabels } from '../../services/helpers'
+import { reportError } from '../../services/sentry'
+import ImageSelectionOverlay from './components/ImageSelectionOverlay'
+import Thumbnail from './components/Thumbnail'
-const Root = styled.View`
+const Root = styled.ScrollView`
padding: ${props => props.theme.spacings.md};
flex: 1;
`
const SaveButton = styled(Button)`
- margin-top: ${props => props.theme.spacings.lg};
+ margin: ${props => props.theme.spacings.lg} 0;
align-self: center;
`
@@ -27,6 +35,7 @@ const AddAudioButton = styled(Button)`
justify-content: flex-start;
padding: 0;
`
+
const AddImageButton = styled(AddAudioButton)`
margin-bottom: 0;
margin-top: ${props => props.theme.spacings.sm};
@@ -36,11 +45,23 @@ const StyledHintText = styled(HintText)`
margin-left: ${props => props.theme.spacings.xxl};
`
-const UserVocabularyProcessScreen = (): ReactElement => {
+const ThumbnailContainer = styled.View`
+ flex-direction: row;
+`
+
+interface UserVocabularyProcessScreenProps {
+ navigation: StackNavigationProp
+}
+
+const UserVocabularyProcessScreen = ({ navigation }: UserVocabularyProcessScreenProps): ReactElement => {
+ const [images, setImages] = useState([])
const [word, setWord] = useState('')
const [articleId, setArticleId] = useState(null)
const [hasWordErrorMessage, setHasWordErrorMessage] = useState(false)
const [hasArticleErrorMessage, setHasArticleErrorMessage] = useState(false)
+ const [hasImageErrorMessage, setHasImageErrorMessage] = useState(false)
+ const [showImageSelectionOverlay, setShowImageSelectionOverlay] = useState(false)
+
const {
headline,
addImage,
@@ -53,19 +74,54 @@ const UserVocabularyProcessScreen = (): ReactElement => {
requiredFields,
} = getLabels().userVocabulary.creation
- const onSave = (): void => {
- const hasError = word.length === 0 || !articleId
- // TODO LUN-419
+ const onSave = async (): Promise => {
+ const hasError = word.length === 0 || !articleId || images.length === 0
+
+ if (hasError) {
+ setHasWordErrorMessage(word.length === 0)
+ setHasArticleErrorMessage(!articleId)
+ setHasImageErrorMessage(images.length === 0 || images.length > 3)
+ return
+ }
+
+ try {
+ const id = await getNextUserVocabularyId()
+ await incrementNextUserVocabularyId()
+
+ const imagePaths = await Promise.all(
+ images.map(async (image, index) => {
+ const path = `${DocumentDirectoryPath}/image-${id}-${index}.txt`
+ await writeFile(path, image, 'utf8')
+ return { id: index, image: path }
+ })
+ )
- setHasWordErrorMessage(word.length === 0)
- setHasArticleErrorMessage(!articleId)
+ await addUserDocument({
+ id,
+ word,
+ article: ARTICLES[articleId],
+ document_image: imagePaths,
+ audio: '',
+ alternatives: [],
+ })
+
+ navigation.navigate('UserVocabularyList', { headerBackLabel: getLabels().general.back })
- if (!hasError) {
setWord('')
setArticleId(null)
+ setImages([])
+ } catch (e) {
+ reportError(e)
}
}
+ const pushImage = (uri: string): void => setImages(old => [...old, uri])
+ const deleteImage = (uri: string): void => setImages(images => images.filter(image => image !== uri))
+
+ if (showImageSelectionOverlay) {
+ return
+ }
+
// TODO add Keyboard handling for input fields LUN-424
return (
@@ -86,14 +142,22 @@ const UserVocabularyProcessScreen = (): ReactElement => {
itemKey='id'
errorMessage={hasArticleErrorMessage ? errorMessage : ''}
/>
+
+ {images.map(item => (
+ deleteImage(item)} />
+ ))}
+
null}
+ onPress={() => setShowImageSelectionOverlay(true)}
+ disabled={images.length >= 3}
label={addImage}
buttonTheme={BUTTONS_THEME.text}
iconLeft={ImageCircleIcon}
iconSize={wp('10%')}
/>
{maxPictureUpload}
+ {hasImageErrorMessage && {getLabels().userVocabulary.creation.imageErrorMessage}}
+
null}
label={addAudio}
diff --git a/src/routes/process-user-vocabulary/__tests__/UserVocabularyProcessScreen.spec.tsx b/src/routes/process-user-vocabulary/__tests__/UserVocabularyProcessScreen.spec.tsx
new file mode 100644
index 000000000..271740b35
--- /dev/null
+++ b/src/routes/process-user-vocabulary/__tests__/UserVocabularyProcessScreen.spec.tsx
@@ -0,0 +1,34 @@
+import { fireEvent } from '@testing-library/react-native'
+import React from 'react'
+
+import { getLabels } from '../../../services/helpers'
+import createNavigationMock from '../../../testing/createNavigationPropMock'
+import render from '../../../testing/render'
+import UserVocabularyProcessScreen from '../UserVocabularyProcessScreen'
+
+jest.mock('react-native-permissions', () => require('react-native-permissions/mock'))
+
+describe('UserVocabularyProcessScreen', () => {
+ const navigation = createNavigationMock<'UserVocabularyProcess'>()
+
+ it('should view and delete thumbnail', async () => {
+ const setState = jest.fn()
+ jest.spyOn(React, 'useState').mockImplementationOnce(() => [['image'], setState])
+
+ const { getByText, getByTestId } = render()
+ const deleteThumbnail = getByTestId('delete-on-thumbnail')
+ expect(getByText(getLabels().userVocabulary.creation.addImage)).not.toBeDisabled()
+ fireEvent.press(deleteThumbnail)
+ expect(setState).toHaveBeenCalled() // TODO vielleicht noch verbessern
+ })
+
+ it('should disable image button, if already three images selected', () => {
+ const setState = jest.fn()
+ jest.spyOn(React, 'useState').mockImplementationOnce(() => [['image-1', 'image-2', 'image-3'], setState])
+
+ const { getAllByTestId, getByText } = render()
+ const a = getAllByTestId('delete-on-thumbnail')
+ expect(a).toHaveLength(3)
+ expect(getByText(getLabels().userVocabulary.creation.addImage)).toBeDisabled()
+ })
+})
diff --git a/src/routes/process-user-vocabulary/components/ImageSelectionOverlay.tsx b/src/routes/process-user-vocabulary/components/ImageSelectionOverlay.tsx
new file mode 100644
index 000000000..f26ec102e
--- /dev/null
+++ b/src/routes/process-user-vocabulary/components/ImageSelectionOverlay.tsx
@@ -0,0 +1,73 @@
+import React, { ReactElement, useRef } from 'react'
+import { RNCamera } from 'react-native-camera'
+import styled from 'styled-components/native'
+
+import { CircleIconWhite, ImageIcon } from '../../../../assets/images'
+import CameraOverlay from '../../../components/CameraOverlay'
+import PressableOpacity from '../../../components/PressableOpacity'
+
+const GALLERY_ICON_SIZE = 30
+const TAKE_IMAGE_ICON_SIZE = 50
+
+const Camera = styled(RNCamera)`
+ flex: 1;
+ justify-content: flex-end;
+ position: relative;
+ padding: ${props => props.theme.spacings.sm};
+`
+
+const ActionBar = styled.View`
+ flex-direction: row;
+ width: 100%;
+ align-items: center;
+`
+
+const TakeImageButton = styled.Pressable`
+ flex: 2;
+ align-items: center;
+ margin-right: ${GALLERY_ICON_SIZE}px;
+`
+
+const Container = styled.View`
+ flex-direction: row;
+ justify-content: center;
+`
+
+interface ImageSelectionOverlayProps {
+ setVisible: (visible: boolean) => void
+ pushImage: (imageUri: string) => void
+}
+
+const ImageSelectionOverlay = ({ setVisible, pushImage }: ImageSelectionOverlayProps): ReactElement => {
+ const camera = useRef(null)
+
+ const takeImage = async () => {
+ if (camera.current) {
+ const options = { quality: 0.5, base64: true }
+ const data = await camera.current.takePictureAsync(options)
+ setVisible(false)
+ pushImage(data.uri)
+ }
+ }
+
+ return (
+
+
+
+
+ {/* TODO LUN-440 implement gallery */}
+ {/* eslint-disable-next-line no-console */}
+ console.log('open gallery')}>
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ImageSelectionOverlay
diff --git a/src/routes/process-user-vocabulary/components/Thumbnail.tsx b/src/routes/process-user-vocabulary/components/Thumbnail.tsx
new file mode 100644
index 000000000..012e92df2
--- /dev/null
+++ b/src/routes/process-user-vocabulary/components/Thumbnail.tsx
@@ -0,0 +1,38 @@
+import React from 'react'
+import { Image as RNImage } from 'react-native'
+import { heightPercentageToDP as hp } from 'react-native-responsive-screen'
+import styled from 'styled-components/native'
+
+import { CloseCircleIconBlue } from '../../../../assets/images'
+import PressableOpacity from '../../../components/PressableOpacity'
+
+const Container = styled.View`
+ margin: ${props => props.theme.spacings.xs};
+`
+
+const CloseIcon = styled(PressableOpacity)`
+ position: absolute;
+ right: 0px;
+ padding: ${props => props.theme.spacings.xxs};
+`
+
+const Image = styled(RNImage)`
+ width: ${hp('12%')}px;
+ height: ${hp('12%')}px;
+`
+
+interface ThumbnailProps {
+ image: string
+ deleteImage: () => void
+}
+
+const Thumbnail = ({ image, deleteImage }: ThumbnailProps): JSX.Element => (
+
+
+
+
+
+
+)
+
+export default Thumbnail
diff --git a/src/routes/process-user-vocabulary/components/__tests__/ImageSelectionOverlay.spec.tsx b/src/routes/process-user-vocabulary/components/__tests__/ImageSelectionOverlay.spec.tsx
new file mode 100644
index 000000000..6fb8f9829
--- /dev/null
+++ b/src/routes/process-user-vocabulary/components/__tests__/ImageSelectionOverlay.spec.tsx
@@ -0,0 +1,21 @@
+import React, { ReactElement, ReactNode } from 'react'
+import { View } from 'react-native'
+
+import render from '../../../../testing/render'
+import ImageSelectionOverlay from '../ImageSelectionOverlay'
+
+jest.mock('../../../../components/CameraOverlay', () => ({ children }: { children: ReactElement }) => (
+ {children}
+))
+
+jest.mock('react-native-camera', () => ({
+ RNCamera: ({ children }: { children: ReactNode }) => {children},
+}))
+
+describe('ImageSelectionOverlay', () => {
+ it('should render shutter and gallery icon', () => {
+ const { getByTestId } = render()
+ expect(getByTestId('take-image-icon')).toBeDefined()
+ expect(getByTestId('gallery-icon')).toBeDefined()
+ })
+})
diff --git a/src/routes/user-vocabulary-list/UserVocabularyListScreen.tsx b/src/routes/user-vocabulary-list/UserVocabularyListScreen.tsx
index 2e2348a52..823c59b89 100644
--- a/src/routes/user-vocabulary-list/UserVocabularyListScreen.tsx
+++ b/src/routes/user-vocabulary-list/UserVocabularyListScreen.tsx
@@ -1,3 +1,4 @@
+import { useFocusEffect } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import React, { ReactElement, useState } from 'react'
import { FlatList } from 'react-native'
@@ -61,7 +62,7 @@ const UserVocabularyListScreen = ({ navigation }: UserVocabularyListScreenProps)
}: { confirm: string; confirmDeletionPart1: string; confirmDeletionPart2: string; finished: string; edit: string } =
getLabels().userVocabulary.list
- // addUserDocument(new DocumentBuilder(4).build()[3]) /* TODO remove im LUN-401 */
+ useFocusEffect(documents.refresh)
const navigateToDetail = (document: Document): void => {
navigation.navigate('UserVocabularyDetail', { document })
diff --git a/src/routes/user-vocabulary-list/__tests__/UserVocabularyListScreen.spec.tsx b/src/routes/user-vocabulary-list/__tests__/UserVocabularyListScreen.spec.tsx
index b58d157b3..5986c2eaa 100644
--- a/src/routes/user-vocabulary-list/__tests__/UserVocabularyListScreen.spec.tsx
+++ b/src/routes/user-vocabulary-list/__tests__/UserVocabularyListScreen.spec.tsx
@@ -12,6 +12,7 @@ import render from '../../../testing/render'
import UserVocabularyListScreen from '../UserVocabularyListScreen'
jest.mock('../../../hooks/useReadUserVocabulary')
+jest.mock('@react-navigation/native')
jest.mock('../../../components/FavoriteButton', () => () => {
const { Text } = require('react-native')
diff --git a/src/services/AsyncStorage.ts b/src/services/AsyncStorage.ts
index 7dfb3ba73..60dee6436 100644
--- a/src/services/AsyncStorage.ts
+++ b/src/services/AsyncStorage.ts
@@ -4,7 +4,7 @@ import { ExerciseKey, Progress } from '../constants/data'
import { Document } from '../constants/endpoints'
import { DocumentResult } from '../navigation/NavigationTypes'
import { CMS, productionCMS, testCMS } from './axios'
-import { calculateScore } from './helpers'
+import { calculateScore, getImages } from './helpers'
const SELECTED_PROFESSIONS_KEY = 'selectedProfessions'
const CUSTOM_DISCIPLINES_KEY = 'customDisciplines'
@@ -14,6 +14,7 @@ const SENTRY_KEY = 'sentryTracking'
const CMS_KEY = 'cms'
const DEV_MODE_KEY = 'devmode'
const USER_VOCABULARY = 'userVocabulary'
+const USER_VOCABULARY_NEXT_ID = 'userVocabularyNextId'
export const isTrackingEnabled = async (): Promise => {
const tracking = await AsyncStorage.getItem(SENTRY_KEY)
@@ -147,17 +148,35 @@ export const getDevMode = async (): Promise => {
return isDevMode ? JSON.parse(isDevMode) : null
}
-export const getUserVocabulary = async (): Promise => {
+export const getNextUserVocabularyId = async (): Promise =>
+ parseInt((await AsyncStorage.getItem(USER_VOCABULARY_NEXT_ID)) ?? '1', 10)
+
+export const incrementNextUserVocabularyId = async (): Promise => {
+ const nextId = (await getNextUserVocabularyId()) + 1
+ return AsyncStorage.setItem(USER_VOCABULARY_NEXT_ID, nextId.toString())
+}
+
+export const getUserVocabularyWithoutImage = async (): Promise => {
const userVocabulary = await AsyncStorage.getItem(USER_VOCABULARY)
return userVocabulary ? JSON.parse(userVocabulary) : []
}
+export const getUserVocabulary = async (): Promise => {
+ const userVocabulary = await getUserVocabularyWithoutImage()
+ return Promise.all(
+ userVocabulary.map(async item => ({
+ ...item,
+ document_image: await getImages(item),
+ }))
+ )
+}
+
export const setUserVocabulary = async (userDocument: Document[]): Promise => {
await AsyncStorage.setItem(USER_VOCABULARY, JSON.stringify(userDocument))
}
export const addUserDocument = async (userDocument: Document): Promise => {
- const userVocabulary = await getUserVocabulary()
+ const userVocabulary = await getUserVocabularyWithoutImage()
if (userVocabulary.find(item => item.word === userDocument.word)) {
return
}
diff --git a/src/services/helpers.ts b/src/services/helpers.ts
index 416fe036c..987947dbc 100644
--- a/src/services/helpers.ts
+++ b/src/services/helpers.ts
@@ -1,5 +1,6 @@
import { AxiosResponse } from 'axios'
import normalizeStrings from 'normalize-strings'
+import { readFile } from 'react-native-fs'
import {
Article,
@@ -10,7 +11,7 @@ import {
Progress,
SCORE_THRESHOLD_UNLOCK,
} from '../constants/data'
-import { AlternativeWord, Discipline, Document, ENDPOINTS } from '../constants/endpoints'
+import { AlternativeWord, Discipline, Document, ENDPOINTS, Image, Images } from '../constants/endpoints'
import labels from '../constants/labels.json'
import { COLORS } from '../constants/theme/colors'
import { ServerResponseDiscipline } from '../hooks/helpers'
@@ -18,6 +19,7 @@ import { loadDiscipline } from '../hooks/useLoadDiscipline'
import { DocumentResult } from '../navigation/NavigationTypes'
import { getExerciseProgress } from './AsyncStorage'
import { getFromEndpoint, postToEndpoint } from './axios'
+import { reportError } from './sentry'
export const stringifyDocument = ({ article, word }: Document | AlternativeWord): string => `${article.value} ${word}`
@@ -138,8 +140,7 @@ export const getProgress = async (profession: Discipline | null): Promise => {
const trainingSetUrl = `${ENDPOINTS.trainingSets}/${disciplineId}`
- const trainingSet = await getFromEndpoint(trainingSetUrl)
- return trainingSet
+ return getFromEndpoint(trainingSetUrl)
}
export const getLabels = (): typeof labels => labels
@@ -201,3 +202,16 @@ export const getSortedAndFilteredDocuments = (documents: Document[] | null, sear
export const willNextExerciseUnlock = (previousScore: number | undefined, score: number): boolean =>
score > SCORE_THRESHOLD_UNLOCK && (previousScore ?? 0) <= SCORE_THRESHOLD_UNLOCK
+
+export const getImages = async (item: Document): Promise => {
+ const images = await Promise.all(
+ item.document_image.map(async image => ({
+ id: image.id,
+ image: await readFile(image.image).catch(err => {
+ reportError(err)
+ return null
+ }),
+ }))
+ )
+ return images.filter((item): item is Image => item.image !== null)
+}
diff --git a/src/testing/DocumentBuilder.ts b/src/testing/DocumentBuilder.ts
index e02bc314f..ad4b630ea 100644
--- a/src/testing/DocumentBuilder.ts
+++ b/src/testing/DocumentBuilder.ts
@@ -6,7 +6,7 @@ const document: Document[] = [
id: 1,
word: 'Spachtel',
article: ARTICLES[1],
- document_image: [{ id: 1, image: 'Spachtel' }],
+ document_image: [{ id: 1, image: 'image' }],
audio: 'https://example.com/my-audio',
alternatives: [
{
@@ -23,7 +23,7 @@ const document: Document[] = [
id: 2,
word: 'Auto',
article: ARTICLES[3],
- document_image: [{ id: 1, image: 'Auto' }],
+ document_image: [{ id: 1, image: 'image' }],
audio: '',
alternatives: [],
},
diff --git a/yarn.lock b/yarn.lock
index 2c0612922..f82b5577a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5383,6 +5383,13 @@ __metadata:
languageName: node
linkType: hard
+"base-64@npm:^0.1.0":
+ version: 0.1.0
+ resolution: "base-64@npm:0.1.0"
+ checksum: fe0dcf076e823f04db7ee9b02495be08a91c445fbc6db03cb9913be9680e2fcc0af8b74459041fe08ad16800b1f65a549501d8f08696a8a6d32880789b7de69d
+ languageName: node
+ linkType: hard
+
"base64-js@npm:^1.1.2, base64-js@npm:^1.5.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
@@ -10143,6 +10150,7 @@ __metadata:
react-native-camera: ^4.2.1
react-native-device-info: ^9.0.2
react-native-dropdown-picker: ^5.4.2
+ react-native-fs: ^2.20.0
react-native-gesture-handler: ^2.5.0
react-native-image-zoom-viewer: ^3.0.1
react-native-keyboard-aware-scroll-view: ^0.9.5
@@ -11984,6 +11992,22 @@ __metadata:
languageName: node
linkType: hard
+"react-native-fs@npm:^2.20.0":
+ version: 2.20.0
+ resolution: "react-native-fs@npm:2.20.0"
+ dependencies:
+ base-64: ^0.1.0
+ utf8: ^3.0.0
+ peerDependencies:
+ react-native: "*"
+ react-native-windows: "*"
+ peerDependenciesMeta:
+ react-native-windows:
+ optional: true
+ checksum: 3722b5568610cd72f319c90f60ba8b019a005d015f27e49017ddd0ea314d1ea6991f79288c28549fdc2964dc81c0fa24f8a5f87a4a6283c97c6ea88d4caa6851
+ languageName: node
+ linkType: hard
+
"react-native-gesture-handler@npm:^2.5.0":
version: 2.5.0
resolution: "react-native-gesture-handler@npm:2.5.0"
@@ -14225,6 +14249,13 @@ __metadata:
languageName: node
linkType: hard
+"utf8@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "utf8@npm:3.0.0"
+ checksum: 675d008bab65fc463ce718d5cae8fd4c063540f269e4f25afebce643098439d53e7164bb1f193e0c3852825c7e3e32fbd8641163d19a618dbb53f1f09acb0d5a
+ languageName: node
+ linkType: hard
+
"util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"