feat: integrate mobile template with backend, add /mobile path routing and Todo dashboard
This commit is contained in:
612
mobile/app/(tabs)/index.tsx
Normal file
612
mobile/app/(tabs)/index.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user