Skip to content

Commit

Permalink
Merge pull request #1 from IgorKhramtsov/ik/feat-search
Browse files Browse the repository at this point in the history
feat: subject page, subject search
  • Loading branch information
IgorKhramtsov authored Aug 12, 2024
2 parents 040b227 + 45c2fce commit 07f20a8
Show file tree
Hide file tree
Showing 40 changed files with 1,799 additions and 604 deletions.
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.expo
node_modules
13 changes: 9 additions & 4 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
// https://docs.expo.dev/guides/using-eslint/

module.exports = {
extends: ["expo", "prettier"],
plugins: ["prettier"],
extends: [
'expo',
'prettier',
'plugin:@tanstack/eslint-plugin-query/recommended',
],
plugins: ['prettier'],
rules: {
"prettier/prettier": "warn",
'prettier/prettier': 'warn',
},
};
}
12 changes: 12 additions & 0 deletions app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { FontAwesome } from '@expo/vector-icons'
import { Tabs } from 'expo-router'
import React from 'react'

export const tabsNavigatorId = 'HomeTabs'

export default function TabLayout() {
return (
<Tabs
id={tabsNavigatorId}
screenOptions={{
tabBarActiveTintColor: 'black',
headerShown: false,
Expand All @@ -19,6 +22,15 @@ export default function TabLayout() {
),
}}
/>
<Tabs.Screen
name='library'
options={{
title: 'Library',
tabBarIcon: ({ color, focused }) => (
<FontAwesome name='search' size={24} color={color} />
),
}}
/>
<Tabs.Screen
name='settings'
options={{
Expand Down
22 changes: 22 additions & 0 deletions app/(app)/(tabs)/library/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Stack } from 'expo-router'

export default function RootLayout() {
return (
<Stack>
<Stack.Screen
name='index'
options={{
title: 'Library',
headerTitle: 'Library',
headerShown: false,
}}
/>
<Stack.Screen
name='subject'
options={{
title: 'Subject',
}}
/>
</Stack>
)
}
106 changes: 106 additions & 0 deletions app/(app)/(tabs)/library/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Colors } from '@/src/constants/Colors'
import { appStyles } from '@/src/constants/styles'
import typography from '@/src/constants/typography'
import { FontAwesome } from '@expo/vector-icons'
import { Keyboard, Pressable, View } from 'react-native'
import { FlatList, TextInput } from 'react-native-gesture-handler'
import { createStyleSheet, useStyles } from 'react-native-unistyles'
import { useTabPress } from '@/src/hooks/useTabPress'
import React from 'react'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useSubjectSearch } from '@/src/hooks/useSubjectSearch'
import { LoadingIndicator } from '@/src/components/LoadingIndicator'
import { SubjectTile } from '@/src/components/SubjectTile'

const Index = () => {
const { styles } = useStyles(stylesheet)
const scrollViewRef = React.useRef<FlatList>(null)
const searchInputRef = React.useRef<TextInput>(null)
const [scrollOffset, setScrollOffset] = React.useState(0)
const [query, setQuery] = React.useState('')

const { subjects, isLoading } = useSubjectSearch(query)
useTabPress(() => {
if (scrollOffset > 0) {
scrollViewRef.current?.scrollToOffset({ offset: 0 })
} else {
searchInputRef.current?.focus()
}
})

return (
<SafeAreaView edges={['top']}>
<Pressable
style={{ height: '100%' }}
disabled={true}
onPress={Keyboard.dismiss}
accessible={false}>
<View style={styles.pageContainer}>
<View style={{ flex: 1 }}>
<LoadingIndicator loading={isLoading}>
<FlatList
ref={scrollViewRef}
data={subjects}
onScroll={e => setScrollOffset(e.nativeEvent.contentOffset.y)}
ListHeaderComponent={
<View style={styles.search}>
<FontAwesome
name='search'
size={16}
color={Colors.gray88}
/>
<View style={{ width: 8 }} />
<TextInput
ref={searchInputRef}
textAlignVertical='center'
multiline={false}
style={styles.searchInput}
placeholder='Search'
autoCorrect={false}
autoComplete='off'
onChangeText={setQuery}
/>
</View>
}
contentContainerStyle={{
marginHorizontal: 30,
paddingBottom: 16,
}}
renderItem={({ item }) => (
<SubjectTile
key={item.id}
variant='extended'
subject={item}
/>
)}
ItemSeparatorComponent={() => <View style={{ height: 4 }} />}
/>
</LoadingIndicator>
</View>
</View>
</Pressable>
</SafeAreaView>
)
}
export default Index

const stylesheet = createStyleSheet({
pageContainer: {
flex: 1,
justifyContent: 'center',
},
search: {
...appStyles.row,
backgroundColor: 'white',
borderRadius: 8,
marginBottom: 16,
paddingLeft: 8,
},
searchInput: {
...typography.body,
lineHeight: typography.body.fontSize * 1.2,
flex: 1,
padding: 8,
margin: 0,
},
})
217 changes: 217 additions & 0 deletions app/(app)/(tabs)/library/subject.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { CompositionSection } from '@/src/components/CompositionPage'
import { ContextSection } from '@/src/components/ContextPage'
import { ExamplesSection } from '@/src/components/ExamplesPage'
import { FullPageLoading } from '@/src/components/FullPageLoading'
import { MeaningSection } from '@/src/components/MeaningPage'
import { ReadingSection } from '@/src/components/ReadingPage'
import { Colors } from '@/src/constants/Colors'
import { appStyles } from '@/src/constants/styles'
import typography from '@/src/constants/typography'
import { useSubjectCache } from '@/src/hooks/useSubjectCache'
import { srsStageToColor, srsStageToMilestone } from '@/src/types/assignment'
import { Subject, SubjectUtils } from '@/src/types/subject'
import { dbHelper } from '@/src/utils/dbHelper'
import { useQuery } from '@tanstack/react-query'
import { useLocalSearchParams, useNavigation } from 'expo-router'
import { useSQLiteContext } from 'expo-sqlite'
import { toLower } from 'lodash'
import { Fragment, useLayoutEffect, useMemo } from 'react'
import { ScrollView, Text, View } from 'react-native'
import { createStyleSheet, useStyles } from 'react-native-unistyles'

export default function Index() {
const { styles } = useStyles(stylesheet)
const navigation = useNavigation()
const params = useLocalSearchParams<{
id: string
}>()
const id = useMemo(() => [parseInt(params.id ?? '')], [params.id])
const { subjects, isLoading } = useSubjectCache(id)
const subject = useMemo((): Subject | undefined => subjects[0], [subjects])
const db = useSQLiteContext()
const { isPending: isAssignmentLoading, data: assignment } = useQuery({
queryKey: ['assignment', db, subject?.id],
queryFn: async () => {
if (!subject?.id) return null

return (await dbHelper.getAssignment(db, subject?.id)) ?? null
},
enabled: !!subject,
})
const { isPending: isReviewStatisticLoading, data: reviewStatistic } =
useQuery({
queryKey: ['review_statistic', db, subject?.id],
queryFn: async () => {
if (!subject?.id) return null

return (await dbHelper.getReviewStatistic(db, subject?.id)) ?? null
},
enabled: !!subject,
})
const stageName = useMemo(
() => srsStageToMilestone(assignment?.srs_stage),
[assignment?.srs_stage],
)
const stageColor = useMemo(
() => srsStageToColor(assignment?.srs_stage),
[assignment?.srs_stage],
)
const associatedColor = useMemo(
() => (subject ? SubjectUtils.getAssociatedColor(subject) : undefined),
[subject],
)
const otherMeanings = useMemo(
() =>
subject?.meanings
.filter(e => !e.primary)
.map(e => e.meaning)
.map(toLower),
[subject?.meanings],
)

useLayoutEffect(() => {
navigation.setOptions({
title:
subject && SubjectUtils.isRadical(subject)
? subject.meanings[0]?.meaning
: subject?.characters,
})
}, [navigation, subject])

if (isLoading || isAssignmentLoading || isReviewStatisticLoading)
return <FullPageLoading />
if (!subject) return <Text>Subject not found</Text>

// TODO: make header expandable? So that it collapse to just characters when
// scrolled
return (
<ScrollView contentContainerStyle={styles.pageView}>
<Fragment>
{stageName && (
<View
style={[
styles.stageBar,
{
backgroundColor: stageColor,
borderBottomColor: Colors.getBottomBorderColor(stageColor),
},
]}>
{stageName && <Text style={styles.stageText}>{stageName}</Text>}
{reviewStatistic && (
<Text style={styles.stageText}>
🎯{reviewStatistic?.percentage_correct}%
</Text>
)}
</View>
)}
<View style={{ height: 12 }} />
<View style={{ alignItems: 'center' }}>
<View style={{ flexDirection: 'row' }}>
<View
style={[
styles.subjectView,
{
paddingHorizontal: 10,
backgroundColor: Colors.gray55,
borderBottomColor: Colors.getBottomBorderColor(Colors.gray55),
},
]}>
<Text style={styles.subjectText}>L{subject.level}</Text>
</View>
<View style={{ width: 4 }} />
<View style={{ alignItems: 'center' }}>
<View
style={[
styles.subjectView,
{
backgroundColor: associatedColor,
borderBottomColor:
Colors.getBottomBorderColor(associatedColor),
},
]}>
<Text style={styles.subjectText}>{subject.characters}</Text>
</View>
</View>
</View>
<View style={{ height: 8 }} />
<Text style={styles.subjectMeaning}>
{SubjectUtils.getPrimaryMeaning(subject)?.meaning ?? ''}
</Text>
{(otherMeanings?.length ?? 0) > 0 && (
<Text style={styles.subjectOtherMeanings}>
{otherMeanings?.join(', ')}
</Text>
)}
</View>
</Fragment>
<View style={{ height: 16 }} />
{(SubjectUtils.isVocabulary(subject) ||
SubjectUtils.isKanji(subject)) && (
<Fragment>
<CompositionSection subject={subject} />
<View style={{ height: 16 }} />
</Fragment>
)}
<MeaningSection showOtherMeanings={false} subject={subject} />
{SubjectUtils.hasReading(subject) && (
<Fragment>
<View style={{ height: 16 }} />
<ReadingSection variant='extended' subject={subject} />
</Fragment>
)}
{(SubjectUtils.isKanji(subject) || SubjectUtils.isRadical(subject)) && (
<Fragment>
<View style={{ height: 16 }} />
<ExamplesSection subject={subject} variant='standard' />
</Fragment>
)}
{(SubjectUtils.isVocabulary(subject) ||
SubjectUtils.isKanaVocabulary(subject)) && (
<Fragment>
<View style={{ height: 16 }} />
<ContextSection subject={subject} />
</Fragment>
)}
</ScrollView>
)
// TODO: add level info, statistics of correct answers, current learning
// level, next review (see Progression in WaniKani)
}

const stylesheet = createStyleSheet({
pageView: {
padding: 20,
paddingTop: 10,
},
stageBar: {
...appStyles.rowSpaceBetween,
paddingVertical: 4,
paddingHorizontal: 8,
borderRadius: 6,
borderBottomWidth: 2,
paddingBottom: 2, // Subtract 2 due to border
},
stageText: {
...typography.body,
color: Colors.white,
},
subjectView: {
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 6,
borderBottomWidth: 4,
paddingBottom: 8, // Subtract 4 due to border
},
subjectText: {
...typography.titleA,
color: Colors.white,
},
subjectMeaning: {
...typography.titleB,
height: typography.titleB.fontSize * 1.18,
},
subjectOtherMeanings: {
...typography.body,
color: Colors.gray55,
},
})
Loading

0 comments on commit 07f20a8

Please sign in to comment.