From 388063adab0401062457b4d41a96504422b323bb Mon Sep 17 00:00:00 2001 From: mattrace-gloo Date: Thu, 9 Jul 2026 15:08:55 -0700 Subject: [PATCH 1/2] [#50]: Build Prepare for Offline chapter selection Add project picker, chapter selection accordion with per-book grids, and a dedicated prepare-offline query module to avoid conflicts with in-flight recording PRs. --- .../prepare-offline/BookChapterSection.tsx | 113 +++++++++++++ .../ChapterSelectionAccordion.tsx | 100 ++++++++++++ .../ChapterSelectorGrid.test.tsx | 45 ++++++ .../prepare-offline/ChapterSelectorGrid.tsx | 90 +++++++++++ src/app/prepare-offline/ProjectPickerStep.tsx | 67 ++++++++ .../screens/PrepareForOfflineScreen.test.tsx | 153 ++++++++++++++++++ src/app/screens/PrepareForOfflineScreen.tsx | 143 +++++++++++++++- src/db/queries.prepareOffline.ts | 59 +++++++ src/hooks/usePrepareOfflineSelection.test.ts | 110 +++++++++++++ src/hooks/usePrepareOfflineSelection.ts | 145 +++++++++++++++++ src/types/navigation/types.ts | 2 +- src/types/prepareOffline/types.ts | 13 ++ src/utils/groupChaptersByBook.test.ts | 53 ++++++ src/utils/groupChaptersByBook.ts | 28 ++++ 14 files changed, 1112 insertions(+), 9 deletions(-) create mode 100644 src/app/prepare-offline/BookChapterSection.tsx create mode 100644 src/app/prepare-offline/ChapterSelectionAccordion.tsx create mode 100644 src/app/prepare-offline/ChapterSelectorGrid.test.tsx create mode 100644 src/app/prepare-offline/ChapterSelectorGrid.tsx create mode 100644 src/app/prepare-offline/ProjectPickerStep.tsx create mode 100644 src/app/screens/PrepareForOfflineScreen.test.tsx create mode 100644 src/db/queries.prepareOffline.ts create mode 100644 src/hooks/usePrepareOfflineSelection.test.ts create mode 100644 src/hooks/usePrepareOfflineSelection.ts create mode 100644 src/types/prepareOffline/types.ts create mode 100644 src/utils/groupChaptersByBook.test.ts create mode 100644 src/utils/groupChaptersByBook.ts diff --git a/src/app/prepare-offline/BookChapterSection.tsx b/src/app/prepare-offline/BookChapterSection.tsx new file mode 100644 index 0000000..d3b34b5 --- /dev/null +++ b/src/app/prepare-offline/BookChapterSection.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ChevronDown, ChevronUp, CircleCheck } from 'lucide-react-native'; +import { PrepareOfflineBookGroup } from '../../types/prepareOffline/types'; +import { theme, iconSizes, listIconStrokeWidth } from '../../theme'; +import { ChapterSelectorGrid } from './ChapterSelectorGrid'; + +interface BookChapterSectionProps { + book: PrepareOfflineBookGroup; + selectedIds: Set; + onToggleChapter: (chapterId: number) => void; + onToggleBook: (book: PrepareOfflineBookGroup) => void; + isBookFullySelected: boolean; +} + +export function BookChapterSection({ + book, + selectedIds, + onToggleChapter, + onToggleBook, + isBookFullySelected, +}: BookChapterSectionProps) { + const [expanded, setExpanded] = useState(true); + + return ( + + + setExpanded(prev => !prev)} + accessibilityRole="button" + accessibilityState={{ expanded }} + > + {book.bookName} + {expanded ? ( + + ) : ( + + )} + + onToggleBook(book)} + accessibilityRole="button" + accessibilityLabel={`Select all chapters in ${book.bookName}`} + > + + Select all + + + + {expanded ? ( + ch.id)} + chapterNumbers={book.chapters.map(ch => ch.chapterNumber)} + selectedIds={selectedIds} + onToggleChapter={onToggleChapter} + /> + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + gap: theme.spacing.sm, + paddingVertical: theme.spacing.sm, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing.sm, + }, + headerMain: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing.sm, + }, + bookTitle: { + flex: 1, + fontSize: theme.typography.sizes.md, + fontWeight: theme.typography.weights.semibold, + color: theme.colors.foreground, + }, + selectAllButton: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.xs, + }, + selectAllLabel: { + fontSize: theme.typography.sizes.sm, + color: theme.colors.mutedForeground, + }, +}); diff --git a/src/app/prepare-offline/ChapterSelectionAccordion.tsx b/src/app/prepare-offline/ChapterSelectionAccordion.tsx new file mode 100644 index 0000000..2b80eef --- /dev/null +++ b/src/app/prepare-offline/ChapterSelectionAccordion.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ChevronDown, ChevronUp } from 'lucide-react-native'; +import { PrepareOfflineBookGroup } from '../../types/prepareOffline/types'; +import { theme, iconSizes, listIconStrokeWidth } from '../../theme'; +import { BookChapterSection } from './BookChapterSection'; + +interface ChapterSelectionAccordionProps { + title: string; + expanded: boolean; + onToggleExpanded: () => void; + books: PrepareOfflineBookGroup[]; + selectedIds: Set; + onToggleChapter: (chapterId: number) => void; + onToggleBook: (book: PrepareOfflineBookGroup) => void; + isBookFullySelected: (book: PrepareOfflineBookGroup) => boolean; +} + +export function ChapterSelectionAccordion({ + title, + expanded, + onToggleExpanded, + books, + selectedIds, + onToggleChapter, + onToggleBook, + isBookFullySelected, +}: ChapterSelectionAccordionProps) { + return ( + + + {title} + {expanded ? ( + + ) : ( + + )} + + + {expanded ? ( + + {books.map(book => ( + + ))} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: theme.colors.cardBackground, + borderRadius: theme.radius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: theme.spacing.md, + paddingVertical: theme.spacing.md, + gap: theme.spacing.sm, + }, + title: { + flex: 1, + fontSize: theme.typography.sizes.md, + fontWeight: theme.typography.weights.semibold, + color: theme.colors.foreground, + }, + body: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + paddingHorizontal: theme.spacing.md, + paddingBottom: theme.spacing.md, + gap: theme.spacing.sm, + }, +}); diff --git a/src/app/prepare-offline/ChapterSelectorGrid.test.tsx b/src/app/prepare-offline/ChapterSelectorGrid.test.tsx new file mode 100644 index 0000000..3188c8f --- /dev/null +++ b/src/app/prepare-offline/ChapterSelectorGrid.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { ChapterSelectorGrid } from './ChapterSelectorGrid'; + +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + __esModule: true, + default: jest.fn(() => ({ width: 400, height: 800, scale: 1, fontScale: 1 })), +})); + +describe('ChapterSelectorGrid', () => { + it('renders chapter numbers and toggles selection', () => { + const onToggleChapter = jest.fn(); + const selectedIds = new Set(); + + const { rerender } = render( + , + ); + + expect(screen.getByText('3')).toBeTruthy(); + expect(screen.getByText('4')).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('Chapter 3')); + expect(onToggleChapter).toHaveBeenCalledWith(1); + + rerender( + , + ); + + expect(screen.getByLabelText('Chapter 3').props.accessibilityState).toEqual( + { + checked: true, + }, + ); + }); +}); diff --git a/src/app/prepare-offline/ChapterSelectorGrid.tsx b/src/app/prepare-offline/ChapterSelectorGrid.tsx new file mode 100644 index 0000000..a927955 --- /dev/null +++ b/src/app/prepare-offline/ChapterSelectorGrid.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { + StyleSheet, + Text, + TouchableOpacity, + View, + useWindowDimensions, +} from 'react-native'; +import { theme } from '../../theme'; + +const GRID_COLUMNS = 5; +const GRID_GAP = theme.spacing.sm; + +interface ChapterSelectorGridProps { + chapterNumbers: number[]; + chapterIds: number[]; + selectedIds: Set; + onToggleChapter: (chapterId: number) => void; +} + +export function ChapterSelectorGrid({ + chapterNumbers, + chapterIds, + selectedIds, + onToggleChapter, +}: ChapterSelectorGridProps) { + const { width: windowWidth } = useWindowDimensions(); + const horizontalPadding = theme.spacing.lg * 2; + const bookSectionPadding = theme.spacing.md * 2; + const availableWidth = + windowWidth - horizontalPadding - bookSectionPadding - GRID_GAP * 4; + const cellSize = Math.floor(availableWidth / GRID_COLUMNS); + + return ( + + {chapterIds.map((chapterId, index) => { + const selected = selectedIds.has(chapterId); + const chapterNumber = chapterNumbers[index]; + + return ( + onToggleChapter(chapterId)} + accessibilityRole="checkbox" + accessibilityState={{ checked: selected }} + accessibilityLabel={`Chapter ${chapterNumber}`} + > + + {chapterNumber} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: GRID_GAP, + }, + cell: { + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.background, + }, + cellSelected: { + backgroundColor: theme.colors.primary, + borderColor: theme.colors.primary, + }, + cellLabel: { + fontSize: theme.typography.sizes.sm, + fontWeight: theme.typography.weights.medium, + color: theme.colors.foreground, + }, + cellLabelSelected: { + color: theme.colors.primaryForeground, + }, +}); diff --git a/src/app/prepare-offline/ProjectPickerStep.tsx b/src/app/prepare-offline/ProjectPickerStep.tsx new file mode 100644 index 0000000..1c2a09c --- /dev/null +++ b/src/app/prepare-offline/ProjectPickerStep.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { FlatList, StyleSheet, Text, View } from 'react-native'; +import { ProjectSummary } from '../../types/db/types'; +import { useProjectsSummary } from '../../hooks/useProjectsSummary'; +import { EmptyState } from '../../components/ui/EmptyState'; +import { LoadingSpinner } from '../../components/ui/LoadingSpinner'; +import { ProjectRow } from '../../components/ui/ProjectRow'; +import { PROJECTS_EMPTY_MESSAGE } from '../../constants/messages'; +import { theme } from '../../theme'; + +interface ProjectPickerStepProps { + onSelectProject: (project: ProjectSummary) => void; +} + +export function ProjectPickerStep({ onSelectProject }: ProjectPickerStepProps) { + const { projects, loading } = useProjectsSummary(); + + if (loading) { + return ( + + + + ); + } + + if (projects.length === 0) { + return ; + } + + return ( + + Select a project + String(item.id)} + contentContainerStyle={styles.listContent} + renderItem={({ item }) => ( + onSelectProject(item)} /> + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + gap: theme.spacing.sm, + }, + label: { + fontSize: theme.typography.sizes.sm, + fontWeight: theme.typography.weights.medium, + color: theme.colors.mutedForeground, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + listContent: { + gap: theme.spacing.sm, + paddingBottom: theme.spacing.lg, + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: theme.spacing.xl, + }, +}); diff --git a/src/app/screens/PrepareForOfflineScreen.test.tsx b/src/app/screens/PrepareForOfflineScreen.test.tsx new file mode 100644 index 0000000..c5893d0 --- /dev/null +++ b/src/app/screens/PrepareForOfflineScreen.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react-native'; +import PrepareForOfflineScreen from './PrepareForOfflineScreen'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), + useRoute: () => ({ params: undefined }), +})); + +jest.mock('../../hooks/useProjectsSummary', () => ({ + useProjectsSummary: jest.fn(() => ({ + projects: [ + { + id: 5, + name: 'Luke', + target_language_name: 'Baka', + chapterCount: 2, + syncState: 'none', + }, + ], + loading: false, + refreshing: false, + refresh: jest.fn(), + })), +})); + +jest.mock('../../hooks/usePrepareOfflineSelection', () => ({ + usePrepareOfflineSelection: jest.fn(), +})); + +jest.mock('../../utils/parseUserId', () => ({ + parseUserId: () => 42, +})); + +jest.mock('react-native-svg', () => { + const MockReact = require('react'); + const { View } = require('react-native'); + const MockSvg = ({ children }: { children?: unknown }) => + MockReact.createElement(View, null, children); + return { + __esModule: true, + default: MockSvg, + Circle: MockSvg, + }; +}); + +jest.mock('lucide-react-native', () => { + const MockReact = require('react'); + const { View } = require('react-native'); + const MockIcon = () => MockReact.createElement(View); + return { + ChevronLeft: MockIcon, + ChevronRight: MockIcon, + ChevronUp: MockIcon, + ChevronDown: MockIcon, + CircleCheck: MockIcon, + }; +}); + +jest.mock('../../components/layout/ScreenContainer', () => ({ + ScreenContainer: ({ children }: { children: React.ReactNode }) => children, +})); + +const { usePrepareOfflineSelection } = jest.requireMock( + '../../hooks/usePrepareOfflineSelection', +); + +describe('PrepareForOfflineScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + usePrepareOfflineSelection.mockImplementation( + (projectId: number | null) => { + if (!projectId) { + return { + books: [], + loading: false, + error: null, + selectedIds: new Set(), + accordionExpanded: true, + setAccordionExpanded: jest.fn(), + accordionTitle: 'Selected chapters (0)', + toggleChapter: jest.fn(), + toggleBook: jest.fn(), + isBookFullySelected: () => false, + retry: jest.fn(), + }; + } + + return { + books: [ + { + bookId: 1, + bookName: 'Genesis', + chapters: [ + { + id: 100, + bookId: 1, + bookName: 'Genesis', + chapterNumber: 1, + assignedUserId: 42, + }, + ], + }, + ], + loading: false, + error: null, + selectedIds: new Set([100]), + accordionExpanded: true, + setAccordionExpanded: jest.fn(), + accordionTitle: 'Assigned chapters (1)', + toggleChapter: jest.fn(), + toggleBook: jest.fn(), + isBookFullySelected: () => true, + retry: jest.fn(), + }; + }, + ); + }); + + it('shows instruction and project picker when no project is selected', () => { + render(); + + expect( + screen.getByText( + 'Download project resources to work without a connection.', + ), + ).toBeTruthy(); + expect(screen.getByText('Select a project')).toBeTruthy(); + expect(screen.getByText('Luke')).toBeTruthy(); + }); + + it('shows chapter accordion after selecting a project', async () => { + render(); + + fireEvent.press(screen.getByText('Luke')); + + await waitFor(() => { + expect(screen.getByText('Assigned chapters (1)')).toBeTruthy(); + }); + expect(screen.getByText('Genesis')).toBeTruthy(); + }); +}); diff --git a/src/app/screens/PrepareForOfflineScreen.tsx b/src/app/screens/PrepareForOfflineScreen.tsx index 0c4caf0..288fd00 100644 --- a/src/app/screens/PrepareForOfflineScreen.tsx +++ b/src/app/screens/PrepareForOfflineScreen.tsx @@ -1,19 +1,124 @@ -import React, { useCallback } from 'react'; -import { ScrollView, StyleSheet, Text, View } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { PageHeader } from '../../components/layout/PageHeader'; import { PageHeaderBackButton } from '../../components/ui/PageHeaderBackButton'; import { ScreenContainer } from '../../components/layout/ScreenContainer'; +import { LoadingSpinner } from '../../components/ui/LoadingSpinner'; +import { EmptyState } from '../../components/ui/EmptyState'; +import { usePrepareOfflineSelection } from '../../hooks/usePrepareOfflineSelection'; +import { ProjectSummary } from '../../types/db/types'; import { RootStackParamList } from '../../types/navigation/types'; +import { parseUserId } from '../../utils/parseUserId'; import { theme } from '../../theme'; +import { ChapterSelectionAccordion } from '../prepare-offline/ChapterSelectionAccordion'; +import { ProjectPickerStep } from '../prepare-offline/ProjectPickerStep'; type Nav = StackNavigationProp; +type Route = RouteProp; + +const INSTRUCTION = 'Download project resources to work without a connection.'; export default function PrepareForOfflineScreen() { const navigation = useNavigation