Initial commit of template
This commit is contained in:
1
.env
Normal file
1
.env
Normal file
@@ -0,0 +1 @@
|
||||
EXPO_PUBLIC_SOLANA_RPC_URL=https://mainnet.helius-rpc.com/?api-key=dd39f964-79fe-4373-a22b-7cac000f163b
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Lockfiles
|
||||
bun.lock
|
||||
|
||||
# Contracts (Anchor / Rust)
|
||||
contracts/node_modules
|
||||
contracts/target/debug
|
||||
contracts/target/release
|
||||
contracts/target/sbpf-solana-solana
|
||||
contracts/target/.rustc_info.json
|
||||
|
||||
# Expo
|
||||
.expo
|
||||
expo-env.d.ts
|
||||
|
||||
# Metro bundler
|
||||
.metro-cache
|
||||
|
||||
# Build output
|
||||
dist
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
38
app.json
Normal file
38
app.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "expo-starter",
|
||||
"slug": "expo-starter",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "myapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.trynoah"
|
||||
},
|
||||
"android": {
|
||||
"package": "com.trynoah",
|
||||
"versionCode": 1,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "single",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"expo-web-browser"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"baseUrl": "/mobile"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
app/(tabs)/_layout.tsx
Normal file
40
app/(tabs)/_layout.tsx
Normal 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
app/(tabs)/index.tsx
Normal file
612
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',
|
||||
},
|
||||
});
|
||||
33
app/+not-found.tsx
Normal file
33
app/+not-found.tsx
Normal 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'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
app/_layout.tsx
Normal file
20
app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
assets/images/favicon.png
Normal file
BIN
assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/icon.png
Normal file
BIN
assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
9
eslint.config.js
Normal file
9
eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
...expoConfig,
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', '.expo/**'],
|
||||
},
|
||||
]);
|
||||
13
hooks/useFrameworkReady.ts
Normal file
13
hooks/useFrameworkReady.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
frameworkReady?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
export function useFrameworkReady() {
|
||||
useEffect(() => {
|
||||
window.frameworkReady?.();
|
||||
});
|
||||
}
|
||||
13
index.ts
Normal file
13
index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'react-native-reanimated';
|
||||
// Import crypto polyfills first
|
||||
import './utils/crypto-setup';
|
||||
import './scripts/network-interceptor';
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const utilityScript = document.createElement('script');
|
||||
utilityScript.src = 'https://content.trynoah.ai/expo-utils.js';
|
||||
document.head.appendChild(utilityScript);
|
||||
}
|
||||
|
||||
// Then import the main expo-router entry
|
||||
import 'expo-router/entry';
|
||||
25
metro.config.ts
Normal file
25
metro.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { FileStore } = require('metro-cache');
|
||||
const path = require('path');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Add resolver configuration for Node.js polyfills
|
||||
config.resolver.alias = {
|
||||
...config.resolver.alias,
|
||||
crypto: 'react-native-get-random-values',
|
||||
stream: 'readable-stream',
|
||||
buffer: 'buffer',
|
||||
};
|
||||
|
||||
|
||||
config.resolver.unstable_conditionNames = ['require', 'react-native'];
|
||||
|
||||
// Add node_modules to resolver platforms
|
||||
config.resolver.platforms = ['native', 'android', 'ios', 'web'];
|
||||
|
||||
// Configure metro to handle the polyfills
|
||||
config.resolver.resolverMainFields = ['react-native', 'browser', 'main'];
|
||||
|
||||
module.exports = config;
|
||||
|
||||
63
package.json
Normal file
63
package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "expo-starter",
|
||||
"main": "index.ts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"build:web": "expo export --platform web",
|
||||
"lint": "expo lint",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "15.1.1",
|
||||
"@lucide/lab": "0.1.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@solana/spl-token": "0.4.13",
|
||||
"@solana/web3.js": "1.98.2",
|
||||
"@supabase/supabase-js": "2.57.4",
|
||||
"bs58": "6.0.0",
|
||||
"buffer": "6.0.3",
|
||||
"expo": "~54.0.35",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-constants": "~18.0.11",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.10",
|
||||
"expo-router": "~6.0.17",
|
||||
"expo-splash-screen": "~31.0.12",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"lucide-react-native": "0.555.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-web": "0.21.2",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"sonner-native": "^0.24.0",
|
||||
"tweetnacl": "1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
51
scripts/network-interceptor.ts
Normal file
51
scripts/network-interceptor.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
if (typeof window !== 'undefined') {
|
||||
const _origFetch = window.fetch;
|
||||
if (typeof _origFetch === 'function' && !(window as any).__noahNetworkPatched) {
|
||||
(window as any).__noahNetworkPatched = true;
|
||||
(window as any).__noahOrigFetch = _origFetch;
|
||||
window.fetch = function (input: any, init?: any) {
|
||||
let url = 'unknown';
|
||||
try { url = input instanceof Request ? input.url : String(input); } catch (_) {}
|
||||
const method = ((input instanceof Request ? input.method : init?.method) || 'GET').toUpperCase();
|
||||
const start = Date.now();
|
||||
const post = (status: number, statusText: string, error: string | null) => {
|
||||
try {
|
||||
window.parent?.postMessage(
|
||||
{type: 'iframe-network', data: {method, url, status, statusText, duration: Date.now() - start, requestBody: null, responseBody: null, error, timestamp: Date.now()}},
|
||||
'*'
|
||||
);
|
||||
} catch (_) {}
|
||||
};
|
||||
let promise: Promise<Response>;
|
||||
try { promise = _origFetch.apply(this, arguments as any); }
|
||||
catch (e: any) { post(0, '', e?.message || String(e)); throw e; }
|
||||
return promise.then(
|
||||
(resp) => { post(resp.status, resp.statusText, null); return resp; },
|
||||
(err: any) => { post(0, '', err?.message || String(err)); throw err; }
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof XMLHttpRequest !== 'undefined' && !(window as any).__noahXHRPatched) {
|
||||
(window as any).__noahXHRPatched = true;
|
||||
const _open = XMLHttpRequest.prototype.open;
|
||||
const _send = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method: string, url: string) {
|
||||
(this as any)._noahMethod = method;
|
||||
(this as any)._noahUrl = url;
|
||||
(this as any)._noahStart = Date.now();
|
||||
return _open.apply(this, arguments as any);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
this.addEventListener('loadend', () => {
|
||||
try {
|
||||
window.parent?.postMessage(
|
||||
{type: 'iframe-network', data: {method: (this as any)._noahMethod || 'GET', url: (this as any)._noahUrl || '', status: this.status, statusText: this.statusText, duration: Date.now() - ((this as any)._noahStart || Date.now()), requestBody: null, responseBody: null, error: this.status === 0 ? 'Network error' : null, timestamp: Date.now()}},
|
||||
'*'
|
||||
);
|
||||
} catch (_) {}
|
||||
});
|
||||
return _send.apply(this, arguments as any);
|
||||
};
|
||||
}
|
||||
}
|
||||
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
5
types/wallet.ts
Normal file
5
types/wallet.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
export interface WalletData {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}
|
||||
23
utils/api.ts
Normal file
23
utils/api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function getApiUrl(path: string): string {
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
const origin = window.location.origin;
|
||||
// Local Expo dev server direct port
|
||||
if (origin.includes(':8081')) {
|
||||
return `http://localhost:8080/api${cleanPath}`;
|
||||
}
|
||||
// Deployed Modal Sandbox direct port
|
||||
if (origin.includes('.modal.host') || origin.includes('.modal.run')) {
|
||||
const backendOrigin = origin.replace('-8081-', '-8080-').replace('-8081.modal.run', '-8080.modal.run');
|
||||
return `${backendOrigin}/api${cleanPath}`;
|
||||
}
|
||||
|
||||
// Relative path works when accessed via port 5173 /mobile proxy
|
||||
return `/api${cleanPath}`;
|
||||
}
|
||||
|
||||
// Fallback for native devices
|
||||
const baseUrl = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8080/api';
|
||||
return `${baseUrl}${cleanPath}`;
|
||||
}
|
||||
31
utils/crypto-setup.ts
Normal file
31
utils/crypto-setup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'react-native-get-random-values';
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
if (typeof global !== 'undefined') {
|
||||
if (typeof global.Buffer === 'undefined') {
|
||||
global.Buffer = Buffer;
|
||||
}
|
||||
} else if (typeof window !== 'undefined') {
|
||||
if (typeof (window as any).Buffer === 'undefined') {
|
||||
(window as any).Buffer = Buffer;
|
||||
}
|
||||
}
|
||||
|
||||
const { getRandomValues } = require('react-native-get-random-values');
|
||||
|
||||
if (typeof global !== 'undefined') {
|
||||
if (typeof global.crypto === 'undefined') {
|
||||
(global as any).crypto = {
|
||||
getRandomValues: getRandomValues,
|
||||
};
|
||||
}
|
||||
} else if (typeof window !== 'undefined') {
|
||||
if (typeof window.crypto === 'undefined') {
|
||||
(window as any).crypto = {
|
||||
getRandomValues: getRandomValues,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user