summaryrefslogtreecommitdiff
path: root/apps/mobile/app/(tabs)
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-18 17:44:50 +0000
committerGitHub <noreply@github.com>2026-01-18 17:44:50 +0000
commit869f21ee2efaefed6a5aa4fbd417c25df8dec02a (patch)
tree2a90820ac817173e5b7154e0ba5e4f5d095f9613 /apps/mobile/app/(tabs)
parent219bca168508e1ea5e91e8a9ce98338afeddfbd2 (diff)
downloadsoryu-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)')
-rw-r--r--apps/mobile/app/(tabs)/_layout.tsx58
-rw-r--r--apps/mobile/app/(tabs)/index.tsx365
-rw-r--r--apps/mobile/app/(tabs)/settings.tsx248
-rw-r--r--apps/mobile/app/(tabs)/tasks.tsx231
4 files changed, 902 insertions, 0 deletions
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 (
+ <Tabs
+ screenOptions={{
+ tabBarActiveTintColor: colors.tint,
+ tabBarInactiveTintColor: colors.tabIconDefault,
+ tabBarStyle: {
+ backgroundColor: colors.background,
+ borderTopColor: colors.border,
+ },
+ headerStyle: {
+ backgroundColor: colors.background,
+ },
+ headerTintColor: colors.text,
+ headerTitleStyle: {
+ fontWeight: '600',
+ },
+ }}
+ >
+ <Tabs.Screen
+ name="index"
+ options={{
+ title: 'Dashboard',
+ tabBarIcon: ({ color, size }) => (
+ <Ionicons name="home" size={size} color={color} />
+ ),
+ }}
+ />
+ <Tabs.Screen
+ name="tasks"
+ options={{
+ title: 'Tasks',
+ tabBarIcon: ({ color, size }) => (
+ <Ionicons name="list" size={size} color={color} />
+ ),
+ }}
+ />
+ <Tabs.Screen
+ name="settings"
+ options={{
+ title: 'Settings',
+ tabBarIcon: ({ color, size }) => (
+ <Ionicons name="settings-outline" size={size} color={color} />
+ ),
+ }}
+ />
+ </Tabs>
+ );
+}
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',
+ },
+});
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 = (
+ <View style={[styles.row, { borderBottomColor: colors.border }]}>
+ <View style={[styles.iconContainer, { backgroundColor: colors.tint + '20' }]}>
+ <Ionicons name={icon} size={20} color={colors.tint} />
+ </View>
+ <Text style={[styles.rowTitle, { color: colors.text }]}>{title}</Text>
+ {value && (
+ <Text style={[styles.rowValue, { color: colors.secondaryText }]}>
+ {value}
+ </Text>
+ )}
+ {showChevron && onPress && (
+ <Ionicons name="chevron-forward" size={20} color={colors.secondaryText} />
+ )}
+ </View>
+ );
+
+ if (onPress) {
+ return (
+ <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
+ {content}
+ </TouchableOpacity>
+ );
+ }
+
+ 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 (
+ <ScrollView
+ style={[styles.container, { backgroundColor: colors.background }]}
+ contentContainerStyle={styles.content}
+ >
+ {/* Account Section */}
+ <View style={styles.section}>
+ <Text style={[styles.sectionTitle, { color: colors.secondaryText }]}>
+ Account
+ </Text>
+ <View style={[styles.card, { backgroundColor: colors.card }]}>
+ <SettingsRow
+ icon="person-outline"
+ title="Profile"
+ onPress={() => {}}
+ />
+ <SettingsRow
+ icon="notifications-outline"
+ title="Notifications"
+ onPress={() => {}}
+ />
+ <SettingsRow
+ icon="key-outline"
+ title="API Key"
+ onPress={() => {}}
+ />
+ </View>
+ </View>
+
+ {/* App Section */}
+ <View style={styles.section}>
+ <Text style={[styles.sectionTitle, { color: colors.secondaryText }]}>
+ App
+ </Text>
+ <View style={[styles.card, { backgroundColor: colors.card }]}>
+ <SettingsRow
+ icon="color-palette-outline"
+ title="Appearance"
+ value={colorScheme === 'dark' ? 'Dark' : 'Light'}
+ showChevron={false}
+ />
+ <SettingsRow
+ icon="server-outline"
+ title="Environment"
+ value={environment === 'local' ? 'Local' : 'Production'}
+ showChevron={false}
+ />
+ </View>
+ </View>
+
+ {/* Support Section */}
+ <View style={styles.section}>
+ <Text style={[styles.sectionTitle, { color: colors.secondaryText }]}>
+ Support
+ </Text>
+ <View style={[styles.card, { backgroundColor: colors.card }]}>
+ <SettingsRow
+ icon="help-circle-outline"
+ title="Help & Support"
+ onPress={() => handleOpenUrl(config.supportUrl)}
+ />
+ <SettingsRow
+ icon="document-text-outline"
+ title="Terms of Service"
+ onPress={() => handleOpenUrl(config.termsOfServiceUrl)}
+ />
+ <SettingsRow
+ icon="shield-checkmark-outline"
+ title="Privacy Policy"
+ onPress={() => handleOpenUrl(config.privacyPolicyUrl)}
+ />
+ </View>
+ </View>
+
+ {/* Sign Out */}
+ <View style={styles.section}>
+ <TouchableOpacity
+ style={[styles.signOutButton, { backgroundColor: colors.card }]}
+ onPress={handleSignOut}
+ activeOpacity={0.7}
+ disabled={isLoading}
+ >
+ <Ionicons name="log-out-outline" size={20} color="#ef4444" />
+ <Text style={styles.signOutText}>Sign Out</Text>
+ </TouchableOpacity>
+ </View>
+
+ {/* Version */}
+ <Text style={[styles.version, { color: colors.secondaryText }]}>
+ Makima Mobile v1.0.0
+ </Text>
+ </ScrollView>
+ );
+}
+
+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 (
+ <View style={[styles.container, { backgroundColor: colors.background }]}>
+ <TaskListSkeleton />
+ </View>
+ );
+ }
+
+ // Render error state
+ if (isError) {
+ return (
+ <View style={[styles.container, { backgroundColor: colors.background }]}>
+ <EmptyState
+ icon="alert-circle-outline"
+ title="Failed to load tasks"
+ message="Pull down to retry"
+ />
+ </View>
+ );
+ }
+
+ // Render empty state
+ if (!tasks || tasks.length === 0) {
+ return (
+ <View style={[styles.container, { backgroundColor: colors.background }]}>
+ <EmptyState
+ icon="cube-outline"
+ title="No tasks yet"
+ message="Tasks created from contracts will appear here"
+ />
+ </View>
+ );
+ }
+
+ return (
+ <View style={[styles.container, { backgroundColor: colors.background }]}>
+ {/* Search bar */}
+ <View
+ style={[
+ styles.searchContainer,
+ {
+ backgroundColor: colors.secondaryBackground,
+ borderColor: colors.border,
+ },
+ ]}
+ >
+ <Ionicons name="search" size={18} color={colors.secondaryText} />
+ <TextInput
+ style={[styles.searchInput, { color: colors.text }]}
+ placeholder="Search tasks..."
+ placeholderTextColor={colors.secondaryText}
+ value={searchQuery}
+ onChangeText={setSearchQuery}
+ autoCapitalize="none"
+ autoCorrect={false}
+ />
+ {searchQuery.length > 0 && (
+ <TouchableOpacity onPress={clearSearch}>
+ <Ionicons name="close-circle" size={18} color={colors.secondaryText} />
+ </TouchableOpacity>
+ )}
+ </View>
+
+ {/* Task list */}
+ <SectionList
+ sections={sections}
+ keyExtractor={(item) => item.id}
+ renderItem={({ item }) => (
+ <TaskListItem task={item} onPress={handleTaskPress} />
+ )}
+ renderSectionHeader={({ section }) => (
+ <View
+ style={[
+ styles.sectionHeader,
+ { backgroundColor: colors.secondaryBackground },
+ ]}
+ >
+ <Text style={[styles.sectionTitle, { color: colors.text }]}>
+ {section.title}
+ </Text>
+ <Text style={[styles.sectionCount, { color: colors.secondaryText }]}>
+ {section.data.length}
+ </Text>
+ </View>
+ )}
+ refreshControl={
+ <RefreshControl
+ refreshing={isRefetching}
+ onRefresh={handleRefresh}
+ tintColor={colors.tint}
+ />
+ }
+ ListEmptyComponent={
+ searchQuery ? (
+ <EmptyState
+ icon="search-outline"
+ title="No matching tasks"
+ message={`No tasks match "${searchQuery}"`}
+ />
+ ) : null
+ }
+ stickySectionHeadersEnabled
+ contentContainerStyle={sections.length === 0 ? styles.emptyContent : undefined}
+ />
+ </View>
+ );
+}
+
+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,
+ },
+});