diff --git a/metro.config.cjs b/metro.config.cjs index 745c3bf..35f2ca5 100644 --- a/metro.config.cjs +++ b/metro.config.cjs @@ -11,6 +11,15 @@ config.resolver = { ...config.resolver, assetExts: config.resolver.assetExts.filter(ext => ext !== 'svg'), sourceExts: [...config.resolver.sourceExts, 'svg'], + blockList: [ + ...(Array.isArray(config.resolver.blockList) + ? config.resolver.blockList + : config.resolver.blockList + ? [config.resolver.blockList] + : []), + /\/__tests__\//, + /\.test\.[jt]sx?$/, + ], }; module.exports = config; diff --git a/src/app/prepare-offline/BookChapterSection.tsx b/src/app/prepare-offline/BookChapterSection.tsx new file mode 100644 index 0000000..d87ee25 --- /dev/null +++ b/src/app/prepare-offline/BookChapterSection.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { PrepareOfflineBookGroup } from '../../types/prepareOffline/types'; +import { theme } from '../../theme'; +import { BookSectionHeader } from './BookSectionHeader'; +import { ChapterSelectorGrid } from './ChapterSelectorGrid'; + +interface BookChapterSectionProps { + book: PrepareOfflineBookGroup; + expanded: boolean; + selectedIds: Set; + onToggleExpanded: () => void; + onToggleChapter: (chapterId: number) => void; + onToggleBook: (book: PrepareOfflineBookGroup) => void; + isBookFullySelected: boolean; +} + +export function BookChapterSection({ + book, + expanded, + selectedIds, + onToggleExpanded, + onToggleChapter, + onToggleBook, + isBookFullySelected, +}: BookChapterSectionProps) { + const selectedCount = book.chapters.filter(ch => + selectedIds.has(ch.id), + ).length; + + return ( + + onToggleBook(book)} + /> + {expanded ? ( + + ch.id)} + chapterNumbers={book.chapters.map(ch => ch.chapterNumber)} + selectedIds={selectedIds} + onToggleChapter={onToggleChapter} + /> + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + overflow: 'hidden', + borderRadius: theme.radius.sm, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.cardBackground, + }, + grid: { + paddingHorizontal: theme.spacing.sm, + paddingBottom: theme.spacing.sm, + }, +}); diff --git a/src/app/prepare-offline/BookSectionHeader.tsx b/src/app/prepare-offline/BookSectionHeader.tsx new file mode 100644 index 0000000..b985c9e --- /dev/null +++ b/src/app/prepare-offline/BookSectionHeader.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ChevronDown, ChevronUp } from 'lucide-react-native'; +import { theme, iconSizes, listIconStrokeWidth } from '../../theme'; +import { SelectionCheckbox } from './SelectionCheckbox'; + +interface BookSectionHeaderProps { + bookName: string; + expanded: boolean; + selectedCount: number; + totalChapters: number; + isBookFullySelected: boolean; + onToggleExpanded: () => void; + onToggleBook: () => void; +} + +export function BookSectionHeader({ + bookName, + expanded, + selectedCount, + totalChapters, + isBookFullySelected, + onToggleExpanded, + onToggleBook, +}: BookSectionHeaderProps) { + const hasSelection = selectedCount > 0; + + return ( + + + {bookName} + + + + Select all + + + {expanded ? ( + + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + minHeight: 40, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: theme.spacing.sm, + gap: theme.spacing.sm, + }, + titleButton: { + flexShrink: 1, + minHeight: 40, + alignItems: 'center', + justifyContent: 'center', + }, + bookTitle: { + fontSize: theme.typography.sizes.sm, + fontWeight: theme.typography.weights.semibold, + color: theme.colors.foreground, + }, + selectAllButton: { + minHeight: 40, + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.xs, + }, + selectAllLabel: { + fontSize: theme.typography.sizes.xs, + color: theme.colors.mutedForeground, + }, + chevronButton: { + minWidth: 36, + minHeight: 40, + marginLeft: 'auto', + alignItems: 'flex-end', + justifyContent: 'center', + }, +}); diff --git a/src/app/prepare-offline/ChapterSelectionAccordion.tsx b/src/app/prepare-offline/ChapterSelectionAccordion.tsx new file mode 100644 index 0000000..91a6280 --- /dev/null +++ b/src/app/prepare-offline/ChapterSelectionAccordion.tsx @@ -0,0 +1,103 @@ +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; + expandedBookIds: Set; + onToggleBookExpanded: (bookId: number) => void; + onToggleChapter: (chapterId: number) => void; + onToggleBook: (book: PrepareOfflineBookGroup) => void; + isBookFullySelected: (book: PrepareOfflineBookGroup) => boolean; +} + +export function ChapterSelectionAccordion({ + title, + expanded, + onToggleExpanded, + books, + selectedIds, + expandedBookIds, + onToggleBookExpanded, + onToggleChapter, + onToggleBook, + isBookFullySelected, +}: ChapterSelectionAccordionProps) { + return ( + + + {title} + {expanded ? ( + + ) : ( + + )} + + + {expanded ? ( + + {books.map(book => ( + onToggleBookExpanded(book.bookId)} + onToggleChapter={onToggleChapter} + onToggleBook={onToggleBook} + isBookFullySelected={isBookFullySelected(book)} + /> + ))} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: theme.colors.cardBackground, + borderRadius: theme.radius.md, + borderWidth: 1, + borderColor: theme.colors.border, + paddingHorizontal: theme.spacing.md, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 44, + paddingVertical: theme.spacing.sm, + gap: theme.spacing.sm, + }, + title: { + flex: 1, + fontSize: theme.typography.sizes.sm, + fontWeight: theme.typography.weights.medium, + color: theme.colors.foreground, + }, + books: { + gap: theme.spacing.xs, + paddingBottom: theme.spacing.md, + }, +}); diff --git a/src/app/prepare-offline/ChapterSelectorGrid.test.tsx b/src/app/prepare-offline/ChapterSelectorGrid.test.tsx new file mode 100644 index 0000000..b070527 --- /dev/null +++ b/src/app/prepare-offline/ChapterSelectorGrid.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { ChapterSelectorGrid } from './ChapterSelectorGrid'; + +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..2b550d3 --- /dev/null +++ b/src/app/prepare-offline/ChapterSelectorGrid.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { theme } from '../../theme'; +import { SelectionCheckbox } from './SelectionCheckbox'; + +const GRID_COLUMNS = 5; + +interface ChapterSelectorGridProps { + chapterNumbers: number[]; + chapterIds: number[]; + selectedIds: Set; + onToggleChapter: (chapterId: number) => void; +} + +export function ChapterSelectorGrid({ + chapterNumbers, + chapterIds, + selectedIds, + onToggleChapter, +}: ChapterSelectorGridProps) { + 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', + }, + cell: { + width: `${100 / GRID_COLUMNS}%`, + minHeight: 36, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing.xs, + paddingHorizontal: theme.spacing.xs, + paddingVertical: theme.spacing.sm, + }, + cellLabel: { + minWidth: 14, + fontSize: theme.typography.sizes.sm, + color: theme.colors.foreground, + }, +}); diff --git a/src/app/prepare-offline/ProjectPickerStep.tsx b/src/app/prepare-offline/ProjectPickerStep.tsx new file mode 100644 index 0000000..d6ab587 --- /dev/null +++ b/src/app/prepare-offline/ProjectPickerStep.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { 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 + + {projects.map(project => ( + onSelectProject(project)} + /> + ))} + + + ); +} + +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/prepare-offline/SelectionCheckbox.tsx b/src/app/prepare-offline/SelectionCheckbox.tsx new file mode 100644 index 0000000..21ae60a --- /dev/null +++ b/src/app/prepare-offline/SelectionCheckbox.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Check } from 'lucide-react-native'; +import { theme, listIconStrokeWidth } from '../../theme'; + +interface SelectionCheckboxProps { + selected: boolean; + /** Show checkmark for partial selection (select-all indeterminate). */ + showCheck?: boolean; +} + +export function SelectionCheckbox({ + selected, + showCheck = selected, +}: SelectionCheckboxProps) { + return ( + + {showCheck ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + box: { + width: 16, + height: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.colors.primary, + backgroundColor: theme.colors.background, + alignItems: 'center', + justifyContent: 'center', + }, + boxSelected: { + backgroundColor: theme.colors.primary, + }, +}); diff --git a/src/app/screens/DraftingScreen.tsx b/src/app/screens/DraftingScreen.tsx index 4faab68..e9f421b 100644 --- a/src/app/screens/DraftingScreen.tsx +++ b/src/app/screens/DraftingScreen.tsx @@ -127,7 +127,7 @@ export default function DraftingScreen() { if (loading) { return ( - + @@ -138,7 +138,7 @@ export default function DraftingScreen() { if (!chapterData) { return ( - + No chapter data found @@ -148,7 +148,7 @@ export default function DraftingScreen() { } return ( - + >(); const [activeTab, setActiveTab] = useState('myWork'); const [refreshKey, setRefreshKey] = useState(0); @@ -43,7 +45,16 @@ export default function HomeScreen({ onSyncComplete: handleSyncComplete, }); - const { status: syncStatus } = useSyncStatus({ isSyncing, refreshKey }); + const { + status: syncStatus, + needsDownloadSync, + isOnline, + } = useSyncStatus({ + isSyncing, + refreshKey, + }); + + const autoRepairSyncAttempted = useRef(false); useEffect(() => { const unsubscribeComplete = onSyncComplete(() => { @@ -61,12 +72,36 @@ export default function HomeScreen({ }; }, []); + useEffect(() => { + if ( + !needsDownloadSync || + !isOnline || + isSyncing || + postLoginSyncActive || + isNewUserLoading || + autoRepairSyncAttempted.current + ) { + return; + } + + autoRepairSyncAttempted.current = true; + void triggerSync(); + }, [ + needsDownloadSync, + isOnline, + isSyncing, + postLoginSyncActive, + isNewUserLoading, + triggerSync, + ]); + const handleSettingsPress = () => { setSettingsAnchor({ top: 56, left: 16 }); setSettingsVisible(true); }; const handleUserSwitched = useCallback(() => { + autoRepairSyncAttempted.current = false; setRefreshKey(key => key + 1); }, []); @@ -83,8 +118,8 @@ export default function HomeScreen({ if (showLoading) { return ( - - + + Syncing data... @@ -93,7 +128,7 @@ export default function HomeScreen({ } return ( - + } rightIcon={ diff --git a/src/app/screens/PrepareForOfflineScreen.test.tsx b/src/app/screens/PrepareForOfflineScreen.test.tsx new file mode 100644 index 0000000..63ca045 --- /dev/null +++ b/src/app/screens/PrepareForOfflineScreen.test.tsx @@ -0,0 +1,163 @@ +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, + Check: MockIcon, + }; +}); + +jest.mock('../../components/layout/ScreenContainer', () => ({ + ScreenContainer: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); + +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(), + selectedCount: 0, + accordionExpanded: true, + setAccordionExpanded: jest.fn(), + expandedBookIds: new Set(), + toggleBookExpanded: 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]), + selectedCount: 1, + accordionExpanded: true, + setAccordionExpanded: jest.fn(), + expandedBookIds: new Set([1]), + toggleBookExpanded: 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..34836e6 100644 --- a/src/app/screens/PrepareForOfflineScreen.tsx +++ b/src/app/screens/PrepareForOfflineScreen.tsx @@ -1,32 +1,121 @@ -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 { StackScreenHeader } from '../../components/layout/StackScreenHeader'; +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