From 869f21ee2efaefed6a5aa4fbd417c25df8dec02a Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 18 Jan 2026 17:44:50 +0000 Subject: Add React Native mobile app for Makima (#3) * [WIP] Heartbeat checkpoint - 2026-01-18 02:58:27 UTC * feat(mobile): complete mobile app integration and verification - Add ThemeColors type export to Colors.ts for type safety - Export SUPABASE_URL from supabase.ts and use environment variables - Update .env.example with correct default URLs - Add comprehensive README.md with setup instructions Verified: - TypeScript compiles without errors - App exports successfully for iOS and Android - All screens accessible (login, dashboard, tasks, settings, task detail) - Auth flow working with Zustand store and Supabase Co-Authored-By: Claude Opus 4.5 * Task completion checkpoint --------- Co-authored-by: Claude Opus 4.5 --- apps/mobile/app/(tabs)/_layout.tsx | 58 ++++++ apps/mobile/app/(tabs)/index.tsx | 365 ++++++++++++++++++++++++++++++++++++ apps/mobile/app/(tabs)/settings.tsx | 248 ++++++++++++++++++++++++ apps/mobile/app/(tabs)/tasks.tsx | 231 +++++++++++++++++++++++ 4 files changed, 902 insertions(+) create mode 100644 apps/mobile/app/(tabs)/_layout.tsx create mode 100644 apps/mobile/app/(tabs)/index.tsx create mode 100644 apps/mobile/app/(tabs)/settings.tsx create mode 100644 apps/mobile/app/(tabs)/tasks.tsx (limited to 'apps/mobile/app/(tabs)') diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..96c1a2d --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Tabs } from 'expo-router'; +import { useColorScheme } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/Colors'; + +export default function TabsLayout() { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx new file mode 100644 index 0000000..96b1a8f --- /dev/null +++ b/apps/mobile/app/(tabs)/index.tsx @@ -0,0 +1,365 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + useColorScheme, + RefreshControl, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors, TaskStatusColors } from '../../constants/Colors'; +import { useTasks, getTaskCounts } from '../../hooks/useTasks'; +import { usePendingQuestions } from '../../hooks/useQuestions'; +import { TaskStatusBadge } from '../../components/TaskStatusBadge'; +import type { TaskSummary } from '../../lib/api'; + +interface StatCardProps { + title: string; + value: number; + icon: keyof typeof Ionicons.glyphMap; + color: string; + onPress?: () => void; +} + +function StatCard({ title, value, icon, color, onPress }: StatCardProps) { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + + return ( + + + + + {value} + + {title} + + + ); +} + +interface QuickTaskItemProps { + task: TaskSummary; + onPress: () => void; +} + +function QuickTaskItem({ task, onPress }: QuickTaskItemProps) { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + + return ( + + + + + {task.name} + + {task.progressSummary && ( + + {task.progressSummary} + + )} + + + + ); +} + +export default function DashboardScreen() { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + const router = useRouter(); + + const { + data: tasks, + isLoading: isLoadingTasks, + refetch: refetchTasks, + isRefetching: isRefetchingTasks, + } = useTasks(); + + const { + data: questions, + isLoading: isLoadingQuestions, + refetch: refetchQuestions, + isRefetching: isRefetchingQuestions, + } = usePendingQuestions(); + + const isRefreshing = isRefetchingTasks || isRefetchingQuestions; + + const handleRefresh = () => { + refetchTasks(); + refetchQuestions(); + }; + + // Calculate counts + const counts = tasks ? getTaskCounts(tasks) : null; + const questionCount = questions?.length ?? 0; + + // Get running tasks for quick access + const runningTasks = tasks?.filter((t) => + ['running', 'initializing', 'starting'].includes(t.status) + ) ?? []; + + // Get tasks needing attention (blocked, paused, or with questions) + const attentionTasks = tasks?.filter((t) => + ['blocked', 'paused'].includes(t.status) + ) ?? []; + + return ( + + } + > + {/* Stats Grid */} + + router.push('/(tabs)/tasks')} + /> + 0 ? () => router.push('/(tabs)/tasks') : undefined} + /> + router.push('/(tabs)/tasks')} + /> + router.push('/(tabs)/tasks') : undefined} + /> + + + {/* Pending Questions Alert */} + {questionCount > 0 && ( + router.push('/(tabs)/tasks')} + activeOpacity={0.7} + > + + + + {questionCount} Question{questionCount !== 1 ? 's' : ''} Waiting + + + Tap to review and respond + + + + + )} + + {/* Running Tasks */} + {runningTasks.length > 0 && ( + + + + Running Tasks + + router.push('/(tabs)/tasks')}> + + See All + + + + + {runningTasks.slice(0, 3).map((task) => ( + router.push(`/task/${task.id}`)} + /> + ))} + + + )} + + {/* Needs Attention */} + {attentionTasks.length > 0 && ( + + + + Needs Attention + + router.push('/(tabs)/tasks')}> + + See All + + + + + {attentionTasks.slice(0, 3).map((task) => ( + router.push(`/task/${task.id}`)} + /> + ))} + + + )} + + {/* Empty State */} + {!isLoadingTasks && (!tasks || tasks.length === 0) && ( + + + + No tasks yet + + + Tasks created from contracts will appear here + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + padding: 16, + gap: 20, + }, + statsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + statCard: { + flex: 1, + minWidth: '45%', + padding: 16, + borderRadius: 12, + alignItems: 'center', + gap: 8, + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + }, + statValue: { + fontSize: 28, + fontWeight: '700', + }, + statTitle: { + fontSize: 14, + fontWeight: '500', + }, + alertBanner: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + gap: 12, + }, + alertContent: { + flex: 1, + }, + alertTitle: { + fontSize: 16, + fontWeight: '600', + }, + alertSubtitle: { + fontSize: 13, + marginTop: 2, + }, + section: { + gap: 12, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + }, + seeAllButton: { + fontSize: 14, + fontWeight: '500', + }, + taskList: { + gap: 8, + }, + quickTaskItem: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 10, + gap: 12, + }, + quickTaskContent: { + flex: 1, + gap: 2, + }, + quickTaskName: { + fontSize: 15, + fontWeight: '500', + }, + quickTaskSummary: { + fontSize: 13, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 48, + gap: 12, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '600', + }, + emptyMessage: { + fontSize: 14, + textAlign: 'center', + }, +}); diff --git a/apps/mobile/app/(tabs)/settings.tsx b/apps/mobile/app/(tabs)/settings.tsx new file mode 100644 index 0000000..f90e86c --- /dev/null +++ b/apps/mobile/app/(tabs)/settings.tsx @@ -0,0 +1,248 @@ +import React, { useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + useColorScheme, + Alert, + Linking, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/Colors'; +import { getEnvironment } from '../../lib/api'; +import { useAuthStore } from '../../stores/authStore'; +import { config } from '../../lib/config'; + +interface SettingsRowProps { + icon: keyof typeof Ionicons.glyphMap; + title: string; + value?: string; + onPress?: () => void; + showChevron?: boolean; +} + +function SettingsRow({ + icon, + title, + value, + onPress, + showChevron = true, +}: SettingsRowProps) { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + + const content = ( + + + + + {title} + {value && ( + + {value} + + )} + {showChevron && onPress && ( + + )} + + ); + + if (onPress) { + return ( + + {content} + + ); + } + + return content; +} + +export default function SettingsScreen() { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + const signOut = useAuthStore((state) => state.signOut); + const isLoading = useAuthStore((state) => state.isLoading); + + const environment = getEnvironment(); + + const handleSignOut = useCallback(() => { + Alert.alert( + 'Sign Out', + 'Are you sure you want to sign out?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Sign Out', + style: 'destructive', + onPress: () => signOut(), + }, + ] + ); + }, [signOut]); + + const handleOpenUrl = useCallback((url: string) => { + Linking.openURL(url); + }, []); + + return ( + + {/* Account Section */} + + + Account + + + {}} + /> + {}} + /> + {}} + /> + + + + {/* App Section */} + + + App + + + + + + + + {/* Support Section */} + + + Support + + + handleOpenUrl(config.supportUrl)} + /> + handleOpenUrl(config.termsOfServiceUrl)} + /> + handleOpenUrl(config.privacyPolicyUrl)} + /> + + + + {/* Sign Out */} + + + + Sign Out + + + + {/* Version */} + + Makima Mobile v1.0.0 + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + padding: 16, + gap: 24, + }, + section: { + gap: 8, + }, + sectionTitle: { + fontSize: 13, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginLeft: 16, + }, + card: { + borderRadius: 12, + overflow: 'hidden', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + gap: 12, + }, + iconContainer: { + width: 32, + height: 32, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + rowTitle: { + flex: 1, + fontSize: 16, + }, + rowValue: { + fontSize: 14, + }, + signOutButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + borderRadius: 12, + gap: 8, + }, + signOutText: { + fontSize: 16, + fontWeight: '600', + color: '#ef4444', + }, + version: { + textAlign: 'center', + fontSize: 12, + marginTop: 8, + }, +}); diff --git a/apps/mobile/app/(tabs)/tasks.tsx b/apps/mobile/app/(tabs)/tasks.tsx new file mode 100644 index 0000000..5aac710 --- /dev/null +++ b/apps/mobile/app/(tabs)/tasks.tsx @@ -0,0 +1,231 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + SectionList, + RefreshControl, + useColorScheme, + TextInput, + TouchableOpacity, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../../constants/Colors'; +import { useTasks, groupTasksByStatus } from '../../hooks/useTasks'; +import { TaskListItem } from '../../components/TaskListItem'; +import { TaskListSkeleton } from '../../components/TaskListSkeleton'; +import { EmptyState } from '../../components/EmptyState'; +import type { TaskSummary } from '../../lib/api'; + +interface Section { + title: string; + data: TaskSummary[]; +} + +export default function TasksScreen() { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(''); + + const { data: tasks, isLoading, isError, refetch, isRefetching } = useTasks(); + + // Group and filter tasks + const sections = useMemo(() => { + if (!tasks) return []; + + // Filter by search query + const filteredTasks = searchQuery + ? tasks.filter( + (task) => + task.name.toLowerCase().includes(searchQuery.toLowerCase()) || + task.contractName?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : tasks; + + // Group by status + const groups = groupTasksByStatus(filteredTasks); + + const result: Section[] = []; + + if (groups.running.length > 0) { + result.push({ title: 'Running', data: groups.running }); + } + if (groups.blocked.length > 0) { + result.push({ title: 'Needs Attention', data: groups.blocked }); + } + if (groups.pending.length > 0) { + result.push({ title: 'Pending', data: groups.pending }); + } + if (groups.completed.length > 0) { + result.push({ title: 'Completed', data: groups.completed }); + } + + return result; + }, [tasks, searchQuery]); + + const handleTaskPress = useCallback( + (task: TaskSummary) => { + router.push(`/task/${task.id}`); + }, + [router] + ); + + const handleRefresh = useCallback(() => { + refetch(); + }, [refetch]); + + const clearSearch = useCallback(() => { + setSearchQuery(''); + }, []); + + // Render loading state + if (isLoading) { + return ( + + + + ); + } + + // Render error state + if (isError) { + return ( + + + + ); + } + + // Render empty state + if (!tasks || tasks.length === 0) { + return ( + + + + ); + } + + return ( + + {/* Search bar */} + + + + {searchQuery.length > 0 && ( + + + + )} + + + {/* Task list */} + item.id} + renderItem={({ item }) => ( + + )} + renderSectionHeader={({ section }) => ( + + + {section.title} + + + {section.data.length} + + + )} + refreshControl={ + + } + ListEmptyComponent={ + searchQuery ? ( + + ) : null + } + stickySectionHeadersEnabled + contentContainerStyle={sections.length === 0 ? styles.emptyContent : undefined} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + margin: 16, + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + gap: 8, + }, + searchInput: { + flex: 1, + fontSize: 16, + padding: 0, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 8, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + sectionCount: { + fontSize: 14, + fontWeight: '500', + }, + emptyContent: { + flex: 1, + }, +}); -- cgit v1.2.3