Skip to content

Commit

Permalink
Merge pull request #307 from digitalfabrik/LUN-132-favorites
Browse files Browse the repository at this point in the history
LUN-132: Favorites
  • Loading branch information
ztefanie authored Jun 21, 2022
2 parents 6456724 + 5d1959c commit b68c707
Show file tree
Hide file tree
Showing 33 changed files with 577 additions and 218 deletions.
14 changes: 10 additions & 4 deletions assets/images/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ import OpenLockIcon from './open-lock-icon.svg'
import QRCodeIcon from './qr-code-icon.svg'
import RepeatIcon from './repeat-icon.svg'
import ShareIcon from './share-icon.svg'
import StartIconGrey from './star-icon-grey.svg'
import StartIconWhite from './star-icon-white.svg'
import StarCircleIconGreyFilled from './star-circle-icon-grey-filled.svg'
import StarCircleIconGrey from './star-circle-icon-grey.svg'
import StarIconGreyFilled from './star-icon-grey-filled.svg'
import StarIconGrey from './star-icon-grey.svg'
import StarIconWhite from './star-icon-white.svg'
import TrashIcon from './trash-bin-icon.svg'
import TrophyIcon from './trophy-icon.svg'
import VolumeUpCircleIcon from './volume-up-circle-icon.svg'
Expand Down Expand Up @@ -99,8 +102,11 @@ export {
QRCodeIcon,
RepeatIcon,
ShareIcon,
StartIconGrey,
StartIconWhite,
StarCircleIconGrey,
StarCircleIconGreyFilled,
StarIconGrey,
StarIconGreyFilled,
StarIconWhite,
TrashIcon,
TrophyIcon,
VolumeUpCircleIcon
Expand Down
6 changes: 6 additions & 0 deletions assets/images/star-circle-icon-grey-filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions assets/images/star-circle-icon-grey.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/images/star-icon-grey-filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions release-notes/unreleased/LUN-132-favorites.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
issue_key: LUN-132
show_in_stores: true
platforms:
- android
- ios
de: Favoriten können nun gespeichert werden.
23 changes: 7 additions & 16 deletions src/components/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,7 @@ export interface AudioPlayerProps {
submittedAlternative?: string | null
}

const StyledView = styled.View`
align-items: center;
margin-bottom: ${props => props.theme.spacings.sm};
`

const VolumeIcon = styled.TouchableOpacity<{ disabled: boolean; isActive: boolean }>`
position: absolute;
top: ${wp('-4.5%')}px;
width: ${wp('9%')}px;
height: ${wp('9%')}px;
border-radius: 50px;
Expand Down Expand Up @@ -107,15 +100,13 @@ const AudioPlayer = ({ document, disabled, submittedAlternative }: AudioPlayerPr
}

return (
<StyledView>
<VolumeIcon
disabled={disabled || !isInitialized}
isActive={isActive}
onPress={handleSpeakerClick}
accessibilityRole='button'>
<VolumeUpCircleIcon width={wp('8%')} height={wp('8%')} />
</VolumeIcon>
</StyledView>
<VolumeIcon
disabled={disabled || !isInitialized}
isActive={isActive}
onPress={handleSpeakerClick}
accessibilityRole='button'>
<VolumeUpCircleIcon width={wp('8%')} height={wp('8%')} />
</VolumeIcon>
)
}

Expand Down
49 changes: 49 additions & 0 deletions src/components/DocumentImageSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { ReactElement } from 'react'
import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
import styled from 'styled-components/native'

import { Document } from '../constants/endpoints'
import AudioPlayer from './AudioPlayer'
import FavoriteButton from './FavoriteButton'
import ImageCarousel from './ImageCarousel'

const AudioContainer = styled.View`
position: absolute;
bottom: ${wp('-4.5%')}px;
align-self: center;
`
const FavoriteContainer = styled.View`
position: absolute;
top: 0;
right: ${props => props.theme.spacings.md};
`

const Container = styled.View`
margin-bottom: ${props => props.theme.spacings.md};
`

interface Props {
document: Document
audioDisabled?: boolean
minimized?: boolean
submittedAlternative?: string | null
}

const DocumentImageSection = ({
document,
audioDisabled = false,
minimized = false,
submittedAlternative
}: Props): ReactElement => (
<Container>
<ImageCarousel images={document.document_image} minimized={minimized} />
<AudioContainer>
<AudioPlayer document={document} disabled={audioDisabled} submittedAlternative={submittedAlternative} />
</AudioContainer>
<FavoriteContainer>
<FavoriteButton document={document} />
</FavoriteContainer>
</Container>
)

export default DocumentImageSection
62 changes: 62 additions & 0 deletions src/components/FavoriteButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useFocusEffect } from '@react-navigation/native'
import React, { ReactElement } from 'react'
import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
import styled from 'styled-components/native'

import { StarCircleIconGrey, StarCircleIconGreyFilled } from '../../assets/images'
import { Document } from '../constants/endpoints'
import useLoadAsync from '../hooks/useLoadAsync'
import AsyncStorage from '../services/AsyncStorage'
import { reportError } from '../services/sentry'

const Icon = styled(StarCircleIconGreyFilled)`
min-width: ${wp('9%')}px;
min-height: ${wp('9%')}px;
`
const IconOutline = styled(StarCircleIconGrey)`
min-width: ${wp('9%')}px;
min-height: ${wp('9%')}px;
`
const Button = styled.TouchableOpacity`
justify-content: center;
align-items: center;
shadow-color: ${props => props.theme.colors.shadow};
shadow-radius: 5px;
shadow-offset: 1px 1px;
shadow-opacity: 0.5;
`

interface Props {
document: Document
onFavoritesChanged?: () => void
}

const FavoriteButton = ({ document, onFavoritesChanged }: Props): ReactElement | null => {
const { data: isFavorite, refresh } = useLoadAsync(AsyncStorage.isFavorite, document.id)

useFocusEffect(refresh)

const onPress = async () => {
if (isFavorite) {
await AsyncStorage.removeFavorite(document.id).catch(reportError)
} else {
await AsyncStorage.addFavorite(document.id).catch(reportError)
}
refresh()
if (onFavoritesChanged) {
onFavoritesChanged()
}
}

if (isFavorite === null) {
return null
}

return (
<Button testID={isFavorite ? 'remove' : 'add'} onPress={onPress}>
{isFavorite ? <Icon /> : <IconOutline />}
</Button>
)
}

export default FavoriteButton
13 changes: 10 additions & 3 deletions src/components/VocabularyList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,26 @@ const Root = styled.View`
interface VocabularyListScreenProps {
documents: Document[]
onItemPress: (index: number) => void
onFavoritesChanged?: () => void
title: string
}

const VocabularyList = ({ documents, onItemPress }: VocabularyListScreenProps): JSX.Element => {
const VocabularyList = ({
documents,
onItemPress,
onFavoritesChanged,
title
}: VocabularyListScreenProps): JSX.Element => {
const renderItem = ({ item, index }: { item: Document; index: number }): JSX.Element => (
<VocabularyListItem document={item} onPress={() => onItemPress(index)} />
<VocabularyListItem document={item} onPress={() => onItemPress(index)} onFavoritesChanged={onFavoritesChanged} />
)

return (
<Root>
<FlatList
ListHeaderComponent={
<Title
title={labels.exercises.vocabularyList.title}
title={title}
description={`${documents.length} ${documents.length === 1 ? labels.general.word : labels.general.words}`}
/>
}
Expand Down
22 changes: 16 additions & 6 deletions src/components/VocabularyListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import styled from 'styled-components/native'
import { Document } from '../constants/endpoints'
import { getArticleColor } from '../services/helpers'
import AudioPlayer from './AudioPlayer'
import FavoriteButton from './FavoriteButton'
import ListItem from './ListItem'
import { ContentTextLight } from './text/Content'

Expand All @@ -24,17 +25,23 @@ const StyledTitle = styled(ContentTextLight)<{ articleColor: string }>`
height: ${wp('5%')}px;
text-align: center;
`
const Speaker = styled.View`
padding-right: ${props => props.theme.spacings.xl};
padding-top: ${props => props.theme.spacings.sm};
const RightChildrenContainer = styled.View`
flex-direction: row;
justify-content: space-between;
`

const FavButtonContainer = styled.View`
padding: ${props => `0 ${props.theme.spacings.xs} 0 ${props.theme.spacings.sm}`};
align-self: center;
`

interface VocabularyListItemProps {
document: Document
onPress: () => void
onFavoritesChanged?: () => void
}

const VocabularyListItem = ({ document, onPress }: VocabularyListItemProps): ReactElement => {
const VocabularyListItem = ({ document, onPress, onFavoritesChanged }: VocabularyListItemProps): ReactElement => {
const { article, word, document_image: documentImage } = document

const title = <StyledTitle articleColor={getArticleColor(article)}>{article.value}</StyledTitle>
Expand All @@ -50,9 +57,12 @@ const VocabularyListItem = ({ document, onPress }: VocabularyListItemProps): Rea
onPress={onPress}
icon={icon}
rightChildren={
<Speaker>
<RightChildrenContainer>
<AudioPlayer document={document} disabled={false} />
</Speaker>
<FavButtonContainer>
<FavoriteButton document={document} onFavoritesChanged={onFavoritesChanged} />
</FavButtonContainer>
</RightChildrenContainer>
}
/>
)
Expand Down
45 changes: 45 additions & 0 deletions src/components/__tests__/FavoriteButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NavigationContainer } from '@react-navigation/native'
import { fireEvent, waitFor } from '@testing-library/react-native'
import React from 'react'

import AsyncStorage from '../../services/AsyncStorage'
import DocumentBuilder from '../../testing/DocumentBuilder'
import render from '../../testing/render'
import FavoriteButton from '../FavoriteButton'

describe('FavoriteButton', () => {
const document = new DocumentBuilder(1).build()[0]

const renderFavoriteButton = () =>
render(
<NavigationContainer>
<FavoriteButton document={document} />
</NavigationContainer>
)

it('should add favorite on click', async () => {
await AsyncStorage.setFavorites([])
await expect(AsyncStorage.isFavorite(document.id)).resolves.toBe(false)

const { getByTestId } = renderFavoriteButton()

await waitFor(() => expect(getByTestId('add')).toBeTruthy())
fireEvent.press(getByTestId('add'))

await waitFor(() => expect(getByTestId('remove')).toBeTruthy())
await expect(AsyncStorage.isFavorite(document.id)).resolves.toBe(true)
})

it('should remove favorite on click', async () => {
await AsyncStorage.setFavorites([document.id])
await expect(AsyncStorage.isFavorite(document.id)).resolves.toBe(true)

const { getByTestId } = renderFavoriteButton()

await waitFor(() => expect(getByTestId('remove')).toBeTruthy())
fireEvent.press(getByTestId('remove'))

await waitFor(() => expect(getByTestId('add')).toBeTruthy())
await expect(AsyncStorage.isFavorite(document.id)).resolves.toBe(false)
})
})
10 changes: 7 additions & 3 deletions src/components/__tests__/VocabularyList.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { mockUseLoadAsyncWithData } from '../../testing/mockUseLoadFromEndpoint'
import render from '../../testing/render'
import VocabularyList from '../VocabularyList'

jest.mock('../FavoriteButton', () => () => {
const { Text } = require('react-native')
return <Text>FavoriteButton</Text>
})
jest.mock('../AudioPlayer', () => {
const Text = require('react-native').Text
return () => <Text>AudioPlayer</Text>
Expand All @@ -24,10 +28,10 @@ describe('VocabularyList', () => {
mockUseLoadAsyncWithData(documents)

const { getByText, getAllByText, getAllByTestId } = render(
<VocabularyList documents={documents} onItemPress={onItemPress} />
<VocabularyList title='Title' documents={documents} onItemPress={onItemPress} />
)

expect(getByText(labels.exercises.vocabularyList.title)).toBeTruthy()
expect(getByText('Title')).toBeTruthy()
expect(getByText(`2 ${labels.general.words}`)).toBeTruthy()
expect(getByText('Der')).toBeTruthy()
expect(getByText('Spachtel')).toBeTruthy()
Expand All @@ -38,7 +42,7 @@ describe('VocabularyList', () => {
})

it('should call onItemPress with correct index', () => {
const { getByText } = render(<VocabularyList documents={documents} onItemPress={onItemPress} />)
const { getByText } = render(<VocabularyList title='Title' documents={documents} onItemPress={onItemPress} />)

expect(onItemPress).toHaveBeenCalledTimes(0)

Expand Down
4 changes: 4 additions & 0 deletions src/components/__tests__/VocabularyListItem.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { Document } from '../../constants/endpoints'
import render from '../../testing/render'
import VocabularyListItem from '../VocabularyListItem'

jest.mock('../FavoriteButton', () => () => {
const { Text } = require('react-native')
return <Text>FavoriteButton</Text>
})
jest.mock('../AudioPlayer', () => () => null)

describe('VocabularyListItem', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/constants/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export const ENDPOINTS = {
groupInfo: 'group_info',
trainingSet: 'training_set',
trainingSets: 'training_sets',
documents: 'documents/:id'
documents: 'documents/:id',
document: 'words'
}

export const ForbiddenError = 'Request failed with status code 403'
Expand Down
3 changes: 2 additions & 1 deletion src/constants/labels.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,5 +147,6 @@
"word": "Wort",
"words": "Wörter",
"customModalCancel": "Zurück"
}
},
"favorites": "Favoriten"
}
Loading

0 comments on commit b68c707

Please sign in to comment.