feat: integrate mobile template with backend, add /mobile path routing and Todo dashboard

This commit is contained in:
Plena Finance Dev
2026-07-09 11:36:45 +05:30
parent 420b803cc0
commit 1a5028b8f3
20 changed files with 1021 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import { Tabs } from 'expo-router';
import { Home } from 'lucide-react-native';
import { StyleSheet } from 'react-native';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: styles.tabBar,
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
tabBarLabelStyle: styles.tabBarLabel,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ size, color }) => <Home size={size} color={color} />,
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
tabBar: {
backgroundColor: '#FFFFFF',
borderTopWidth: 0.5,
borderTopColor: '#E5E5EA',
paddingBottom: 8,
paddingTop: 8,
height: 84,
},
tabBarLabel: {
fontSize: 12,
fontWeight: '500',
},
});

612
mobile/app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,612 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
TextInput,
Pressable,
FlatList,
ActivityIndicator,
StyleSheet,
RefreshControl,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { toast } from 'sonner-native';
import {
CheckCircle2,
Circle,
Trash2,
Plus,
RefreshCw,
Edit2,
X,
Check,
Activity,
Database,
HardDrive,
} from 'lucide-react-native';
import { getApiUrl } from '../../utils/api';
type Todo = {
id: number;
title: string;
description: string | null;
completed: boolean;
createdAt: string;
updatedAt: string;
attachment: unknown | null;
};
type HealthResponse = {
status: string;
projectId: string;
ready: boolean;
config: {
apiPrefix: string;
databaseConfigured: boolean;
storageConfigured: boolean;
missingEnv: string[];
};
};
export default function Index() {
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Form State
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
// Edit State
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
// Health State
const [health, setHealth] = useState<HealthResponse | null>(null);
const [healthLoading, setHealthLoading] = useState(true);
// Fetch Health Check Status
const fetchHealth = useCallback(async () => {
try {
setHealthLoading(true);
const res = await fetch(getApiUrl('/health'));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setHealth(data);
} catch (err) {
console.warn('Health check failed', err);
setHealth(null);
} finally {
setHealthLoading(false);
}
}, []);
// Fetch Todos List
const fetchTodos = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(getApiUrl('/todos'));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setTodos(data.todos ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch todos');
toast.error('Fetch Failed', {
description: 'Could not retrieve todo items from backend.',
});
} finally {
setLoading(false);
}
}, []);
// Initial Load
useEffect(() => {
fetchHealth();
fetchTodos();
}, [fetchHealth, fetchTodos]);
const handleRefresh = async () => {
await Promise.all([fetchHealth(), fetchTodos()]);
toast.success('Refreshed', {
description: 'Dashboard updated successfully.',
});
};
// Create Todo
const createTodo = async () => {
if (!title.trim()) return;
try {
setSubmitting(true);
const res = await fetch(getApiUrl('/todos'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(),
description: description.trim() || null,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setTitle('');
setDescription('');
toast.success('Success', {
description: 'Todo item created.',
});
await fetchTodos();
} catch (err) {
toast.error('Error', {
description: 'Failed to create todo item.',
});
} finally {
setSubmitting(false);
}
};
// Toggle Completed
const toggleCompleted = async (todo: Todo) => {
try {
const res = await fetch(getApiUrl(`/todos/${todo.id}`), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
toast.success(todo.completed ? 'Reopened' : 'Completed', {
description: `"${todo.title}" updated.`,
});
await fetchTodos();
} catch (err) {
toast.error('Error', {
description: 'Failed to update todo status.',
});
}
};
// Update Title/Description
const saveEdit = async (id: number) => {
if (!editTitle.trim()) return;
try {
const res = await fetch(getApiUrl(`/todos/${id}`), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: editTitle.trim(),
description: editDescription.trim() || null,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setEditingId(null);
toast.success('Updated', {
description: 'Todo changes saved.',
});
await fetchTodos();
} catch (err) {
toast.error('Error', {
description: 'Failed to update todo details.',
});
}
};
// Delete Todo
const deleteTodo = async (id: number) => {
try {
const res = await fetch(getApiUrl(`/todos/${id}`), {
method: 'DELETE',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
toast.success('Deleted', {
description: 'Todo item removed.',
});
await fetchTodos();
} catch (err) {
toast.error('Error', {
description: 'Failed to delete todo.',
});
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
{/* Header Banner */}
<View style={styles.header}>
<View style={styles.headerRow}>
<Text style={styles.headerTitle}>Todo Workspace</Text>
<Pressable style={styles.refreshBtn} onPress={handleRefresh}>
<RefreshCw size={18} color="#4F46E5" />
</Pressable>
</View>
{/* Health Check Panel */}
{healthLoading ? (
<View style={styles.healthContainer}>
<ActivityIndicator size="small" color="#4F46E5" />
<Text style={styles.healthText}>Verifying system health...</Text>
</View>
) : health ? (
<View style={[styles.healthContainer, health.status === 'ok' ? styles.healthOk : styles.healthErr]}>
<View style={styles.healthRow}>
<Activity size={14} color={health.status === 'ok' ? '#10B981' : '#EF4444'} />
<Text style={styles.healthStatusText}>
Status: {health.status.toUpperCase()}
</Text>
</View>
<View style={styles.healthIcons}>
<View style={styles.healthIconItem}>
<Database size={12} color={health.config.databaseConfigured ? '#10B981' : '#EF4444'} />
<Text style={styles.healthIconLabel}>DB</Text>
</View>
<View style={styles.healthIconItem}>
<HardDrive size={12} color={health.config.storageConfigured ? '#10B981' : '#EF4444'} />
<Text style={styles.healthIconLabel}>Storage</Text>
</View>
</View>
</View>
) : (
<View style={[styles.healthContainer, styles.healthErr]}>
<Activity size={14} color="#EF4444" />
<Text style={styles.healthStatusText}>Backend Offline</Text>
</View>
)}
</View>
{/* Main Content List */}
<FlatList
data={todos}
keyExtractor={(item) => item.id.toString()}
refreshControl={
<RefreshControl refreshing={false} onRefresh={handleRefresh} colors={['#4F46E5']} />
}
contentContainerStyle={styles.listContent}
ListHeaderComponent={
/* Add Todo Panel */
<View style={styles.formContainer}>
<Text style={styles.sectionTitle}>Add New Task</Text>
<TextInput
style={styles.input}
placeholder="Task title..."
placeholderTextColor="#9CA3AF"
value={title}
onChangeText={setTitle}
/>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="Description (optional)..."
placeholderTextColor="#9CA3AF"
multiline
numberOfLines={2}
value={description}
onChangeText={setDescription}
/>
<Pressable
style={[styles.createBtn, !title.trim() && styles.createBtnDisabled]}
onPress={createTodo}
disabled={!title.trim() || submitting}
>
{submitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Plus size={18} color="#FFFFFF" style={styles.btnIcon} />
<Text style={styles.createBtnText}>Create Todo</Text>
</>
)}
</Pressable>
</View>
}
ListEmptyComponent={
!loading ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No tasks yet. Create one above!</Text>
</View>
) : null
}
renderItem={({ item }) => (
<View style={[styles.todoCard, item.completed && styles.todoCardCompleted]}>
{editingId === item.id ? (
/* Editing State */
<View style={styles.editForm}>
<TextInput
style={styles.editInput}
value={editTitle}
onChangeText={setEditTitle}
placeholder="Task title..."
/>
<TextInput
style={[styles.editInput, styles.textArea]}
value={editDescription}
onChangeText={setEditDescription}
placeholder="Description..."
multiline
/>
<View style={styles.editActions}>
<Pressable
style={[styles.iconBtn, styles.saveBtn]}
onPress={() => saveEdit(item.id)}
>
<Check size={16} color="#FFFFFF" />
</Pressable>
<Pressable
style={[styles.iconBtn, styles.cancelBtn]}
onPress={() => setEditingId(null)}
>
<X size={16} color="#FFFFFF" />
</Pressable>
</View>
</View>
) : (
/* Display State */
<View style={styles.todoRow}>
<Pressable onPress={() => toggleCompleted(item)} style={styles.checkboxContainer}>
{item.completed ? (
<CheckCircle2 size={22} color="#10B981" />
) : (
<Circle size={22} color="#D1D5DB" />
)}
</Pressable>
<View style={styles.todoTexts}>
<Text style={[styles.todoTitle, item.completed && styles.lineThrough]}>
{item.title}
</Text>
{item.description ? (
<Text style={[styles.todoDesc, item.completed && styles.lineThrough]}>
{item.description}
</Text>
) : null}
</View>
<View style={styles.todoActions}>
<Pressable
style={styles.actionIconBtn}
onPress={() => {
setEditingId(item.id);
setEditTitle(item.title);
setEditDescription(item.description ?? '');
}}
>
<Edit2 size={16} color="#4F46E5" />
</Pressable>
<Pressable style={styles.actionIconBtn} onPress={() => deleteTodo(item.id)}>
<Trash2 size={16} color="#EF4444" />
</Pressable>
</View>
</View>
)}
</View>
)}
/>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
header: {
backgroundColor: '#FFFFFF',
paddingTop: 50,
paddingHorizontal: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
headerTitle: {
fontSize: 22,
fontWeight: 'bold',
color: '#111827',
},
refreshBtn: {
padding: 8,
borderRadius: 8,
backgroundColor: '#EEF2F6',
},
healthContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: '#F3F4F6',
},
healthOk: {
backgroundColor: '#ECFDF5',
},
healthErr: {
backgroundColor: '#FEF2F2',
},
healthRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
healthStatusText: {
fontSize: 12,
fontWeight: '600',
color: '#374151',
},
healthIcons: {
flexDirection: 'row',
gap: 12,
},
healthIconItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
healthIconLabel: {
fontSize: 10,
fontWeight: '500',
color: '#6B7280',
},
healthText: {
fontSize: 12,
color: '#6B7280',
marginLeft: 8,
},
listContent: {
padding: 20,
paddingBottom: 40,
},
formContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 16,
marginBottom: 20,
borderWidth: 1,
borderColor: '#E5E7EB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#374151',
marginBottom: 12,
},
input: {
backgroundColor: '#F9FAFB',
borderWidth: 1,
borderColor: '#D1D5DB',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 14,
color: '#111827',
marginBottom: 10,
},
textArea: {
height: 60,
textAlignVertical: 'top',
},
createBtn: {
backgroundColor: '#4F46E5',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 10,
borderRadius: 8,
marginTop: 4,
},
createBtnDisabled: {
backgroundColor: '#93C5FD',
},
btnIcon: {
marginRight: 6,
},
createBtnText: {
color: '#FFFFFF',
fontWeight: '600',
fontSize: 14,
},
todoCard: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: '#E5E7EB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.03,
shadowRadius: 2,
elevation: 1,
},
todoCardCompleted: {
backgroundColor: '#FAFAFA',
borderColor: '#E5E7EB',
},
todoRow: {
flexDirection: 'row',
alignItems: 'flex-start',
},
checkboxContainer: {
marginRight: 12,
marginTop: 2,
},
todoTexts: {
flex: 1,
marginRight: 8,
},
todoTitle: {
fontSize: 15,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
},
todoDesc: {
fontSize: 13,
color: '#6B7280',
},
lineThrough: {
textDecorationLine: 'line-through',
color: '#9CA3AF',
},
todoActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
actionIconBtn: {
padding: 6,
borderRadius: 6,
backgroundColor: '#F3F4F6',
},
editForm: {
gap: 8,
},
editInput: {
backgroundColor: '#F9FAFB',
borderWidth: 1,
borderColor: '#D1D5DB',
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 6,
fontSize: 14,
color: '#111827',
},
editActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
marginTop: 4,
},
iconBtn: {
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 6,
justifyContent: 'center',
alignItems: 'center',
},
saveBtn: {
backgroundColor: '#10B981',
},
cancelBtn: {
backgroundColor: '#EF4444',
},
emptyContainer: {
alignItems: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 14,
color: '#9CA3AF',
},
});

33
mobile/app/+not-found.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.text}>This screen doesn&apos;t exist.</Text>
<Link href="/" style={styles.link}>
<Text>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
text: {
fontSize: 20,
fontWeight: 600,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});

20
mobile/app/_layout.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Toaster } from 'sonner-native';
import { useFrameworkReady } from '@/hooks/useFrameworkReady';
export default function RootLayout() {
useFrameworkReady(); // Assuming this handles side effects
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="+not-found" options={{ headerShown: false }} />
</Stack>
<StatusBar style="auto" />
<Toaster theme="light" />
</GestureHandlerRootView>
);
}