From ad5dcb5c3a5c58f57f5a64758b20eb4006de2ac6 Mon Sep 17 00:00:00 2001 From: Igor Khramtsov Date: Sun, 20 Oct 2024 15:41:11 +0400 Subject: [PATCH] feat: extra study --- app/(app)/(tabs)/_layout.tsx | 1 + app/(app)/(tabs)/home/LevelProgress.tsx | 3 +- app/(app)/(tabs)/home/index.tsx | 200 ++++++++------- app/(app)/extraStudy/index.tsx | 308 ++++++++++++++++++++++++ app/(app)/lessonPicker/index.tsx | 236 +++++++----------- app/(app)/lessons/index.tsx | 1 - src/api/localDb/api.ts | 55 ++++- src/api/localDb/assignment.ts | 30 ++- src/api/localDb/subject.ts | 8 +- src/components/Collapsible.tsx | 107 +++++--- src/components/SubjectPickerPage.tsx | 206 ++++++++++++++++ src/components/SubjectSymbol.tsx | 2 +- src/utils/arrayUtils.ts | 3 + src/utils/dateUtils.ts | 11 + 14 files changed, 883 insertions(+), 288 deletions(-) create mode 100644 app/(app)/extraStudy/index.tsx create mode 100644 src/components/SubjectPickerPage.tsx create mode 100644 src/utils/arrayUtils.ts diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx index 27f0b78..b60a59a 100644 --- a/app/(app)/(tabs)/_layout.tsx +++ b/app/(app)/(tabs)/_layout.tsx @@ -12,6 +12,7 @@ export default function TabLayout() { tabBarActiveTintColor: 'black', headerShown: false, tabBarStyle: { + // TODO: make it glass like ? paddingTop: 8, }, }}> diff --git a/app/(app)/(tabs)/home/LevelProgress.tsx b/app/(app)/(tabs)/home/LevelProgress.tsx index 2d6db1b..061bb16 100644 --- a/app/(app)/(tabs)/home/LevelProgress.tsx +++ b/app/(app)/(tabs)/home/LevelProgress.tsx @@ -3,6 +3,7 @@ import { useFindAssignmentsByQuery } from '@/src/api/localDb/assignment' import { useFindSubjectsByQuery } from '@/src/api/localDb/subject' import { Colors } from '@/src/constants/Colors' import typography from '@/src/constants/typography' +import { filterNotUndefined } from '@/src/utils/arrayUtils' import { useCallback, useMemo, useState } from 'react' import { LayoutChangeEvent, Text, View } from 'react-native' import { createStyleSheet, useStyles } from 'react-native-unistyles' @@ -21,7 +22,7 @@ export const LevelProgress = () => { [levelProgressions], ) const { data: kanjiOnLevel } = useFindSubjectsByQuery({ - level: currentLevel, + levels: filterNotUndefined([currentLevel]), type: 'kanji', }) const subjectIds = useMemo( diff --git a/app/(app)/(tabs)/home/index.tsx b/app/(app)/(tabs)/home/index.tsx index e4d3773..6c279d0 100644 --- a/app/(app)/(tabs)/home/index.tsx +++ b/app/(app)/(tabs)/home/index.tsx @@ -96,96 +96,127 @@ export default function Index() { const duration = 600 return ( - - - }> - - - + + + + }> + + + + + } + href={{ + pathname: '/lessons', + params: { assignmentIds: lessonIdsBatch }, + }} + /> + + + } + href={{ + pathname: '/lessonPicker', + params: { assignmentIds: lessonIdsBatch }, + }} + /> + + } + /> + + + + } + /> + + } + /> + + } - href={{ - pathname: '/lessons', - params: { assignmentIds: lessonIdsBatch }, - }} /> - - - } - href={{ - pathname: '/lessonPicker', - params: { assignmentIds: lessonIdsBatch }, - }} - /> - - } - /> - - - } - /> - } - /> - - - - - + } + /> + + + + + + ) } @@ -321,7 +352,13 @@ const Card = ({ return ( {suptitle && ( @@ -352,6 +389,7 @@ const cardStylesheet = createStyleSheet({ marginHorizontal: 20, padding: 20, borderRadius: 4, + borderBottomWidth: 2, }, text: { ...typography.body, diff --git a/app/(app)/extraStudy/index.tsx b/app/(app)/extraStudy/index.tsx new file mode 100644 index 0000000..505daf5 --- /dev/null +++ b/app/(app)/extraStudy/index.tsx @@ -0,0 +1,308 @@ +import { FullPageLoading } from '@/src/components/FullPageLoading' +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 { Subject, SubjectType } from '@/src/types/subject' +import { StringUtils } from '@/src/utils/stringUtils' +import { FontAwesome } from '@expo/vector-icons' +import { router } from 'expo-router' +import { useCallback, useMemo, useState } from 'react' +import { Pressable, Text, View } from 'react-native' +import { createStyleSheet, useStyles } from 'react-native-unistyles' +import { useSettings } from '@/src/hooks/useSettings' +import { + useFindAssignmentsByQuery, + useGetBurnedAssignmentsQuery, + useGetRecentLessonsQuery, +} from '@/src/api/localDb/assignment' +import { Category, SubjectPickerPage } from '@/src/components/SubjectPickerPage' +import { + useGetCriticalConditionReviewStatisticsQuery, + useGetLevelProgressionsQuery, + useGetRecentMistakeReviewsQuery, +} from '@/src/api/localDb/api' +import { useFindSubjectsByQuery } from '@/src/api/localDb/subject' + +export default function Index() { + const { styles } = useStyles(stylesheet) + const { settings } = useSettings() + const [interleave, setInterleave] = useState( + settings.interleave_advanced_lessons ?? false, + ) + const { data: burnedAssignments, isLoading: burnedAssignmentsIsLoading } = + useGetBurnedAssignmentsQuery() + const { data: criticalCondition, isLoading: criticalConditionIsLoading } = + useGetCriticalConditionReviewStatisticsQuery() + const { + data: recentMistakeReviews, + isLoading: recentMistakeReviewsIsLoading, + } = useGetRecentMistakeReviewsQuery() + const { data: recentLessons, isLoading: recentLessonsIsLoading } = + useGetRecentLessonsQuery() + const { data: levelProgressions, isLoading: levelProgressionsIsLoading } = + useGetLevelProgressionsQuery() + const lastThreeLevels = useMemo( + () => + // Copy to allow mutation + [...(levelProgressions ?? [])] + .sort((a, b) => b.level - a.level) + .slice(0, 3) + .map(lp => lp.level), + [levelProgressions], + ) + const { data: subjectsForLevels, isLoading: subjectsForLevelsIsLoading } = + useFindSubjectsByQuery({ levels: lastThreeLevels }) + const lastThreeLevelSubjectIds = useMemo( + () => (subjectsForLevels ?? []).map(e => e.id), + [subjectsForLevels], + ) + const { + data: assignmentsForLevelSubjects, + isLoading: assignmentsForLevelSubjectsIsLoading, + } = useFindAssignmentsByQuery({ subjectIds: lastThreeLevelSubjectIds }) + const startedAssignments = useMemo( + () => (assignmentsForLevelSubjects ?? []).filter(e => !!e.started_at), + [assignmentsForLevelSubjects], + ) + const startedAssignmentsSubjectIds = useMemo( + () => startedAssignments.map(e => e.subject_id), + [startedAssignments], + ) + const startedSubjectsForLevels = useMemo( + () => + (subjectsForLevels ?? []).filter(e => + startedAssignmentsSubjectIds.includes(e.id), + ), + [subjectsForLevels, startedAssignmentsSubjectIds], + ) + + const subjectIds = useMemo( + () => + (burnedAssignments ?? []) + .map(e => e.subject_id) + .concat((criticalCondition ?? []).map(e => e.subject_id)) + .concat((recentMistakeReviews ?? []).map(e => e.subject_id)) + .concat((recentLessons ?? []).map(e => e.subject_id)), + [burnedAssignments, criticalCondition, recentMistakeReviews, recentLessons], + ) + + const { subjects } = useSubjectCache(subjectIds, false) + const subjectsMap = useMemo( + () => + subjects.reduce( + (map, subject) => { + map[subject.id] = subject + return map + }, + {} as Record, + ), + [subjects], + ) + // We need a way to be able to select other started subjects. There could be + // a lot of them, so fetching all and grouping by level is not an option. + // Options: + // 1. Add search and group selected from search elements under 'other' + // 2. Add a way to lazy load subjects for levels. (is there a point to select + // subjects by level?) + // + // Decision: Load last 3 levels and add search. + + const isLoading = useMemo( + () => + burnedAssignmentsIsLoading || + criticalConditionIsLoading || + recentMistakeReviewsIsLoading || + recentLessonsIsLoading || + levelProgressionsIsLoading || + subjectsForLevelsIsLoading || + assignmentsForLevelSubjectsIsLoading, + [ + burnedAssignmentsIsLoading, + criticalConditionIsLoading, + recentMistakeReviewsIsLoading, + recentLessonsIsLoading, + levelProgressionsIsLoading, + subjectsForLevelsIsLoading, + assignmentsForLevelSubjectsIsLoading, + ], + ) + + const categories = useMemo(() => { + const result: Category[] = [] + if (recentMistakeReviews && recentMistakeReviews.length > 0) { + result.push({ + name: 'Recent Mistakes', + children: recentMistakeReviews + .map(e => subjectsMap[e.subject_id]) + .filter(Boolean), + }) + } + if (recentLessons && recentLessons.length > 0) { + result.push({ + name: 'Recent Lessons', + children: recentLessons + .map(e => subjectsMap[e.subject_id]) + .filter(Boolean), + }) + } + if (criticalCondition && criticalCondition.length > 0) { + result.push({ + name: 'Critical Condition', + children: criticalCondition + .map(e => subjectsMap[e.subject_id]) + .filter(Boolean), + }) + } + if (burnedAssignments && burnedAssignments.length > 0) { + result.push({ + name: 'Burned Items', + children: burnedAssignments + .map(e => subjectsMap[e.subject_id]) + .filter(Boolean), + }) + } + const typesOrder: SubjectType[] = [ + 'radical', + 'kanji', + 'vocabulary', + 'kana_vocabulary', + ] + for (const level of lastThreeLevels) { + const children = (startedSubjectsForLevels ?? []) + .filter(e => e.level === level) + .sort((a, b) => typesOrder.indexOf(a.type) - typesOrder.indexOf(b.type)) + if (children.length > 0) { + result.push({ + name: `Level ${level}`, + children: children, + }) + } + } + return result + }, [ + recentMistakeReviews, + recentLessons, + criticalCondition, + burnedAssignments, + subjectsMap, + lastThreeLevels, + startedSubjectsForLevels, + ]) + + const startLessons = useCallback( + (subjectIds: number[]) => { + router.replace({ + pathname: '/lessons', + params: { + assignmentIds: subjectIds, + interleave: interleave.toString(), + }, + }) + }, + [interleave], + ) + + const startReviews = useCallback((subjectIds: number[]) => { + router.replace({ + pathname: '/quiz', + params: { + subjectIds: subjectIds, + quizMode: 'quiz', + }, + }) + }, []) + + if (isLoading) { + return + } + + return ( + { + return ( + <> + { + // TODO: implement lessons + } + startLessons(selectedIds)}> + + Start Lessons + {selectedIds.length > 0 && ( + <> + + + + {selectedIds.length} + + + + )} + + + + startReviews(selectedIds)}> + + Start Reviews + {selectedIds.length > 0 && ( + <> + + + + {selectedIds.length} + + + + )} + + + + ) + }} + /> + ) +} + +const stylesheet = createStyleSheet({ + startButtonView: { + backgroundColor: Colors.pink, + borderRadius: 3, + flex: 1, + width: '80%', + height: 42, + alignItems: 'center', + justifyContent: 'center', + borderBottomWidth: 4, + borderColor: Colors.getBottomBorderColor(Colors.pink), + }, + startButtonText: { + ...typography.callout, + color: Colors.white, + }, + startbuttonLenTextContainer: { + backgroundColor: Colors.white, + borderRadius: 18, + paddingHorizontal: 7, + paddingVertical: 3, + }, + startbuttonLenText: { + ...typography.callout, + color: Colors.pink, + }, +}) diff --git a/app/(app)/lessonPicker/index.tsx b/app/(app)/lessonPicker/index.tsx index 6682f03..7b9335b 100644 --- a/app/(app)/lessonPicker/index.tsx +++ b/app/(app)/lessonPicker/index.tsx @@ -1,5 +1,4 @@ import { FullPageLoading } from '@/src/components/FullPageLoading' -import { SubjectTile } from '@/src/components/SubjectTile' import { Colors } from '@/src/constants/Colors' import { appStyles } from '@/src/constants/styles' import typography from '@/src/constants/typography' @@ -8,13 +7,13 @@ import { Subject, SubjectType } from '@/src/types/subject' import { StringUtils } from '@/src/utils/stringUtils' import { FontAwesome } from '@expo/vector-icons' import { router } from 'expo-router' -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react' -import { Pressable, ScrollView, Text, View } from 'react-native' -import { useSafeArea } from 'react-native-safe-area-context' +import { useCallback, useMemo, useState } from 'react' +import { Pressable, Text, View } from 'react-native' import { createStyleSheet, useStyles } from 'react-native-unistyles' -import { BlurView } from 'expo-blur' import { useSettings } from '@/src/hooks/useSettings' import { useGetLessonsQuery } from '@/src/api/localDb/assignment' +import { SubjectPickerPage } from '@/src/components/SubjectPickerPage' +import { filterNotUndefined } from '@/src/utils/arrayUtils' export default function Index() { const { styles } = useStyles(stylesheet) @@ -24,47 +23,33 @@ export default function Index() { const subjectIds = useMemo(() => { return assignments.map(el => el.subject_id) }, [assignments]) - const [selectedIds, setSelectedIds] = useState([]) const { settings } = useSettings() const [interleave, setInterleave] = useState( settings.interleave_advanced_lessons ?? false, ) const { subjects, isLoading: subjectsIsLoading } = useSubjectCache(subjectIds) - const { bottom: bottomSafePadding } = useSafeArea() const isLoading = useMemo( () => assignmentsIsLoading || subjectsIsLoading, [assignmentsIsLoading, subjectsIsLoading], ) - const toggleSelected = useCallback( - (id: number) => { - setSelectedIds(prev => - prev.includes(id) ? prev.filter(el => el !== id) : [...prev, id], - ) + const startLessons = useCallback( + (subjectIds: number[]) => { + router.replace({ + pathname: '/lessons', + params: { + assignmentIds: (subjectIds.length > 0 + ? assignments.filter(e => subjectIds.includes(e.subject_id)) + : assignments + ).map(e => e.id), + interleave: interleave.toString(), + }, + }) }, - [setSelectedIds], + [assignments, interleave], ) - const selectedAssignments = useMemo(() => { - return assignments - .filter(el => selectedIds.includes(el.subject_id)) - .map(e => e.id) - }, [assignments, selectedIds]) - - const startLessons = useCallback(() => { - router.replace({ - pathname: '/lessons', - params: { - assignmentIds: - selectedAssignments.length > 0 - ? selectedAssignments - : assignments.map(e => e.id), - interleave: interleave.toString(), - }, - }) - }, [selectedAssignments, assignments, interleave]) - const subjectsByLevel = useMemo(() => { const result = new Map>() subjects.forEach(subject => { @@ -109,118 +94,82 @@ export default function Index() { return result }, [subjects]) - const buttonCopy = useMemo( - () => (selectedIds.length === 0 ? 'Batch, Please!' : 'Start lessons'), - [selectedIds], - ) + const categories = useMemo(() => { + return filterNotUndefined( + Array.from(subjectsByLevel.keys()).map(level => { + const levelBucket = subjectsByLevel.get(level) + const levelKeys = Array.from(levelBucket?.keys() ?? []) + if (levelBucket) { + return { + name: `Level ${level}`, + children: filterNotUndefined( + levelKeys.map(type => { + const typeBucket = levelBucket.get(type) + if (!typeBucket) { + return undefined + } + + return { + name: StringUtils.capitalizeFirstLetter(type), + children: typeBucket, + } + }), + ), + } + } + return undefined + }), + ) + }, [subjectsByLevel]) if (isLoading) { return } return ( - <> - - {subjectsByLevel && - Array.from(subjectsByLevel.keys()).map(level => { - const levelBucket = subjectsByLevel.get(level) - const levelKeys = Array.from(levelBucket?.keys() ?? []) - return ( - - Level {level} - {levelBucket && - levelKeys.map((type, i) => { - const subjects = levelBucket.get(type) - return ( - - - - {StringUtils.capitalizeFirstLetter(type)} - - - {subjects && - subjects.map(subject => { - const isSelected = selectedIds.includes( - subject.id, - ) - return ( - toggleSelected(subject.id)}> - - - - - ) - })} - - - {i < levelKeys.length - 1 && ( - - )} - - ) - })} - - ) - })} - - - - setInterleave(!interleave)}> - - - Interleave - - + ( + <> + setInterleave(!interleave)}> + + + Interleave + + - - - {buttonCopy} - {selectedIds.length > 0 && ( - <> - - - - {selectedIds.length} - - - - )} - - - - + startLessons(selectedIds)}> + + + {selectedIds.length === 0 ? 'Batch, Please!' : 'Start lessons'} + + {selectedIds.length > 0 && ( + <> + + + + {selectedIds.length} + + + + )} + + + + )} + /> ) } const stylesheet = createStyleSheet({ - pageView: { - flex: 1, - }, - bottomBar: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - paddingTop: 16, - alignItems: 'center', - }, startButtonView: { backgroundColor: Colors.pink, borderRadius: 3, @@ -245,25 +194,4 @@ const stylesheet = createStyleSheet({ ...typography.callout, color: Colors.pink, }, - viewText: { - ...typography.titleC, - paddingHorizontal: 4, - paddingVertical: 8, - }, - levelView: { - padding: 8, - borderRadius: 8, - }, - typeView: { - backgroundColor: Colors.white, - padding: 8, - borderRadius: 8, - }, - subjectsRow: { - ...appStyles.row, - flexWrap: 'wrap', - }, - subjectTile: { - margin: 4, - }, }) diff --git a/app/(app)/lessons/index.tsx b/app/(app)/lessons/index.tsx index 2ccb6c1..453428f 100644 --- a/app/(app)/lessons/index.tsx +++ b/app/(app)/lessons/index.tsx @@ -17,7 +17,6 @@ import { Colors } from '@/src/constants/Colors' import { AntDesign } from '@expo/vector-icons' import { FullPageLoading } from '@/src/components/FullPageLoading' import { useSubjectCache } from '@/src/hooks/useSubjectCache' -import { useAppSelector } from '@/src/hooks/redux' import { SubjectTile } from '@/src/components/SubjectTile' import { CompositionPage } from '@/src/components/CompositionPage' import { MeaningPage } from '@/src/components/MeaningPage' diff --git a/src/api/localDb/api.ts b/src/api/localDb/api.ts index 5914b9c..4a605f6 100644 --- a/src/api/localDb/api.ts +++ b/src/api/localDb/api.ts @@ -12,7 +12,7 @@ import { getTableConfig, } from 'drizzle-orm/sqlite-core' import '@/src/db/schema' -import { Query, eq, getTableColumns } from 'drizzle-orm' +import { Query, eq, lt, gte, and, getTableColumns, or } from 'drizzle-orm' import { levelProgressionsTable, reviewStatisticsTable, @@ -21,6 +21,11 @@ import { import { ReviewStatistic } from '@/src/types/reviewStatistic' import { Review } from '@/src/types/review' import { LevelProgression } from '@/src/types/levelProgression' +import { + dateToUnixTimestamp, + getLocalDayAgoTime, + getLocalDayStart, +} from '@/src/utils/dateUtils' const qb = new QueryBuilder() @@ -59,6 +64,11 @@ export const localDbApi = createApi({ 'LevelProgression', ], endpoints: builder => ({ + saveReviewStatistics: builder.mutation({ + invalidatesTags: ['ReviewStatistic'], + query: reviewStatistics => + upsertTable(reviewStatisticsTable, reviewStatistics), + }), getReviewStatistic: builder.query({ providesTags: ['ReviewStatistic'], query: subject_id => @@ -70,10 +80,19 @@ export const localDbApi = createApi({ transformResponse: (rows: any[]) => transformDrizzleResponse(rows, reviewStatisticsTable, false), }), - saveReviewStatistics: builder.mutation({ - invalidatesTags: ['ReviewStatistic'], - query: reviewStatistics => - upsertTable(reviewStatisticsTable, reviewStatistics), + getCriticalConditionReviewStatistics: builder.query< + ReviewStatistic[], + void + >({ + providesTags: ['ReviewStatistic'], + query: () => + qb + .select() + .from(reviewStatisticsTable) + .where(lt(reviewStatisticsTable.percentage_correct, 75)) + .toSQL(), + transformResponse: (rows: any[]) => + transformDrizzleResponse(rows, reviewStatisticsTable), }), saveReviews: builder.mutation({ invalidatesTags: ['Review'], @@ -90,12 +109,38 @@ export const localDbApi = createApi({ transformResponse: (rows: any[]) => transformDrizzleResponse(rows, levelProgressionsTable), }), + getRecentMistakeReviews: builder.query({ + providesTags: ['Review'], + query: () => + qb + .select() + .from(reviewsTable) + .where( + and( + gte( + reviewsTable.created_at, + dateToUnixTimestamp(getLocalDayAgoTime()), + ), + or( + gte(reviewsTable.incorrect_meaning_answers, 0), + gte(reviewsTable.incorrect_reading_answers, 0), + ), + ), + ) + .toSQL(), + transformResponse: (rows: any[]) => + transformDrizzleResponse(rows, reviewsTable), + }), }), }) export const { useGetReviewStatisticQuery, + useGetCriticalConditionReviewStatisticsQuery, useGetLevelProgressionsQuery, + + useGetRecentMistakeReviewsQuery, + useSaveReviewStatisticsMutation, useSaveLevelProgressionsMutation, } = localDbApi diff --git a/src/api/localDb/assignment.ts b/src/api/localDb/assignment.ts index 3fa2c10..1abf427 100644 --- a/src/api/localDb/assignment.ts +++ b/src/api/localDb/assignment.ts @@ -1,13 +1,4 @@ -import { - SQL, - and, - eq, - gte, - inArray, - isNotNull, - isNull, - lte, -} from 'drizzle-orm' +import { SQL, and, eq, gte, inArray, isNotNull, isNull, lte } from 'drizzle-orm' import { QueryBuilder } from 'drizzle-orm/sqlite-core' import { localDbApi, transformDrizzleResponse, upsertTable } from './api' import { Assignment } from '@/src/types/assignment' @@ -43,6 +34,23 @@ export const localDbAssignmentsApi = localDbApi.injectEndpoints({ qb.select().from(table).where(inArray(table.id, assignmentIds)).toSQL(), transformResponse: (rows: any[]) => transformDrizzleResponse(rows, table), }), + getBurnedAssignments: builder.query({ + providesTags: ['Assignment'], + query: () => + // An item could be resurrected, so burned_at might not be enough + qb.select().from(table).where(eq(table.srs_stage, 9)).toSQL(), + transformResponse: (rows: any[]) => transformDrizzleResponse(rows, table), + }), + getRecentLessons: builder.query({ + providesTags: ['Assignment'], + query: () => + qb + .select() + .from(table) + .where(and(isNotNull(table.available_at), isNull(table.passed_at))) + .toSQL(), + transformResponse: (rows: any[]) => transformDrizzleResponse(rows, table), + }), getAssignmentsForForecast: builder.query({ providesTags: ['Assignment'], // we want all reviews, we will use already available for the forecast @@ -119,6 +127,8 @@ export const localDbAssignmentsApi = localDbApi.injectEndpoints({ export const { useGetAssignmentQuery, useGetAssignmentsQuery, + useGetBurnedAssignmentsQuery, + useGetRecentLessonsQuery, useGetAssignmentForSubjectQuery, useGetAssignmentsForForecastQuery, useGetReviewsQuery, diff --git a/src/api/localDb/subject.ts b/src/api/localDb/subject.ts index c424dc2..fc10612 100644 --- a/src/api/localDb/subject.ts +++ b/src/api/localDb/subject.ts @@ -25,13 +25,13 @@ export const localDbSubjectsApi = localDbApi.injectEndpoints({ }), findSubjectsBy: builder.query< Subject[], - { level?: number; type?: SubjectType } + { levels?: number[]; type?: SubjectType } >({ providesTags: ['Subject'], - query: ({ level, type }) => { + query: ({ levels, type }) => { let sql: SQL[] = [] - if (level !== undefined) { - sql.push(eq(table.level, level)) + if (levels !== undefined) { + sql.push(inArray(table.level, levels)) } if (type !== undefined) { sql.push(eq(table.type, type)) diff --git a/src/components/Collapsible.tsx b/src/components/Collapsible.tsx index 42dd90a..4033cbb 100644 --- a/src/components/Collapsible.tsx +++ b/src/components/Collapsible.tsx @@ -1,41 +1,86 @@ -import Ionicons from '@expo/vector-icons/Ionicons'; -import { PropsWithChildren, useState } from 'react'; -import { StyleSheet, TouchableOpacity, useColorScheme } from 'react-native'; -import { ThemedView } from './ThemedView'; -import { Colors } from '../constants/Colors'; -import { ThemedText } from './ThemedText'; +// AI +// TODO: Fix flickering on first frame (when we measure the height) +import React, { useState, useRef, useEffect } from 'react' +import { View, Text, StyleSheet, Pressable } from 'react-native' -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useColorScheme() ?? 'light'; +interface CollapsibleProps { + children: React.ReactNode + disabled?: boolean + buttonBuilder?: (expanded: boolean) => React.ReactNode + previewHeight: number + expandButtonText?: string + collapseButtonText?: string +} + +const Collapsible: React.FC = ({ + children, + disabled = false, + previewHeight, + expandButtonText = 'Show More', + collapseButtonText = 'Show Less', + buttonBuilder, +}) => { + const [expanded, setExpanded] = useState(false) + const [collapsible, setCollapsible] = useState(false) + const contentRef = useRef(null) + + useEffect(() => { + if (contentRef.current) { + contentRef.current.measure((x, y, width, height) => { + setCollapsible(height > previewHeight) + }) + } + }, [previewHeight]) + + const toggleExpanded = () => { + setExpanded(!expanded) + } + + if (disabled) return <>{children} return ( - - setIsOpen((value) => !value)} - activeOpacity={0.8}> - - {title} - - {isOpen && {children}} - - ); + + + {children} + + {collapsible && ( + + {buttonBuilder ? ( + buttonBuilder(expanded) + ) : ( + + + {expanded ? collapseButtonText : expandButtonText} + + + )} + + )} + + ) } const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', + content: { + marginBottom: 10, + }, + button: { + backgroundColor: '#e0e0e0', + padding: 10, + borderRadius: 5, alignItems: 'center', - gap: 6, }, - content: { - marginTop: 6, - marginLeft: 24, + buttonText: { + color: '#333', + fontWeight: 'bold', }, -}); +}) + +export default Collapsible diff --git a/src/components/SubjectPickerPage.tsx b/src/components/SubjectPickerPage.tsx new file mode 100644 index 0000000..5c3f4d7 --- /dev/null +++ b/src/components/SubjectPickerPage.tsx @@ -0,0 +1,206 @@ +import { SubjectTile } from '@/src/components/SubjectTile' +import { Colors } from '@/src/constants/Colors' +import { appStyles } from '@/src/constants/styles' +import typography from '@/src/constants/typography' +import { Subject } from '@/src/types/subject' +import { Fragment, useCallback, useState } from 'react' +import { + LayoutChangeEvent, + Pressable, + ScrollView, + Text, + View, +} from 'react-native' +import { useSafeArea } from 'react-native-safe-area-context' +import { createStyleSheet, useStyles } from 'react-native-unistyles' +import { BlurView } from 'expo-blur' +import Collapsible from './Collapsible' + +const MAX_CATEGORY_HEIGHT = 84 + +export type Category = { + name: string + children: Category[] | Subject[] +} + +const isCategoryArray = ( + children: Category[] | Subject[], +): children is Category[] => children.length > 0 && 'name' in children[0] + +type SubjectPickerPageProps = { + categories: Category[] + bottomBarBuilder: (selectedIds: number[]) => React.ReactNode + expandable?: boolean +} + +export const SubjectPickerPage = ({ + categories, + bottomBarBuilder, + expandable = false, +}: SubjectPickerPageProps) => { + const { styles } = useStyles(stylesheet) + const { bottom: bottomSafePadding } = useSafeArea() + const [selectedIds, setSelectedIds] = useState([]) + const [bottomBarHeight, setBottomBarHeight] = useState(0) + + const toggleSelected = useCallback( + (id: number) => { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(el => el !== id) : [...prev, id], + ) + }, + [setSelectedIds], + ) + const selectAll = useCallback( + (ids: number[]) => setSelectedIds(prev => [...new Set([...prev, ...ids])]), + [setSelectedIds], + ) + const deselectAll = useCallback( + (ids: number[]) => + setSelectedIds(prev => prev.filter(el => !ids.includes(el))), + [setSelectedIds], + ) + + return ( + <> + + {categories.map(topCategory => { + const childrenCategories = isCategoryArray(topCategory.children) + ? topCategory.children + : [topCategory] + return ( + + { + // Show title only if we have one more level of categories + isCategoryArray(topCategory.children) && ( + {topCategory.name} + ) + } + {childrenCategories.map((category, i) => { + if (isCategoryArray(category.children)) { + throw 'Only 1 nested category is supported' + } + const subjects = category.children + const subjectIds = subjects.map(e => e.id) + const isAllSelected = subjectIds.every(e => + selectedIds.includes(e), + ) + return ( + + + + {category.name} + + isAllSelected + ? deselectAll(subjectIds) + : selectAll(subjectIds) + }> + + Select {isAllSelected ? 'None' : 'All'} + + + + ( + + + {expanded ? 'Show Less' : 'Show More'} + + + )}> + + {subjects.map(subject => { + const isSelected = selectedIds.includes(subject.id) + return ( + toggleSelected(subject.id)}> + + + + + ) + })} + + + + {i < childrenCategories.length - 1 && ( + + )} + + ) + })} + + ) + })} + + + setBottomBarHeight(e.nativeEvent.layout.height)} + style={[styles.bottomBar, { paddingBottom: bottomSafePadding }]}> + {bottomBarBuilder(selectedIds)} + + + ) +} + +const stylesheet = createStyleSheet({ + pageView: { + flex: 1, + }, + bottomBar: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingTop: 16, + alignItems: 'center', + }, + viewText: { + ...typography.titleC, + paddingHorizontal: 4, + paddingVertical: 8, + }, + selectAllText: { + ...typography.body, + color: Colors.pink, + }, + topCategoryView: { + padding: 8, + borderRadius: 8, + }, + categoryView: { + backgroundColor: Colors.white, + padding: 8, + borderRadius: 8, + }, + subjectsRow: { + ...appStyles.row, + flexWrap: 'wrap', + }, + showMoreButton: { + borderWidth: 1, + borderColor: Colors.blue, + padding: 10, + borderRadius: 4, + alignItems: 'center', + }, + showMoreButtonText: { + ...typography.caption, + color: Colors.blue, + }, + subjectTile: { + margin: 4, + }, +}) diff --git a/src/components/SubjectSymbol.tsx b/src/components/SubjectSymbol.tsx index 6e6e6ba..d343880 100644 --- a/src/components/SubjectSymbol.tsx +++ b/src/components/SubjectSymbol.tsx @@ -87,6 +87,6 @@ export const SubjectSymbol = ({ if (subject.characters) { return {subject.characters} } else { - return '??' + return ?? } } diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts new file mode 100644 index 0000000..bbd69eb --- /dev/null +++ b/src/utils/arrayUtils.ts @@ -0,0 +1,3 @@ +export function filterNotUndefined(arr: (T | undefined)[]): T[] { + return arr.filter((item): item is T => item !== undefined) +} diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index 67cf361..87e97ce 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -4,6 +4,17 @@ export const getLocalDayStart = (): Date => { return new Date(now.getFullYear(), now.getMonth(), now.getDate()) } +export const getLocalDayAgoTime = (): Date => { + const now = new Date() + return new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() - 1, + now.getHours(), + now.getMinutes(), + ) +} + export const getLocalStartOfDayInUTCString = (): string => { const localMidnight = getLocalDayStart() // Convert local midnight to UTC