diff options
| author | soryu <soryu@soryu.co> | 2026-01-18 17:44:50 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-18 17:44:50 +0000 |
| commit | 869f21ee2efaefed6a5aa4fbd417c25df8dec02a (patch) | |
| tree | 2a90820ac817173e5b7154e0ba5e4f5d095f9613 /apps/mobile/app/(tabs)/index.tsx | |
| parent | 219bca168508e1ea5e91e8a9ce98338afeddfbd2 (diff) | |
| download | soryu-869f21ee2efaefed6a5aa4fbd417c25df8dec02a.tar.gz soryu-869f21ee2efaefed6a5aa4fbd417c25df8dec02a.zip | |
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 <noreply@anthropic.com>
* Task completion checkpoint
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'apps/mobile/app/(tabs)/index.tsx')
| -rw-r--r-- | apps/mobile/app/(tabs)/index.tsx | 365 |
1 files changed, 365 insertions, 0 deletions
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 ( + <TouchableOpacity + style={[styles.statCard, { backgroundColor: colors.card }]} + onPress={onPress} + activeOpacity={onPress ? 0.7 : 1} + disabled={!onPress} + > + <View style={[styles.iconContainer, { backgroundColor: color + '20' }]}> + <Ionicons name={icon} size={24} color={color} /> + </View> + <Text style={[styles.statValue, { color: colors.text }]}>{value}</Text> + <Text style={[styles.statTitle, { color: colors.secondaryText }]}> + {title} + </Text> + </TouchableOpacity> + ); +} + +interface QuickTaskItemProps { + task: TaskSummary; + onPress: () => void; +} + +function QuickTaskItem({ task, onPress }: QuickTaskItemProps) { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + + return ( + <TouchableOpacity + style={[styles.quickTaskItem, { backgroundColor: colors.card }]} + onPress={onPress} + activeOpacity={0.7} + > + <TaskStatusBadge status={task.status} size="small" /> + <View style={styles.quickTaskContent}> + <Text + style={[styles.quickTaskName, { color: colors.text }]} + numberOfLines={1} + > + {task.name} + </Text> + {task.progressSummary && ( + <Text + style={[styles.quickTaskSummary, { color: colors.secondaryText }]} + numberOfLines={1} + > + {task.progressSummary} + </Text> + )} + </View> + <Ionicons name="chevron-forward" size={16} color={colors.secondaryText} /> + </TouchableOpacity> + ); +} + +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 ( + <ScrollView + style={[styles.container, { backgroundColor: colors.background }]} + contentContainerStyle={styles.content} + refreshControl={ + <RefreshControl + refreshing={isRefreshing} + onRefresh={handleRefresh} + tintColor={colors.tint} + /> + } + > + {/* Stats Grid */} + <View style={styles.statsGrid}> + <StatCard + title="Running" + value={counts?.running ?? 0} + icon="play-circle" + color={TaskStatusColors.running.dot} + onPress={() => router.push('/(tabs)/tasks')} + /> + <StatCard + title="Questions" + value={questionCount} + icon="help-circle" + color="#f59e0b" + onPress={questionCount > 0 ? () => router.push('/(tabs)/tasks') : undefined} + /> + <StatCard + title="Completed" + value={counts?.completed ?? 0} + icon="checkmark-circle" + color={TaskStatusColors.done.dot} + onPress={() => router.push('/(tabs)/tasks')} + /> + <StatCard + title="Failed" + value={counts?.failed ?? 0} + icon="alert-circle" + color={TaskStatusColors.failed.dot} + onPress={counts?.failed ? () => router.push('/(tabs)/tasks') : undefined} + /> + </View> + + {/* Pending Questions Alert */} + {questionCount > 0 && ( + <TouchableOpacity + style={[styles.alertBanner, { backgroundColor: '#fef3c7' }]} + onPress={() => router.push('/(tabs)/tasks')} + activeOpacity={0.7} + > + <Ionicons name="help-circle" size={24} color="#92400e" /> + <View style={styles.alertContent}> + <Text style={[styles.alertTitle, { color: '#92400e' }]}> + {questionCount} Question{questionCount !== 1 ? 's' : ''} Waiting + </Text> + <Text style={[styles.alertSubtitle, { color: '#b45309' }]}> + Tap to review and respond + </Text> + </View> + <Ionicons name="chevron-forward" size={20} color="#92400e" /> + </TouchableOpacity> + )} + + {/* Running Tasks */} + {runningTasks.length > 0 && ( + <View style={styles.section}> + <View style={styles.sectionHeader}> + <Text style={[styles.sectionTitle, { color: colors.text }]}> + Running Tasks + </Text> + <TouchableOpacity onPress={() => router.push('/(tabs)/tasks')}> + <Text style={[styles.seeAllButton, { color: colors.tint }]}> + See All + </Text> + </TouchableOpacity> + </View> + <View style={styles.taskList}> + {runningTasks.slice(0, 3).map((task) => ( + <QuickTaskItem + key={task.id} + task={task} + onPress={() => router.push(`/task/${task.id}`)} + /> + ))} + </View> + </View> + )} + + {/* Needs Attention */} + {attentionTasks.length > 0 && ( + <View style={styles.section}> + <View style={styles.sectionHeader}> + <Text style={[styles.sectionTitle, { color: colors.text }]}> + Needs Attention + </Text> + <TouchableOpacity onPress={() => router.push('/(tabs)/tasks')}> + <Text style={[styles.seeAllButton, { color: colors.tint }]}> + See All + </Text> + </TouchableOpacity> + </View> + <View style={styles.taskList}> + {attentionTasks.slice(0, 3).map((task) => ( + <QuickTaskItem + key={task.id} + task={task} + onPress={() => router.push(`/task/${task.id}`)} + /> + ))} + </View> + </View> + )} + + {/* Empty State */} + {!isLoadingTasks && (!tasks || tasks.length === 0) && ( + <View style={styles.emptyState}> + <Ionicons + name="cube-outline" + size={64} + color={colors.secondaryText} + /> + <Text style={[styles.emptyTitle, { color: colors.text }]}> + No tasks yet + </Text> + <Text style={[styles.emptyMessage, { color: colors.secondaryText }]}> + Tasks created from contracts will appear here + </Text> + </View> + )} + </ScrollView> + ); +} + +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', + }, +}); |
