Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/mobile/src/components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import { View, Text, Image, ViewStyle, ImageStyle, StyleSheet } from 'react-native';
import { COLORS } from '../theme/tokens';

type Props = {
uri?: string | null;
name?: string;
size?: number;
style?: ViewStyle | ImageStyle;
};

export const Avatar: React.FC<Props> = ({ uri, name = 'D', size = 56, style }) => {
const initials = name.charAt(0).toUpperCase();
const imageStyle = [{ width: size, height: size, borderRadius: size / 2 } as ImageStyle, style as ImageStyle];
const placeholderStyle = [{ width: size, height: size, borderRadius: size / 2, backgroundColor: COLORS.primary }, style as ViewStyle];

return uri ? (
<Image source={{ uri }} style={imageStyle} />
) : (
<View style={[styles.placeholder, ...placeholderStyle]}>
<Text style={[styles.placeholderText, { fontSize: Math.round(size / 2) }]}>{initials}</Text>
</View>
);
};

export default Avatar;

const styles = StyleSheet.create({
placeholder: {
alignItems: 'center',
justifyContent: 'center',
},
placeholderText: {
color: COLORS.white,
fontWeight: '800',
},
});
20 changes: 8 additions & 12 deletions apps/mobile/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,18 @@ import * as Linking from 'expo-linking';

// DevCard API Configuration

const getDevServerHost = () => {
const constants = Constants as any;
const hostUri =
Constants.expoConfig?.hostUri ||
constants.manifest2?.extra?.expoGo?.debuggerHost ||
constants.manifest?.debuggerHost;
// Prefer explicit configuration via Expo/EAS extras. Fallback to sensible defaults
const extras = (Constants as any).manifest?.extra || (Constants as any).expoConfig?.extra;

return hostUri?.split(':')[0] || '10.155.14.65';
};
const DEV_API = extras?.API_BASE_URL || extras?.DEV_API_BASE_URL;
const DEV_APP = extras?.APP_URL;

export const API_BASE_URL = __DEV__
? `http://${getDevServerHost()}:3000`
: 'https://api.devcard.dev';
? DEV_API ?? `http://10.0.2.2:3000` // 10.0.2.2 is a common emulator host for Android
: extras?.API_BASE_URL ?? 'https://api.devcard.dev';

export const APP_URL = __DEV__
? `http://${getDevServerHost()}:5173`
: 'https://devcard.dev';
? DEV_APP ?? `http://localhost:5173`
: extras?.APP_URL ?? 'https://devcard.dev';

export const OAUTH_REDIRECT_URI = Linking.createURL('oauth/callback');
20 changes: 5 additions & 15 deletions apps/mobile/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { API_BASE_URL } from '../config';
import { get } from '../services/api';

interface User {
id: string;
Expand Down Expand Up @@ -41,13 +41,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(newToken);
// TODO: Save token to secure storage
try {
const res = await fetch(`${API_BASE_URL}/api/profiles/me`, {
headers: { Authorization: `Bearer ${newToken}` },
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
}
const userData = await get<any>('/api/profiles/me', newToken).catch(() => null);
if (userData) setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
}
Expand All @@ -62,13 +57,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const refreshUser = async () => {
if (!token) return;
try {
const res = await fetch(`${API_BASE_URL}/api/profiles/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
}
const userData = await get<any>('/api/profiles/me', token).catch(() => null);
if (userData) setUser(userData);
} catch (error) {
console.error('Failed to refresh user:', error);
}
Expand Down
14 changes: 9 additions & 5 deletions apps/mobile/src/navigation/MainTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ function TabIcon({ name, focused }: { name: string; focused: boolean }) {
);
}

function ScanButton() {
return (
<View style={styles.scanButton}>
<Text style={styles.scanEmoji}>📷</Text>
</View>
);
}

// ─── Tab Navigator ───

const Tab = createBottomTabNavigator<MainTabsParamList>();
Expand All @@ -87,11 +95,7 @@ function TabNavigator() {
component={ScanScreen}
options={{
tabBarLabel: '',
tabBarIcon: () => (
<View style={styles.scanButton}>
<Text style={styles.scanEmoji}>📷</Text>
</View>
),
tabBarIcon: () => <ScanButton />,
}}
/>
<Tab.Screen name="Cards" component={CardsScreen} />
Expand Down
56 changes: 21 additions & 35 deletions apps/mobile/src/screens/CardsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useFocusEffect } from '@react-navigation/native';
import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens';
import { useAuth } from '../context/AuthContext';
import { PLATFORMS } from '@devcard/shared';
import { API_BASE_URL } from '../config';
import { get, post, del, put } from '../services/api';
import { EmptyState } from '../components/EmptyState';
import { Skeleton } from '../components/Skeleton';

Expand Down Expand Up @@ -46,19 +46,12 @@ export default function CardsScreen() {
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const [cardsRes, profileRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/cards`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`${API_BASE_URL}/api/profiles/me`, {
headers: { Authorization: `Bearer ${token}` },
}),
const [cardsData, profileData] = await Promise.all([
get<Card[]>('/api/cards', token).catch(() => []),
get<any>('/api/profiles/me', token).catch(() => null),
]);
if (cardsRes.ok) setCards(await cardsRes.json());
if (profileRes.ok) {
const data = await profileRes.json();
setAllLinks(data.platformLinks || []);
}
setCards(cardsData || []);
setAllLinks(profileData?.platformLinks || []);
} catch (error) {
console.error('Failed to fetch:', error);
} finally {
Expand All @@ -84,20 +77,11 @@ export default function CardsScreen() {
return;
}
try {
const res = await fetch(`${API_BASE_URL}/api/cards`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title: newTitle.trim(), linkIds: selectedLinkIds }),
});
if (res.ok) {
setShowCreate(false);
setNewTitle('');
setSelectedLinkIds([]);
fetchData();
}
await post('/api/cards', { title: newTitle.trim(), linkIds: selectedLinkIds }, token);
setShowCreate(false);
setNewTitle('');
setSelectedLinkIds([]);
fetchData();
} catch {
Alert.alert('Error', 'Failed to create card');
}
Expand All @@ -110,21 +94,23 @@ export default function CardsScreen() {
text: 'Delete',
style: 'destructive',
onPress: async () => {
await fetch(`${API_BASE_URL}/api/cards/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
try {
await del(`/api/cards/${id}`, undefined, token);
} catch {
// ignore
}
fetchData();
},
},
]);
};

const setDefault = async (id: string) => {
await fetch(`${API_BASE_URL}/api/cards/${id}/default`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
});
try {
await put(`/api/cards/${id}/default`, undefined, token);
} catch {
// ignore
}
fetchData();
};

Expand Down
21 changes: 5 additions & 16 deletions apps/mobile/src/screens/ConnectPlatformsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens';
import { useAuth } from '../context/AuthContext';
import { API_BASE_URL } from '../config';
import { get, del } from '../services/api';
import { LoadingPlaceholder } from '../components/LoadingPlaceholder';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/MainTabs';
Expand All @@ -28,13 +29,8 @@ export const ConnectPlatformsScreen: React.FC<Props> = ({ navigation: _navigatio
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/connect/status`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
setConnectedPlatforms(data.connectedPlatforms || []);
}
const data = await get<any>('/api/connect/status', token).catch(() => null);
setConnectedPlatforms(data?.connectedPlatforms || []);
} catch (error) {
console.error('Failed to fetch connected platforms', error);
} finally {
Expand Down Expand Up @@ -79,15 +75,8 @@ export const ConnectPlatformsScreen: React.FC<Props> = ({ navigation: _navigatio
onPress: async () => {
try {
if (!token) return;
const response = await fetch(`${API_BASE_URL}/api/connect/${platform}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
fetchConnections();
} else {
Alert.alert('Error', 'Failed to disconnect');
}
await del(`/api/connect/${platform}`, undefined, token);
fetchConnections();
} catch {
Alert.alert('Error', 'Failed to disconnect');
}
Expand Down
Loading