summaryrefslogtreecommitdiff
path: root/apps/mobile/app
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-18 02:58:27 +0000
committersoryu <soryu@soryu.co>2026-01-18 02:58:27 +0000
commitfcf9e70d54bd737d2dea848d25314120f37db503 (patch)
treebc304a9e153485f7686830614b2ddae4d4ff182e /apps/mobile/app
parentf84a7f2d820f6f432be2b1d78d6bf833b5b19380 (diff)
downloadsoryu-fcf9e70d54bd737d2dea848d25314120f37db503.tar.gz
soryu-fcf9e70d54bd737d2dea848d25314120f37db503.zip
[WIP] Heartbeat checkpoint - 2026-01-18 02:58:27 UTC
Diffstat (limited to 'apps/mobile/app')
-rw-r--r--apps/mobile/app/(auth)/_layout.tsx31
-rw-r--r--apps/mobile/app/(auth)/login.tsx384
-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
-rw-r--r--apps/mobile/app/_layout.tsx111
-rw-r--r--apps/mobile/app/task/[id].tsx259
-rw-r--r--apps/mobile/app/task/_layout.tsx5
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 />;
+}