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 | |
| 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')
| -rw-r--r-- | apps/mobile/app/(auth)/_layout.tsx | 31 | ||||
| -rw-r--r-- | apps/mobile/app/(auth)/login.tsx | 384 | ||||
| -rw-r--r-- | apps/mobile/app/(tabs)/_layout.tsx | 58 | ||||
| -rw-r--r-- | apps/mobile/app/(tabs)/index.tsx | 365 | ||||
| -rw-r--r-- | apps/mobile/app/(tabs)/settings.tsx | 248 | ||||
| -rw-r--r-- | apps/mobile/app/(tabs)/tasks.tsx | 231 | ||||
| -rw-r--r-- | apps/mobile/app/_layout.tsx | 111 | ||||
| -rw-r--r-- | apps/mobile/app/task/[id].tsx | 259 | ||||
| -rw-r--r-- | apps/mobile/app/task/_layout.tsx | 5 |
9 files changed, 1692 insertions, 0 deletions
diff --git a/apps/mobile/app/(auth)/_layout.tsx b/apps/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..1def062 --- /dev/null +++ b/apps/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,31 @@ +import { Stack } from 'expo-router'; +import { useColorScheme } from 'react-native'; +import { Colors } from '../../constants/Colors'; + +/** + * Auth group layout + * Provides stack navigation for authentication screens + */ +export default function AuthLayout() { + const colorScheme = useColorScheme() ?? 'dark'; + const colors = Colors[colorScheme]; + + return ( + <Stack + screenOptions={{ + headerShown: false, + contentStyle: { + backgroundColor: colors.background, + }, + animation: 'fade', + }} + > + <Stack.Screen + name="login" + options={{ + title: 'Sign In', + }} + /> + </Stack> + ); +} diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx new file mode 100644 index 0000000..7e0f14f --- /dev/null +++ b/apps/mobile/app/(auth)/login.tsx @@ -0,0 +1,384 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, + ScrollView, + ActivityIndicator, + Linking, + type TextInput as TextInputType, +} from 'react-native'; +import { useColorScheme } from 'react-native'; +import { router } from 'expo-router'; +import { Colors } from '../../constants/Colors'; +import { useAuthStore } from '../../stores/authStore'; +import { config } from '../../lib/config'; + +/** + * Email validation regex + */ +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Login screen component + * Handles user authentication with email and password + */ +export default function LoginScreen() { + const colorScheme = useColorScheme() ?? 'dark'; + const colors = Colors[colorScheme]; + + // Form state + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [emailError, setEmailError] = useState<string | null>(null); + const [passwordError, setPasswordError] = useState<string | null>(null); + + // Refs for input focus + const passwordInputRef = useRef<TextInputType>(null); + + // Auth store + const signIn = useAuthStore((state) => state.signIn); + const isLoading = useAuthStore((state) => state.isLoading); + const authError = useAuthStore((state) => state.error); + const clearError = useAuthStore((state) => state.clearError); + + /** + * Validate email format + */ + const validateEmail = useCallback((value: string): boolean => { + if (!value.trim()) { + setEmailError('Email is required'); + return false; + } + if (!EMAIL_REGEX.test(value.trim())) { + setEmailError('Please enter a valid email address'); + return false; + } + setEmailError(null); + return true; + }, []); + + /** + * Validate password + */ + const validatePassword = useCallback((value: string): boolean => { + if (!value) { + setPasswordError('Password is required'); + return false; + } + if (value.length < 6) { + setPasswordError('Password must be at least 6 characters'); + return false; + } + setPasswordError(null); + return true; + }, []); + + /** + * Handle email change + */ + const handleEmailChange = useCallback( + (value: string) => { + setEmail(value); + if (emailError) { + setEmailError(null); + } + if (authError) { + clearError(); + } + }, + [emailError, authError, clearError] + ); + + /** + * Handle password change + */ + const handlePasswordChange = useCallback( + (value: string) => { + setPassword(value); + if (passwordError) { + setPasswordError(null); + } + if (authError) { + clearError(); + } + }, + [passwordError, authError, clearError] + ); + + /** + * Handle login button press + */ + const handleLogin = useCallback(async () => { + // Clear previous errors + clearError(); + + // Validate inputs + const isEmailValid = validateEmail(email); + const isPasswordValid = validatePassword(password); + + if (!isEmailValid || !isPasswordValid) { + return; + } + + // Attempt sign in + const success = await signIn(email, password); + + if (success) { + // Navigate to main app + router.replace('/(tabs)'); + } + }, [email, password, signIn, validateEmail, validatePassword, clearError]); + + /** + * Handle forgot password link press + */ + const handleForgotPassword = useCallback(() => { + Linking.openURL(config.forgotPasswordUrl); + }, []); + + /** + * Handle email submit (move focus to password) + */ + const handleEmailSubmit = useCallback(() => { + passwordInputRef.current?.focus(); + }, []); + + /** + * Get combined error message + */ + const displayError = authError || emailError || passwordError; + + return ( + <KeyboardAvoidingView + style={[styles.container, { backgroundColor: colors.background }]} + behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + > + <ScrollView + contentContainerStyle={styles.scrollContent} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} + > + <View style={styles.headerContainer}> + <Text style={[styles.title, { color: colors.text }]}>Welcome to Makima</Text> + <Text style={[styles.subtitle, { color: colors.textSecondary }]}> + Sign in to continue + </Text> + </View> + + <View style={styles.formContainer}> + {/* Email Input */} + <View style={styles.inputContainer}> + <Text style={[styles.label, { color: colors.text }]}>Email</Text> + <TextInput + style={[ + styles.input, + { + backgroundColor: colors.input, + borderColor: emailError ? colors.error : colors.inputBorder, + color: colors.text, + }, + ]} + placeholder="Enter your email" + placeholderTextColor={colors.inputPlaceholder} + value={email} + onChangeText={handleEmailChange} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + autoComplete="email" + returnKeyType="next" + onSubmitEditing={handleEmailSubmit} + editable={!isLoading} + testID="email-input" + /> + {emailError && ( + <Text style={[styles.fieldError, { color: colors.error }]}>{emailError}</Text> + )} + </View> + + {/* Password Input */} + <View style={styles.inputContainer}> + <Text style={[styles.label, { color: colors.text }]}>Password</Text> + <TextInput + ref={passwordInputRef} + style={[ + styles.input, + { + backgroundColor: colors.input, + borderColor: passwordError ? colors.error : colors.inputBorder, + color: colors.text, + }, + ]} + placeholder="Enter your password" + placeholderTextColor={colors.inputPlaceholder} + value={password} + onChangeText={handlePasswordChange} + secureTextEntry + autoCapitalize="none" + autoCorrect={false} + autoComplete="password" + returnKeyType="done" + onSubmitEditing={handleLogin} + editable={!isLoading} + testID="password-input" + /> + {passwordError && ( + <Text style={[styles.fieldError, { color: colors.error }]}>{passwordError}</Text> + )} + </View> + + {/* Auth Error */} + {authError && ( + <View style={[styles.errorContainer, { backgroundColor: colors.error + '20' }]}> + <Text style={[styles.errorText, { color: colors.error }]}>{authError}</Text> + </View> + )} + + {/* Login Button */} + <TouchableOpacity + style={[ + styles.button, + { + backgroundColor: isLoading ? colors.buttonDisabled : colors.buttonPrimary, + }, + ]} + onPress={handleLogin} + disabled={isLoading} + activeOpacity={0.8} + testID="login-button" + > + {isLoading ? ( + <ActivityIndicator color={colors.buttonPrimaryText} size="small" /> + ) : ( + <Text style={[styles.buttonText, { color: colors.buttonPrimaryText }]}> + Sign In + </Text> + )} + </TouchableOpacity> + + {/* Forgot Password Link */} + <TouchableOpacity + style={styles.forgotPasswordContainer} + onPress={handleForgotPassword} + disabled={isLoading} + testID="forgot-password-link" + > + <Text style={[styles.forgotPasswordText, { color: colors.link }]}> + Forgot Password? + </Text> + </TouchableOpacity> + </View> + + {/* Footer */} + <View style={styles.footerContainer}> + <Text style={[styles.footerText, { color: colors.textSecondary }]}> + By signing in, you agree to our{' '} + <Text + style={[styles.linkText, { color: colors.link }]} + onPress={() => Linking.openURL(config.termsOfServiceUrl)} + > + Terms of Service + </Text> + {' and '} + <Text + style={[styles.linkText, { color: colors.link }]} + onPress={() => Linking.openURL(config.privacyPolicyUrl)} + > + Privacy Policy + </Text> + </Text> + </View> + </ScrollView> + </KeyboardAvoidingView> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + paddingHorizontal: 24, + paddingVertical: 40, + }, + headerContainer: { + alignItems: 'center', + marginBottom: 40, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + }, + formContainer: { + width: '100%', + }, + inputContainer: { + marginBottom: 20, + }, + label: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + }, + input: { + height: 50, + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 16, + fontSize: 16, + }, + fieldError: { + fontSize: 12, + marginTop: 4, + }, + errorContainer: { + padding: 12, + borderRadius: 8, + marginBottom: 20, + }, + errorText: { + fontSize: 14, + textAlign: 'center', + }, + button: { + height: 50, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + marginTop: 8, + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + }, + forgotPasswordContainer: { + alignItems: 'center', + marginTop: 20, + }, + forgotPasswordText: { + fontSize: 14, + }, + footerContainer: { + marginTop: 40, + alignItems: 'center', + }, + footerText: { + fontSize: 12, + textAlign: 'center', + lineHeight: 18, + }, + linkText: { + textDecorationLine: 'underline', + }, +}); 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, + }, +}); diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx new file mode 100644 index 0000000..2c030b6 --- /dev/null +++ b/apps/mobile/app/_layout.tsx @@ -0,0 +1,111 @@ +import React, { useEffect } from 'react'; +import { Stack, useRouter, useSegments } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useColorScheme } from 'react-native'; +import { Colors } from '../constants/Colors'; +import { useAuthStore, setupAuthListener } from '../stores/authStore'; +import { LoadingScreen } from '../components/LoadingScreen'; + +// Create a client +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5000, // Data is fresh for 5 seconds + retry: 2, // Retry failed requests twice + refetchOnWindowFocus: false, // Don't refetch on app focus (mobile) + }, + mutations: { + retry: 1, + }, + }, +}); + +/** + * Auth state handler component + * Redirects users based on authentication status + */ +function AuthStateHandler({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const segments = useSegments(); + const session = useAuthStore((state) => state.session); + const isInitialized = useAuthStore((state) => state.isInitialized); + const initialize = useAuthStore((state) => state.initialize); + + // Initialize auth state on mount + useEffect(() => { + initialize(); + }, [initialize]); + + // Setup auth listener on mount + useEffect(() => { + const unsubscribe = setupAuthListener(); + return unsubscribe; + }, []); + + // Handle auth-based routing + useEffect(() => { + if (!isInitialized) return; + + const inAuthGroup = segments[0] === '(auth)'; + const isAuthenticated = session !== null; + + if (!isAuthenticated && !inAuthGroup) { + // Redirect to login if not authenticated and not already on auth screens + router.replace('/(auth)/login'); + } else if (isAuthenticated && inAuthGroup) { + // Redirect to main app if authenticated and on auth screens + router.replace('/(tabs)'); + } + }, [session, segments, isInitialized, router]); + + // Show loading screen while initializing + if (!isInitialized) { + return <LoadingScreen message="Checking authentication..." />; + } + + return <>{children}</>; +} + +export default function RootLayout() { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + + return ( + <QueryClientProvider client={queryClient}> + <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} /> + <AuthStateHandler> + <Stack + screenOptions={{ + headerStyle: { + backgroundColor: colors.background, + }, + headerTintColor: colors.text, + headerTitleStyle: { + fontWeight: '600', + }, + contentStyle: { + backgroundColor: colors.background, + }, + }} + > + <Stack.Screen + name="(auth)" + options={{ headerShown: false }} + /> + <Stack.Screen + name="(tabs)" + options={{ headerShown: false }} + /> + <Stack.Screen + name="task/[id]" + options={{ + presentation: 'card', + headerBackTitle: 'Back', + }} + /> + </Stack> + </AuthStateHandler> + </QueryClientProvider> + ); +} diff --git a/apps/mobile/app/task/[id].tsx b/apps/mobile/app/task/[id].tsx new file mode 100644 index 0000000..121063a --- /dev/null +++ b/apps/mobile/app/task/[id].tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + useColorScheme, + ActivityIndicator, +} from 'react-native'; +import { useLocalSearchParams, Stack } from 'expo-router'; +import { Colors } from '../../constants/Colors'; +import { useTask } from '../../hooks/useTasks'; +import { TaskStatusBadge } from '../../components/TaskStatusBadge'; +import { EmptyState } from '../../components/EmptyState'; + +export default function TaskDetailScreen() { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + const { id } = useLocalSearchParams<{ id: string }>(); + + const { data: task, isLoading, isError } = useTask(id); + + if (isLoading) { + return ( + <> + <Stack.Screen options={{ title: 'Loading...' }} /> + <View style={[styles.container, styles.centered, { backgroundColor: colors.background }]}> + <ActivityIndicator size="large" color={colors.tint} /> + </View> + </> + ); + } + + if (isError || !task) { + return ( + <> + <Stack.Screen options={{ title: 'Error' }} /> + <View style={[styles.container, { backgroundColor: colors.background }]}> + <EmptyState + icon="alert-circle-outline" + title="Failed to load task" + message="The task could not be found or an error occurred" + /> + </View> + </> + ); + } + + return ( + <> + <Stack.Screen options={{ title: task.name }} /> + <ScrollView + style={[styles.container, { backgroundColor: colors.background }]} + contentContainerStyle={styles.content} + > + {/* Header */} + <View style={[styles.header, { backgroundColor: colors.card }]}> + <View style={styles.headerTop}> + <TaskStatusBadge status={task.status} showLabel size="large" /> + </View> + <Text style={[styles.taskName, { color: colors.text }]}> + {task.name} + </Text> + {task.description && ( + <Text style={[styles.description, { color: colors.secondaryText }]}> + {task.description} + </Text> + )} + </View> + + {/* Progress Summary */} + {task.progressSummary && ( + <View style={[styles.section, { backgroundColor: colors.card }]}> + <Text style={[styles.sectionTitle, { color: colors.text }]}> + Progress + </Text> + <Text style={[styles.progressText, { color: colors.secondaryText }]}> + {task.progressSummary} + </Text> + </View> + )} + + {/* Task Info */} + <View style={[styles.section, { backgroundColor: colors.card }]}> + <Text style={[styles.sectionTitle, { color: colors.text }]}> + Details + </Text> + + <View style={styles.infoRow}> + <Text style={[styles.infoLabel, { color: colors.secondaryText }]}> + Created + </Text> + <Text style={[styles.infoValue, { color: colors.text }]}> + {new Date(task.createdAt).toLocaleString()} + </Text> + </View> + + {task.startedAt && ( + <View style={styles.infoRow}> + <Text style={[styles.infoLabel, { color: colors.secondaryText }]}> + Started + </Text> + <Text style={[styles.infoValue, { color: colors.text }]}> + {new Date(task.startedAt).toLocaleString()} + </Text> + </View> + )} + + {task.completedAt && ( + <View style={styles.infoRow}> + <Text style={[styles.infoLabel, { color: colors.secondaryText }]}> + Completed + </Text> + <Text style={[styles.infoValue, { color: colors.text }]}> + {new Date(task.completedAt).toLocaleString()} + </Text> + </View> + )} + + {task.repositoryUrl && ( + <View style={styles.infoRow}> + <Text style={[styles.infoLabel, { color: colors.secondaryText }]}> + Repository + </Text> + <Text + style={[styles.infoValue, { color: colors.tint }]} + numberOfLines={1} + > + {task.repositoryUrl} + </Text> + </View> + )} + </View> + + {/* Subtasks */} + {task.subtasks && task.subtasks.length > 0 && ( + <View style={[styles.section, { backgroundColor: colors.card }]}> + <Text style={[styles.sectionTitle, { color: colors.text }]}> + Subtasks ({task.subtasks.length}) + </Text> + {task.subtasks.map((subtask) => ( + <View key={subtask.id} style={styles.subtaskRow}> + <TaskStatusBadge status={subtask.status} size="small" /> + <Text + style={[styles.subtaskName, { color: colors.text }]} + numberOfLines={1} + > + {subtask.name} + </Text> + </View> + ))} + </View> + )} + + {/* Error message */} + {task.errorMessage && ( + <View style={[styles.section, styles.errorSection]}> + <Text style={[styles.sectionTitle, { color: '#991b1b' }]}> + Error + </Text> + <Text style={styles.errorText}>{task.errorMessage}</Text> + </View> + )} + + {/* Placeholder for future features */} + <View style={styles.placeholder}> + <Text style={[styles.placeholderText, { color: colors.secondaryText }]}> + Task output and controls will be added here + </Text> + </View> + </ScrollView> + </> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + centered: { + justifyContent: 'center', + alignItems: 'center', + }, + content: { + padding: 16, + gap: 16, + }, + header: { + padding: 16, + borderRadius: 12, + gap: 8, + }, + headerTop: { + flexDirection: 'row', + justifyContent: 'flex-start', + }, + taskName: { + fontSize: 20, + fontWeight: '700', + }, + description: { + fontSize: 14, + lineHeight: 20, + }, + section: { + padding: 16, + borderRadius: 12, + gap: 12, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + }, + progressText: { + fontSize: 14, + lineHeight: 20, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + infoLabel: { + fontSize: 14, + }, + infoValue: { + fontSize: 14, + fontWeight: '500', + flex: 1, + textAlign: 'right', + marginLeft: 16, + }, + subtaskRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 4, + }, + subtaskName: { + fontSize: 14, + flex: 1, + }, + errorSection: { + backgroundColor: '#fee2e2', + }, + errorText: { + fontSize: 14, + color: '#991b1b', + lineHeight: 20, + }, + placeholder: { + padding: 32, + alignItems: 'center', + }, + placeholderText: { + fontSize: 14, + textAlign: 'center', + }, +}); diff --git a/apps/mobile/app/task/_layout.tsx b/apps/mobile/app/task/_layout.tsx new file mode 100644 index 0000000..7e40c63 --- /dev/null +++ b/apps/mobile/app/task/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function TaskLayout() { + return <Stack />; +} |
