From 869f21ee2efaefed6a5aa4fbd417c25df8dec02a Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
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 <noreply@anthropic.com>

* Task completion checkpoint

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
---
 apps/mobile/app/(tabs)/index.tsx | 365 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 365 insertions(+)
 create mode 100644 apps/mobile/app/(tabs)/index.tsx

(limited to 'apps/mobile/app/(tabs)/index.tsx')

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',
+  },
+});
-- 
cgit v1.2.3

