feat: Sprint 0 — project scaffold, DB schema, Zustand stores, base UI components
This commit is contained in:
6
.eslintrc.js
Normal file
6
.eslintrc.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
extends: ['expo', 'prettier'],
|
||||
rules: {
|
||||
'no-unused-vars': 'warn',
|
||||
},
|
||||
};
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
.env
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
53
app.json
Normal file
53
app.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "TerritoryLog",
|
||||
"slug": "territory-log",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "territorylog",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#1A6B72"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "space.juankibin.territorylog"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#1A6B72"
|
||||
},
|
||||
"package": "space.juankibin.territorylog"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-sqlite",
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow TerritoryLog to use your location to tag ministry contacts."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-local-authentication",
|
||||
{
|
||||
"faceIDPermission": "Allow TerritoryLog to use Face ID for app lock."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow TerritoryLog to use the camera to scan QR codes."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/(tabs)/_layout.tsx
Normal file
43
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Home, Users, Map, Settings } from 'lucide-react-native';
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#1A6B72',
|
||||
tabBarInactiveTintColor: '#9CA3AF',
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color, size }) => <Home size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="contacts"
|
||||
options={{
|
||||
title: 'Contacts',
|
||||
tabBarIcon: ({ color, size }) => <Users size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="map"
|
||||
options={{
|
||||
title: 'Map',
|
||||
tabBarIcon: ({ color, size }) => <Map size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
10
app/(tabs)/contacts.tsx
Normal file
10
app/(tabs)/contacts.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
export default function ContactsScreen() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-secondary">
|
||||
<Text className="text-xl text-charcoal">Contacts</Text>
|
||||
<Text className="text-gray-500 mt-2">Coming in Sprint 1</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
10
app/(tabs)/index.tsx
Normal file
10
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-secondary">
|
||||
<Text className="text-2xl font-bold text-primary">TerritoryLog</Text>
|
||||
<Text className="text-charcoal mt-2">Dashboard coming in Sprint 2</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
10
app/(tabs)/map.tsx
Normal file
10
app/(tabs)/map.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
export default function MapScreen() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-secondary">
|
||||
<Text className="text-xl text-charcoal">Map</Text>
|
||||
<Text className="text-gray-500 mt-2">Coming in Sprint 4</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
10
app/(tabs)/settings.tsx
Normal file
10
app/(tabs)/settings.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-secondary">
|
||||
<Text className="text-xl text-charcoal">Settings</Text>
|
||||
<Text className="text-gray-500 mt-2">Coming in Sprint 6</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
11
app/_layout.tsx
Normal file
11
app/_layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import '../global.css';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
0
assets/images/.gitkeep
Normal file
0
assets/images/.gitkeep
Normal file
9
babel.config.js
Normal file
9
babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||
],
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
};
|
||||
37
components/ui/Button.tsx
Normal file
37
components/ui/Button.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { TouchableOpacity, Text, ActivityIndicator } from 'react-native';
|
||||
|
||||
interface ButtonProps {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ label, onPress, variant = 'primary', loading, disabled }: ButtonProps) {
|
||||
const base = 'rounded-xl py-4 px-6 items-center justify-center';
|
||||
const variants = {
|
||||
primary: 'bg-primary',
|
||||
secondary: 'bg-secondary border border-primary',
|
||||
danger: 'bg-danger',
|
||||
};
|
||||
const textColors = {
|
||||
primary: 'text-white',
|
||||
secondary: 'text-primary',
|
||||
danger: 'text-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className={`${base} ${variants[variant]} ${disabled || loading ? 'opacity-50' : ''}`}
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={variant === 'secondary' ? '#1A6B72' : 'white'} />
|
||||
) : (
|
||||
<Text className={`font-semibold text-base ${textColors[variant]}`}>{label}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
9
components/ui/Card.tsx
Normal file
9
components/ui/Card.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { View, ViewProps } from 'react-native';
|
||||
|
||||
export function Card({ children, className = '', ...props }: ViewProps & { className?: string }) {
|
||||
return (
|
||||
<View className={`bg-white rounded-2xl p-4 shadow-sm ${className}`} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
22
components/ui/Input.tsx
Normal file
22
components/ui/Input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { View, Text, TextInput, TextInputProps } from 'react-native';
|
||||
|
||||
interface InputProps extends TextInputProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, error, ...props }: InputProps) {
|
||||
return (
|
||||
<View className="mb-4">
|
||||
{label && <Text className="text-charcoal font-medium mb-1">{label}</Text>}
|
||||
<TextInput
|
||||
className={`border rounded-xl px-4 py-3 text-charcoal bg-white ${
|
||||
error ? 'border-danger' : 'border-gray-200'
|
||||
}`}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
{...props}
|
||||
/>
|
||||
{error && <Text className="text-danger text-sm mt-1">{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
3
global.css
Normal file
3
global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
114
lib/database.ts
Normal file
114
lib/database.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
let db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
export async function getDatabase(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (!db) {
|
||||
db = await SQLite.openDatabaseAsync('territorylog.db');
|
||||
await initSchema(db);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
async function initSchema(database: SQLite.SQLiteDatabase): Promise<void> {
|
||||
await database.execAsync(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
share_id TEXT UNIQUE NOT NULL,
|
||||
is_self INTEGER DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS territories (
|
||||
id TEXT PRIMARY KEY,
|
||||
territory_code TEXT UNIQUE NOT NULL,
|
||||
municipality TEXT,
|
||||
barangay TEXT,
|
||||
area TEXT,
|
||||
block TEXT,
|
||||
assigned_to TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
address TEXT,
|
||||
household_count INTEGER DEFAULT 1,
|
||||
gender TEXT,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'Active',
|
||||
tags TEXT DEFAULT '[]',
|
||||
notes TEXT,
|
||||
territory_code TEXT REFERENCES territories(territory_code),
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS visits (
|
||||
id TEXT PRIMARY KEY,
|
||||
contact_id TEXT NOT NULL REFERENCES contacts(id),
|
||||
visited_by_name TEXT NOT NULL,
|
||||
visited_by_id TEXT NOT NULL,
|
||||
visit_date INTEGER NOT NULL,
|
||||
topic TEXT,
|
||||
response TEXT,
|
||||
remarks TEXT,
|
||||
next_visit_date INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
partner_id TEXT NOT NULL,
|
||||
partner_name TEXT,
|
||||
synced_at INTEGER NOT NULL,
|
||||
sent_count INTEGER DEFAULT 0,
|
||||
received_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(full_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_status ON contacts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_territory ON contacts(territory_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_visits_contact ON visits(contact_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_visits_date ON visits(visit_date);
|
||||
`);
|
||||
|
||||
// Seed default topics
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const defaultTopics = [
|
||||
{ id: 't1', name: 'The Kingdom of God' },
|
||||
{ id: 't2', name: 'Paradise Earth' },
|
||||
{ id: 't3', name: 'Life After Death' },
|
||||
{ id: 't4', name: "The Bible's Reliability" },
|
||||
{ id: 't5', name: "God's Name (Jehovah)" },
|
||||
{ id: 't6', name: 'Why Bad Things Happen' },
|
||||
{ id: 't7', name: 'Jesus Christ' },
|
||||
{ id: 't8', name: 'The Resurrection' },
|
||||
{ id: 't9', name: 'Family Happiness' },
|
||||
];
|
||||
|
||||
for (const topic of defaultTopics) {
|
||||
await database.runAsync(
|
||||
'INSERT OR IGNORE INTO topics (id, name, is_default, created_at) VALUES (?, ?, 1, ?)',
|
||||
[topic.id, topic.name, now]
|
||||
);
|
||||
}
|
||||
}
|
||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "territory-log",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "eslint . --ext .ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"expo": "~52.0.0",
|
||||
"expo-router": "~4.0.0",
|
||||
"expo-sqlite": "~15.0.0",
|
||||
"expo-location": "~18.0.0",
|
||||
"expo-local-authentication": "~14.0.0",
|
||||
"expo-camera": "~16.0.0",
|
||||
"expo-sharing": "~12.0.0",
|
||||
"expo-document-picker": "~12.0.0",
|
||||
"expo-file-system": "~18.0.0",
|
||||
"react": "18.3.2",
|
||||
"react-native": "0.76.0",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.0.0",
|
||||
"react-native-gesture-handler": "~2.20.0",
|
||||
"react-native-reanimated": "~3.16.0",
|
||||
"@gorhom/bottom-sheet": "^5.0.0",
|
||||
"zustand": "^5.0.0",
|
||||
"nativewind": "^4.0.0",
|
||||
"lucide-react-native": "^0.460.0",
|
||||
"react-native-svg": "15.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.0",
|
||||
"@types/react": "~18.3.12",
|
||||
"typescript": "^5.3.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-expo": "~8.0.0",
|
||||
"prettier": "^3.3.0"
|
||||
}
|
||||
}
|
||||
42
store/useContactStore.ts
Normal file
42
store/useContactStore.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ContactCategory = 'Adult' | 'Teenager' | 'Kid';
|
||||
export type ContactStatus = 'Active' | 'Return Visit' | 'Bible Study' | 'Not Interested' | 'Do Not Call';
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
fullName: string;
|
||||
address?: string;
|
||||
householdCount: number;
|
||||
gender?: string;
|
||||
category: ContactCategory;
|
||||
status: ContactStatus;
|
||||
tags: string[];
|
||||
notes?: string;
|
||||
territoryCode?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface ContactStore {
|
||||
contacts: Contact[];
|
||||
setContacts: (contacts: Contact[]) => void;
|
||||
addContact: (contact: Contact) => void;
|
||||
updateContact: (id: string, updates: Partial<Contact>) => void;
|
||||
removeContact: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useContactStore = create<ContactStore>((set) => ({
|
||||
contacts: [],
|
||||
setContacts: (contacts) => set({ contacts }),
|
||||
addContact: (contact) => set((state) => ({ contacts: [...state.contacts, contact] })),
|
||||
updateContact: (id, updates) =>
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map((c) => (c.id === id ? { ...c, ...updates } : c)),
|
||||
})),
|
||||
removeContact: (id) =>
|
||||
set((state) => ({ contacts: state.contacts.filter((c) => c.id !== id) })),
|
||||
}));
|
||||
19
store/useUserStore.ts
Normal file
19
store/useUserStore.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
displayName: string;
|
||||
shareId: string;
|
||||
}
|
||||
|
||||
interface UserStore {
|
||||
user: User | null;
|
||||
setUser: (user: User) => void;
|
||||
clearUser: () => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserStore>((set) => ({
|
||||
user: null,
|
||||
setUser: (user) => set({ user }),
|
||||
clearUser: () => set({ user: null }),
|
||||
}));
|
||||
19
tailwind.config.js
Normal file
19
tailwind.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#1A6B72',
|
||||
'primary-light': '#2A8B94',
|
||||
secondary: '#F8F4EF',
|
||||
accent: '#D4A843',
|
||||
success: '#4CAF7D',
|
||||
danger: '#C0392B',
|
||||
charcoal: '#2C3E50',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
9
tsconfig.json
Normal file
9
tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user