Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/app/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { View, StyleSheet } from 'react-native';
import { theme } from '../../theme';
import { PageHeader } from '../../components/layout/PageHeader';
import { TabBar, HomeTab } from '../../components/layout/TabBar';
import { ScreenContainer } from '../../components/layout/ScreenContainer';
Expand All @@ -25,7 +26,7 @@ export default function HomeScreen() {
<TabBar activeTab={activeTab} onTabChange={setActiveTab} />
<View style={styles.content}>
{activeTab === 'myWork' ? (
<MyWorkTab />
<MyWorkTab refreshKey={refreshKey} isSyncing={isSyncing} />
) : (
<ProjectsTab refreshKey={refreshKey} />
)}
Expand All @@ -37,5 +38,6 @@ export default function HomeScreen() {
const styles = StyleSheet.create({
content: {
flex: 1,
backgroundColor: theme.colors.background,
},
});
131 changes: 131 additions & 0 deletions src/app/tabs/MyWorkTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React from 'react';
import { render, screen } from '@testing-library/react-native';
import { MyWorkTab } from './MyWorkTab';
import { MyWorkChapter } from '../../types/db/types';

jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
navigate: jest.fn(),
}),
}));

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 {
BookOpen: MockIcon,
ListChecks: MockIcon,
Cloud: MockIcon,
CloudCheck: MockIcon,
CloudUpload: MockIcon,
Circle: MockIcon,
Mic: MockIcon,
UserCheck: MockIcon,
ChevronRight: MockIcon,
};
});

jest.mock('../../hooks/useMyWorkChapters', () => ({
useMyWorkChapters: jest.fn(),
}));

const { useMyWorkChapters } = jest.requireMock(
'../../hooks/useMyWorkChapters',
) as {
useMyWorkChapters: jest.Mock;
};

const sampleChapter: MyWorkChapter = {
id: 10,
displayLabel: 'Luke 4',
bookName: 'Luke',
chapterNumber: 4,
workflowStage: 'draft',
syncState: 'synced',
completedVerses: 3,
totalVerses: 5,
downloadedVerses: 5,
lastActivityLabel: 'Jun 1, 2024',
projectName: 'Gospel of Luke',
targetLanguageName: 'Baka',
};

const notStartedChapter: MyWorkChapter = {
id: 11,
displayLabel: 'Luke 16',
bookName: 'Luke',
chapterNumber: 16,
workflowStage: 'not_started',
syncState: 'none',
completedVerses: 0,
totalVerses: 5,
downloadedVerses: 0,
projectName: 'Gospel of Luke',
targetLanguageName: 'Baka',
};

describe('MyWorkTab', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders empty state when there are no chapters', async () => {
useMyWorkChapters.mockReturnValue({
chapters: [],
loading: false,
refreshing: false,
refresh: jest.fn(),
});

render(<MyWorkTab />);

expect(
await screen.findByText(
"You don't have any chapters to work on right now. Check the Projects tab to find available work.",
),
).toBeTruthy();
});

it('renders chapter title, badge, and activity date', async () => {
useMyWorkChapters.mockReturnValue({
chapters: [sampleChapter],
loading: false,
refreshing: false,
refresh: jest.fn(),
});

render(<MyWorkTab />);

expect(await screen.findByText('Luke 4')).toBeTruthy();
expect(await screen.findByText('Draft')).toBeTruthy();
expect(await screen.findByText('Jun 1, 2024')).toBeTruthy();
});

it('renders not started badge when source is not downloaded', async () => {
useMyWorkChapters.mockReturnValue({
chapters: [notStartedChapter],
loading: false,
refreshing: false,
refresh: jest.fn(),
});

render(<MyWorkTab />);

expect(await screen.findByText('Luke 16')).toBeTruthy();
expect(await screen.findByText('Not Started')).toBeTruthy();
expect(screen.queryByText('Source not downloaded')).toBeNull();
});
});
62 changes: 59 additions & 3 deletions src/app/tabs/MyWorkTab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,63 @@
import React from 'react';
import { EmptyState } from '../../components/ui/EmptyState';
import { FlatList, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { MY_WORK_EMPTY_MESSAGE } from '../../constants/messages';
import { useMyWorkChapters } from '../../hooks/useMyWorkChapters';
import { RootStackParamList } from '../../types/navigation/types';
import { EmptyState } from '../../components/ui/EmptyState';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
import { MyWorkRow } from '../../components/ui/MyWorkRow';
import { theme } from '../../theme';

type Nav = StackNavigationProp<RootStackParamList, 'Home'>;

export function MyWorkTab() {
return <EmptyState message={MY_WORK_EMPTY_MESSAGE} />;
interface MyWorkTabProps {
refreshKey?: number;
isSyncing?: boolean;
}

export function MyWorkTab({
refreshKey = 0,
isSyncing = false,
}: MyWorkTabProps) {
const navigation = useNavigation<Nav>();
const { chapters, loading, refreshing, refresh } =
useMyWorkChapters(refreshKey);

if (loading) {
return <LoadingSpinner />;
}

if (chapters.length === 0) {
return <EmptyState message={MY_WORK_EMPTY_MESSAGE} />;
}

return (
<FlatList
data={chapters}
keyExtractor={item => String(item.id)}
contentContainerStyle={styles.listContent}
refreshing={refreshing}
onRefresh={refresh}
renderItem={({ item }) => (
<MyWorkRow
chapter={item}
isSyncing={isSyncing}
onPress={() =>
navigation.navigate('VerseDetail', {
chapterId: item.id,
chapterName: item.displayLabel,
projectName: item.projectName,
language: item.targetLanguageName,
})
}
/>
)}
/>
);
}

const styles = StyleSheet.create({
listContent: theme.homeListContent,
});
5 changes: 1 addition & 4 deletions src/app/tabs/ProjectsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,5 @@ export function ProjectsTab({ refreshKey = 0 }: ProjectsTabProps) {
}

const styles = StyleSheet.create({
listContent: {
padding: theme.spacing.lg,
gap: theme.spacing.md,
},
listContent: theme.homeListContent,
});
25 changes: 18 additions & 7 deletions src/components/layout/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
iconSizes,
logoSize,
headerLayout,
lucideStrokeWidth,
listIconStrokeWidth,
touchHitSlop,
} from '../../theme';

Expand All @@ -33,22 +33,23 @@ export function PageHeader({
<View style={styles.container}>
<TouchableOpacity
onPress={onSettingsPress}
style={styles.sideSlot}
style={styles.settingsSlot}
accessibilityLabel="Settings"
hitSlop={touchHitSlop}
>
<Settings
size={iconSizes.header}
color={iconColor}
strokeWidth={lucideStrokeWidth}
strokeWidth={listIconStrokeWidth}
/>
</TouchableOpacity>

<TouchableOpacity
onPress={onSyncPress}
disabled={isSyncing}
style={styles.sideSlot}
accessibilityLabel="Sync"
style={styles.syncSlot}
accessibilityLabel={isSyncing ? 'Syncing…. Open Sync page.' : 'Sync'}
accessibilityState={{ disabled: isSyncing }}
hitSlop={touchHitSlop}
>
{isSyncing ? (
Expand All @@ -57,7 +58,7 @@ export function PageHeader({
<CloudUpload
size={iconSizes.header}
color={iconColor}
strokeWidth={lucideStrokeWidth}
strokeWidth={listIconStrokeWidth}
/>
)}
</TouchableOpacity>
Expand All @@ -84,11 +85,21 @@ const styles = StyleSheet.create({
paddingVertical: headerLayout.paddingVertical,
minHeight: headerLayout.minHeight,
},
sideSlot: {
settingsSlot: {
width: headerLayout.sideSlot,
height: headerLayout.sideSlot,
alignItems: 'center',
justifyContent: 'center',
marginLeft: -theme.spacing.sm,
zIndex: 1,
},
syncSlot: {
width: headerLayout.sideSlot,
height: headerLayout.sideSlot,
alignItems: 'center',
justifyContent: 'center',
borderRadius: theme.radius.full,
padding: 6,
zIndex: 1,
},
logoOverlay: {
Expand Down
8 changes: 4 additions & 4 deletions src/components/layout/TabBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { BookOpen, ListChecks, LucideIcon } from 'lucide-react-native';
import { theme, iconSizes, lucideStrokeWidth } from '../../theme';
import { theme, iconSizes, listIconStrokeWidth } from '../../theme';

export type HomeTab = 'myWork' | 'projects';

Expand All @@ -22,7 +22,7 @@ export function TabBar({ activeTab, onTabChange }: TabBarProps) {
const isActive = activeTab === id;
const color = isActive
? theme.colors.primary
: theme.colors.tabInactive;
: theme.colors.mutedForeground;

return (
<TouchableOpacity
Expand All @@ -35,7 +35,7 @@ export function TabBar({ activeTab, onTabChange }: TabBarProps) {
<Icon
size={iconSizes.headerTab}
color={color}
strokeWidth={lucideStrokeWidth}
strokeWidth={listIconStrokeWidth}
/>
<Text style={[styles.label, { color }]}>{label}</Text>
</TouchableOpacity>
Expand All @@ -48,7 +48,7 @@ export function TabBar({ activeTab, onTabChange }: TabBarProps) {
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: theme.colors.background,
backgroundColor: theme.colors.tabBarBackground,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
Expand Down
Loading
Loading