Update mobile app dependencies and screens

This commit is contained in:
fullsizemalt 2025-11-19 09:42:02 -08:00
parent 5bc3ca1832
commit 4d29a097a3
11 changed files with 662 additions and 100 deletions

View file

@ -0,0 +1,121 @@
version: '3.8'
# Gemini Parallel Deployment
# Ports shifted to avoid conflict with production
services:
postgres:
image: postgres:15-alpine
container_name: mtad-postgres-gemini
restart: unless-stopped
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASSWORD:-gemini-test-password}
POSTGRES_DB: morethanadiagnosis
POSTGRES_INITDB_ARGS: "-c shared_preload_libraries=pg_stat_statements"
volumes:
- postgres_data_gemini:/var/lib/postgresql/data
ports:
- "5433:5432"
networks:
- mtad-network-gemini
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d morethanadiagnosis"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: mtad-redis-gemini
restart: unless-stopped
ports:
- "6380:6379"
volumes:
- redis_data_gemini:/data
networks:
- mtad-network-gemini
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-gemini-test-password}
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
api:
build:
context: .
dockerfile: Dockerfile
container_name: mtad-api-gemini
restart: unless-stopped
environment:
ENV: development
DEBUG: "true"
DATABASE_URL: postgresql://admin:${DB_PASSWORD:-gemini-test-password}@postgres:5432/morethanadiagnosis
REDIS_URL: redis://:${REDIS_PASSWORD:-gemini-test-password}@redis:6379/0
SECRET_KEY: ${SECRET_KEY:-gemini-test-secret}
CORS_ORIGINS: '["http://localhost:3001", "http://localhost:8081"]'
LOG_LEVEL: DEBUG
ports:
- "8001:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- mtad-network-gemini
volumes:
- ./app:/app/app:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
frontend:
build:
context: ../web
dockerfile: Dockerfile
container_name: mtad-web-gemini
restart: unless-stopped
expose:
- "3000"
networks:
- mtad-network-gemini
depends_on:
- api
environment:
NEXT_PUBLIC_API_BASE_URL: http://api:8000/api/v1
nginx:
image: nginx:alpine
container_name: mtad-nginx-gemini
restart: unless-stopped
ports:
- "8081:80"
# - "8443:443" # SSL disabled for gemini test to avoid cert conflicts
volumes:
- ./nginx.gemini.conf:/etc/nginx/nginx.conf:ro
# - ./certbot/conf:/etc/letsencrypt:ro # Disable SSL for test
- ../web/out:/usr/share/nginx/html:ro
networks:
- mtad-network-gemini
depends_on:
- api
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
mtad-network-gemini:
driver: bridge
volumes:
postgres_data_gemini:
driver: local
redis_data_gemini:
driver: local

100
backend/nginx.gemini.conf Normal file
View file

@ -0,0 +1,100 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml font/truetype font/opentype
application/vnd.ms-fontobject image/svg+xml;
# Upstream API
upstream api {
least_conn;
server api:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
# HTTP server - Gemini Test Deployment
server {
listen 80;
server_name _; # Catch all
# Proxy frontend requests to Next.js service
location / {
proxy_pass http://frontend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# API endpoints
location /api/v1/ {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_redirect off;
}
location /docs {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /redoc {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /openapi.json {
proxy_pass http://api;
proxy_set_header Host $host;
}
location /health {
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}

View file

@ -9,12 +9,12 @@ python-multipart==0.0.6
sqlalchemy==2.0.23 sqlalchemy==2.0.23
alembic==1.12.1 alembic==1.12.1
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
psycopg[binary]==3.9.10 psycopg[binary]==3.1.18
# Authentication & Security # Authentication & Security
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
PyJWT==2.8.1 PyJWT>=2.8.0
bcrypt==4.1.1 bcrypt==4.1.1
cryptography==41.0.7 cryptography==41.0.7
argon2-cffi==23.1.0 argon2-cffi==23.1.0
@ -48,7 +48,7 @@ httpx==0.25.2
# Linting & Type Checking # Linting & Type Checking
ruff==0.1.8 ruff==0.1.8
mypy==1.7.1 mypy==1.7.1
types-python-dateutil==2.8.20 types-python-dateutil>=2.8.19
# Development # Development
black==23.12.0 black==23.12.0

View file

@ -54,6 +54,17 @@ export default function TabLayout() {
}} }}
/> />
<Tabs.Screen
name="podcasts"
options={{
title: 'Podcasts',
tabBarLabel: 'Podcasts',
tabBarIcon: ({ color, size }) => (
<Ionicons name="mic" size={size} color={color} />
),
}}
/>
<Tabs.Screen <Tabs.Screen
name="profile" name="profile"
options={{ options={{

View file

@ -7,122 +7,125 @@ import {
SafeAreaView, SafeAreaView,
TouchableOpacity, TouchableOpacity,
RefreshControl, RefreshControl,
StatusBar,
} from 'react-native'; } from 'react-native';
import { useCommunity } from '../../hooks/useCommunity'; import { useCommunity } from '../../hooks/useCommunity';
import { LoadingState } from '../../components/ui/LoadingState'; import { LoadingState } from '../../components/ui/LoadingState';
import { ErrorState } from '../../components/ui/ErrorState'; import { ErrorState } from '../../components/ui/ErrorState';
import { EmptyState } from '../../components/ui/EmptyState'; import { EmptyState } from '../../components/ui/EmptyState';
import { Theme } from '../../constants/Theme';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: Theme.colors.background,
}, },
header: { header: {
backgroundColor: '#fff', backgroundColor: Theme.colors.surface,
padding: 16, padding: Theme.spacing.l,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#e0e0e0', borderBottomColor: Theme.colors.border,
}, },
headerTitle: { headerTitle: {
fontSize: 24, ...Theme.typography.heading,
fontWeight: '700', color: Theme.colors.primary,
color: '#000',
marginBottom: 8,
}, },
section: { section: {
paddingHorizontal: 16, paddingHorizontal: Theme.spacing.m,
marginTop: 16, marginTop: Theme.spacing.l,
}, },
communityCard: { communityCard: {
backgroundColor: '#fff', backgroundColor: Theme.colors.surface,
borderRadius: 12, borderRadius: Theme.borderRadius.l,
padding: 16, padding: Theme.spacing.m,
marginBottom: 12, marginBottom: Theme.spacing.s,
...Theme.shadows.small,
}, },
communityCardHeader: { communityCardHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 12, marginBottom: Theme.spacing.s,
}, },
communityIcon: { communityIcon: {
width: 40, width: 48,
height: 40, height: 48,
borderRadius: 20, borderRadius: 24,
backgroundColor: '#f0f0f0', backgroundColor: Theme.colors.secondaryLight,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: 12, marginRight: Theme.spacing.m,
}, },
communityTitle: { communityTitle: {
fontSize: 16, ...Theme.typography.subheading,
fontWeight: '600', color: Theme.colors.text,
color: '#000',
}, },
communityDescription: { communityDescription: {
fontSize: 13, ...Theme.typography.body,
color: '#666', fontSize: 14,
lineHeight: 20, color: Theme.colors.textMuted,
marginBottom: 12, marginBottom: Theme.spacing.m,
}, },
memberCount: { memberCount: {
fontSize: 12, ...Theme.typography.caption,
color: '#999', color: Theme.colors.primary,
marginBottom: 10, fontWeight: '600',
marginBottom: Theme.spacing.s,
}, },
joinButton: { joinButton: {
backgroundColor: '#0066cc', backgroundColor: Theme.colors.primary,
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: Theme.spacing.m,
borderRadius: 6, borderRadius: Theme.borderRadius.round,
alignItems: 'center', alignItems: 'center',
}, },
joinButtonText: { joinButtonText: {
color: '#fff', color: Theme.colors.textLight,
fontSize: 13, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}, },
sectionTitle: { sectionTitle: {
fontSize: 18, ...Theme.typography.subheading,
fontWeight: '700', color: Theme.colors.text,
color: '#000', marginBottom: Theme.spacing.s,
marginBottom: 12,
}, },
postCard: { postCard: {
backgroundColor: '#fff', backgroundColor: Theme.colors.surface,
borderRadius: 12, borderRadius: Theme.borderRadius.m,
padding: 16, padding: Theme.spacing.m,
marginBottom: 12, marginBottom: Theme.spacing.s,
...Theme.shadows.small,
}, },
postHeader: { postHeader: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 8, marginBottom: Theme.spacing.s,
}, },
postAuthor: { postAuthor: {
fontWeight: '600', fontWeight: '600',
fontSize: 14, fontSize: 14,
color: Theme.colors.text,
}, },
postTime: { postTime: {
color: '#666', color: Theme.colors.textMuted,
fontSize: 12, fontSize: 12,
}, },
postContent: { postContent: {
...Theme.typography.body,
fontSize: 14, fontSize: 14,
lineHeight: 20, color: Theme.colors.text,
color: '#333', marginBottom: Theme.spacing.m,
marginBottom: 12,
}, },
postFooter: { postFooter: {
flexDirection: 'row', flexDirection: 'row',
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#f0f0f0', borderTopColor: Theme.colors.border,
paddingTop: 12, paddingTop: Theme.spacing.s,
}, },
postStat: { postStat: {
marginRight: 16, marginRight: Theme.spacing.m,
color: '#666', color: Theme.colors.textMuted,
fontSize: 12, fontSize: 12,
fontWeight: '500',
}, },
}); });
@ -170,10 +173,16 @@ export default function CommunityScreen() {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor={Theme.colors.background} />
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={ refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={onRefresh} /> <RefreshControl
refreshing={isLoading}
onRefresh={onRefresh}
tintColor={Theme.colors.primary}
colors={[Theme.colors.primary]}
/>
} }
> >
<View style={styles.header}> <View style={styles.header}>
@ -189,12 +198,14 @@ export default function CommunityScreen() {
<View style={styles.communityCard}> <View style={styles.communityCard}>
<View style={styles.communityCardHeader}> <View style={styles.communityCardHeader}>
<View style={styles.communityIcon}> <View style={styles.communityIcon}>
<Text style={{ fontSize: 20 }}>{community.icon}</Text> <Text style={{ fontSize: 24 }}>{community.icon}</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.communityTitle}>{community.title}</Text>
<Text style={styles.memberCount}>{community.members}</Text>
</View> </View>
<Text style={styles.communityTitle}>{community.title}</Text>
</View> </View>
<Text style={styles.communityDescription}>{community.description}</Text> <Text style={styles.communityDescription}>{community.description}</Text>
<Text style={styles.memberCount}>{community.members}</Text>
<TouchableOpacity style={styles.joinButton}> <TouchableOpacity style={styles.joinButton}>
<Text style={styles.joinButtonText}>Join Community</Text> <Text style={styles.joinButtonText}>Join Community</Text>
</TouchableOpacity> </TouchableOpacity>
@ -229,9 +240,9 @@ export default function CommunityScreen() {
)} )}
<View style={[styles.section, { marginBottom: 40 }]}> <View style={[styles.section, { marginBottom: 40 }]}>
<View style={styles.communityCard}> <View style={[styles.communityCard, { backgroundColor: Theme.colors.secondaryLight }]}>
<Text style={styles.communityTitle}>Community Guidelines</Text> <Text style={[styles.communityTitle, { color: Theme.colors.primaryDark }]}>Community Guidelines</Text>
<Text style={[styles.communityDescription, { marginTop: 8 }]}> <Text style={[styles.communityDescription, { marginTop: 8, color: Theme.colors.primaryDark }]}>
Be respectful and supportive{'\n'} Be respectful and supportive{'\n'}
Share your authentic story{'\n'} Share your authentic story{'\n'}
Respect others' privacy{'\n'} Respect others' privacy{'\n'}

View file

@ -0,0 +1,242 @@
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, StyleSheet, TouchableOpacity, Image, ActivityIndicator } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { Audio } from 'expo-av';
import { Theme } from '../../constants/Theme';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
const API_URL = process.env.EXPO_PUBLIC_API_URL || 'http://216.158.230.94:8001/api/v1';
interface PodcastEpisode {
id: string;
title: string;
description: string;
audio_url: string;
published_at: string;
duration: string;
image_url: string;
}
interface PodcastFeed {
title: string;
description: string;
image_url: string;
episodes: PodcastEpisode[];
}
const fetchPodcasts = async (): Promise<PodcastFeed> => {
const response = await fetch(`${API_URL}/podcast/`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};
export default function PodcastsScreen() {
const { data, isLoading, error } = useQuery({
queryKey: ['podcasts'],
queryFn: fetchPodcasts,
});
const [sound, setSound] = useState<Audio.Sound | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentEpisodeId, setCurrentEpisodeId] = useState<string | null>(null);
useEffect(() => {
return () => {
if (sound) {
sound.unloadAsync();
}
};
}, [sound]);
const playPodcast = async (audioUrl: string, episodeId: string) => {
try {
if (sound) {
await sound.unloadAsync();
}
const { sound: newSound } = await Audio.Sound.createAsync(
{ uri: audioUrl },
{ shouldPlay: true }
);
setSound(newSound);
setCurrentEpisodeId(episodeId);
setIsPlaying(true);
newSound.setOnPlaybackStatusUpdate((status) => {
if (status.isLoaded) {
setIsPlaying(status.isPlaying);
if (status.didJustFinish) {
setIsPlaying(false);
setCurrentEpisodeId(null);
}
}
});
} catch (error) {
console.error('Error playing sound:', error);
}
};
const pausePodcast = async () => {
if (sound) {
await sound.pauseAsync();
setIsPlaying(false);
}
};
const resumePodcast = async () => {
if (sound) {
await sound.playAsync();
setIsPlaying(true);
}
};
const handlePlayPause = (audioUrl: string, episodeId: string) => {
if (currentEpisodeId === episodeId) {
if (isPlaying) {
pausePodcast();
} else {
resumePodcast();
}
} else {
playPodcast(audioUrl, episodeId);
}
};
const renderItem = ({ item }: { item: PodcastEpisode }) => (
<View style={styles.card}>
<View style={styles.cardContent}>
<Image source={{ uri: item.image_url || data?.image_url }} style={styles.thumbnail} />
<View style={styles.textContainer}>
<Text style={styles.episodeTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.episodeDate}>{new Date(item.published_at).toLocaleDateString()}</Text>
<Text style={styles.episodeDuration}>{item.duration}</Text>
</View>
<TouchableOpacity
style={styles.playButton}
onPress={() => handlePlayPause(item.audio_url, item.id)}
>
<Ionicons
name={currentEpisodeId === item.id && isPlaying ? "pause-circle" : "play-circle"}
size={48}
color={Theme.colors.primary}
/>
</TouchableOpacity>
</View>
</View>
);
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={Theme.colors.primary} />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>Failed to load podcasts</Text>
</View>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<FlatList
data={data?.episodes}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.headerTitle}>Podcasts</Text>
<Text style={styles.headerSubtitle}>Listen to the latest episodes</Text>
</View>
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Theme.colors.background,
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
listContent: {
padding: Theme.spacing.m,
},
header: {
marginBottom: Theme.spacing.l,
},
headerTitle: {
fontSize: Theme.typography.fontSize.xxl,
fontFamily: Theme.typography.fontFamily.heading,
color: Theme.colors.text,
fontWeight: 'bold',
},
headerSubtitle: {
fontSize: Theme.typography.fontSize.m,
fontFamily: Theme.typography.fontFamily.body,
color: Theme.colors.text,
opacity: 0.7,
},
card: {
backgroundColor: Theme.colors.surface,
borderRadius: Theme.borderRadius.m,
padding: Theme.spacing.m,
marginBottom: Theme.spacing.m,
...Theme.shadows.card,
},
cardContent: {
flexDirection: 'row',
alignItems: 'center',
},
thumbnail: {
width: 60,
height: 60,
borderRadius: Theme.borderRadius.s,
marginRight: Theme.spacing.m,
backgroundColor: Theme.colors.secondary,
},
textContainer: {
flex: 1,
marginRight: Theme.spacing.s,
},
episodeTitle: {
fontSize: Theme.typography.fontSize.m,
fontFamily: Theme.typography.fontFamily.heading,
color: Theme.colors.text,
fontWeight: '600',
marginBottom: 4,
},
episodeDate: {
fontSize: Theme.typography.fontSize.xs,
fontFamily: Theme.typography.fontFamily.body,
color: Theme.colors.text,
opacity: 0.6,
},
episodeDuration: {
fontSize: Theme.typography.fontSize.xs,
fontFamily: Theme.typography.fontFamily.body,
color: Theme.colors.text,
opacity: 0.6,
},
playButton: {
padding: 4,
},
errorText: {
color: Theme.colors.error || 'red',
fontSize: Theme.typography.fontSize.m,
},
});

View file

@ -7,75 +7,75 @@ import {
SafeAreaView, SafeAreaView,
TouchableOpacity, TouchableOpacity,
RefreshControl, RefreshControl,
StatusBar,
} from 'react-native'; } from 'react-native';
import { useResources } from '../../hooks/useResources'; import { useResources } from '../../hooks/useResources';
import { LoadingState } from '../../components/ui/LoadingState'; import { LoadingState } from '../../components/ui/LoadingState';
import { ErrorState } from '../../components/ui/ErrorState'; import { ErrorState } from '../../components/ui/ErrorState';
import { EmptyState } from '../../components/ui/EmptyState'; import { EmptyState } from '../../components/ui/EmptyState';
import { Theme } from '../../constants/Theme';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: Theme.colors.background,
}, },
header: { header: {
backgroundColor: '#fff', backgroundColor: Theme.colors.surface,
padding: 16, padding: Theme.spacing.l,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#e0e0e0', borderBottomColor: Theme.colors.border,
}, },
headerTitle: { headerTitle: {
fontSize: 24, ...Theme.typography.heading,
fontWeight: '700', color: Theme.colors.primary,
color: '#000', marginBottom: Theme.spacing.s,
marginBottom: 8,
}, },
headerDescription: { headerDescription: {
fontSize: 14, ...Theme.typography.body,
color: '#666', color: Theme.colors.textMuted,
lineHeight: 20,
}, },
section: { section: {
paddingHorizontal: 16, paddingHorizontal: Theme.spacing.m,
marginTop: 16, marginTop: Theme.spacing.l,
}, },
sectionTitle: { sectionTitle: {
fontSize: 16, ...Theme.typography.subheading,
fontWeight: '600', color: Theme.colors.text,
color: '#000', marginBottom: Theme.spacing.m,
marginBottom: 12,
}, },
resourceCard: { resourceCard: {
backgroundColor: '#fff', backgroundColor: Theme.colors.surface,
borderRadius: 12, borderRadius: Theme.borderRadius.m,
padding: 16, padding: Theme.spacing.m,
marginBottom: 12, marginBottom: Theme.spacing.m,
borderLeftWidth: 4, borderLeftWidth: 4,
borderLeftColor: '#0066cc', borderLeftColor: Theme.colors.primary,
...Theme.shadows.small,
}, },
resourceTitle: { resourceTitle: {
fontSize: 15, ...Theme.typography.subheading,
fontWeight: '600', fontSize: 16,
color: '#000', color: Theme.colors.text,
marginBottom: 6, marginBottom: Theme.spacing.xs,
}, },
resourceDescription: { resourceDescription: {
fontSize: 13, ...Theme.typography.body,
color: '#666', fontSize: 14,
lineHeight: 18, color: Theme.colors.textMuted,
marginBottom: Theme.spacing.m,
}, },
resourceLink: { resourceLink: {
marginTop: 10, paddingVertical: Theme.spacing.s,
paddingVertical: 8, paddingHorizontal: Theme.spacing.m,
paddingHorizontal: 12, backgroundColor: Theme.colors.secondaryLight,
backgroundColor: '#f0f0f0', borderRadius: Theme.borderRadius.s,
borderRadius: 6, alignSelf: 'flex-start',
}, },
resourceLinkText: { resourceLinkText: {
color: '#0066cc', color: Theme.colors.primaryDark,
fontSize: 12, fontSize: 12,
fontWeight: '600', fontWeight: '600',
textAlign: 'center',
}, },
}); });
@ -96,10 +96,16 @@ export default function ResourcesScreen() {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor={Theme.colors.background} />
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={ refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={onRefresh} /> <RefreshControl
refreshing={isLoading}
onRefresh={onRefresh}
tintColor={Theme.colors.primary}
colors={[Theme.colors.primary]}
/>
} }
> >
<View style={styles.header}> <View style={styles.header}>
@ -127,7 +133,7 @@ export default function ResourcesScreen() {
<View style={[styles.section, { marginBottom: 40 }]}> <View style={[styles.section, { marginBottom: 40 }]}>
<Text style={styles.sectionTitle}>More Coming Soon</Text> <Text style={styles.sectionTitle}>More Coming Soon</Text>
<Text style={{ fontSize: 14, color: '#666', lineHeight: 20 }}> <Text style={{ ...Theme.typography.body, fontSize: 14, color: Theme.colors.textMuted }}>
Be sure to check back often or sign up to receive updates as we're adding new resources all the time! Be sure to check back often or sign up to receive updates as we're adding new resources all the time!
</Text> </Text>
</View> </View>

View file

@ -0,0 +1,12 @@
export const Colors = {
text: '#FAF9F6', // Cream Text
textMuted: '#9CA3AF',
textLight: '#FFFFFF',
error: '#EF4444',
success: '#10B981',
warning: '#F59E0B',
border: '#374151',
},
};

48
mobile/constants/Theme.ts Normal file
View file

@ -0,0 +1,48 @@
import { Colors } from './Colors';
export const Theme = {
colors: Colors.light, // Default to light theme for now
spacing: {
xs: 4,
s: 8,
m: 16,
l: 24,
xl: 32,
xxl: 48,
},
borderRadius: {
s: 8,
m: 12,
l: 16,
xl: 24,
round: 9999,
},
typography: {
fontFamily: {
heading: 'System', // Placeholder
body: 'System', // Placeholder
},
fontSize: {
xs: 12,
s: 14,
m: 16,
l: 20,
xl: 24,
xxl: 32,
},
fontWeight: {
regular: '400' as const,
medium: '500' as const,
bold: '700' as const,
},
},
shadows: {
card: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
},
};

View file

@ -12,6 +12,7 @@
"@tanstack/react-query": "^5.90.10", "@tanstack/react-query": "^5.90.10",
"axios": "^1.6.0", "axios": "^1.6.0",
"expo": "^51.0.0", "expo": "^51.0.0",
"expo-av": "~14.0.7",
"expo-constants": "~15.4.0", "expo-constants": "~15.4.0",
"expo-linking": "~6.0.0", "expo-linking": "~6.0.0",
"expo-router": "^3.4.0", "expo-router": "^3.4.0",
@ -8347,6 +8348,15 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-av": {
"version": "14.0.7",
"resolved": "https://registry.npmjs.org/expo-av/-/expo-av-14.0.7.tgz",
"integrity": "sha512-FvKZxyy+2/qcCmp+e1GTK3s4zH8ZO1RfjpqNxh7ARlS1oH8HPtk1AyZAMo52tHz3yQ3UIqxQ2YbI9CFb4065lA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-constants": { "node_modules/expo-constants": {
"version": "15.4.6", "version": "15.4.6",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-15.4.6.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-15.4.6.tgz",

View file

@ -20,6 +20,7 @@
"@tanstack/react-query": "^5.90.10", "@tanstack/react-query": "^5.90.10",
"axios": "^1.6.0", "axios": "^1.6.0",
"expo": "^51.0.0", "expo": "^51.0.0",
"expo-av": "~14.0.7",
"expo-constants": "~15.4.0", "expo-constants": "~15.4.0",
"expo-linking": "~6.0.0", "expo-linking": "~6.0.0",
"expo-router": "^3.4.0", "expo-router": "^3.4.0",