Skip to content
Open
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
16 changes: 9 additions & 7 deletions apps/mobile/src/components/CardPickerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
BottomSheetScrollView,
} from '@gorhom/bottom-sheet';
import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens';
import { EmptyState } from './EmptyState';

type Props = {
cards: Card[];
Expand Down Expand Up @@ -50,7 +51,10 @@ const CardPickerSheet = React.forwardRef<BottomSheetModal, Props>(

{cards.length === 0 ? (
<View style={styles.noCards}>
<Text style={styles.noCardsText}>No cards yet</Text>
<EmptyState
title="No cards yet"
description="Create a card before switching the QR target."
/>
</View>
) : (
cards.map(card => {
Expand Down Expand Up @@ -144,12 +148,10 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
noCards: {
alignItems: 'center',
paddingVertical: SPACING.lg,
},
noCardsText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textMuted,
backgroundColor: COLORS.bgCard,
borderRadius: BORDER_RADIUS.md,
borderWidth: 1,
borderColor: COLORS.border,
},
cardRow: {
flexDirection: 'row',
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { COLORS, SPACING, FONT_SIZE } from '../theme/tokens';

interface EmptyStateProps {
emoji?: string;
title: string;
description?: string;
}

export const EmptyState: React.FC<EmptyStateProps> = ({ emoji, title, description }) => (
<View style={styles.container}>
{emoji ? <Text style={styles.emoji}>{emoji}</Text> : null}
<Text style={styles.title}>{title}</Text>
{description ? <Text style={styles.description}>{description}</Text> : null}
</View>
);

const styles = StyleSheet.create({
container: {
alignItems: 'center',
paddingVertical: SPACING.xxl,
paddingHorizontal: SPACING.lg,
},
emoji: {
fontSize: 48,
marginBottom: SPACING.md,
},
title: {
fontSize: FONT_SIZE.lg,
fontWeight: '700',
color: COLORS.textPrimary,
textAlign: 'center',
},
description: {
marginTop: SPACING.xs,
fontSize: FONT_SIZE.sm,
color: COLORS.textMuted,
textAlign: 'center',
lineHeight: 20,
},
});
44 changes: 44 additions & 0 deletions apps/mobile/src/components/LoadingPlaceholder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Skeleton } from './Skeleton';
import { SPACING, BORDER_RADIUS, COLORS } from '../theme/tokens';

interface LoadingPlaceholderProps {
rows?: number;
}

export const LoadingPlaceholder: React.FC<LoadingPlaceholderProps> = ({ rows = 3 }) => (
<View style={styles.container}>
{Array.from({ length: rows }).map((_, index) => (
<View key={index} style={styles.item}>
<Skeleton width={52} height={52} borderRadius={16} />
<View style={styles.textColumn}>
<Skeleton width="65%" height={16} />
<Skeleton width="45%" height={14} style={styles.secondLine} />
</View>
</View>
))}
</View>
);

const styles = StyleSheet.create({
container: {
padding: SPACING.lg,
},
item: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING.md,
marginBottom: SPACING.md,
backgroundColor: COLORS.bgCard,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.md,
},
textColumn: {
flex: 1,
justifyContent: 'center',
},
secondLine: {
marginTop: SPACING.xs,
},
});
6 changes: 3 additions & 3 deletions apps/mobile/src/components/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React, { useEffect, useRef } from 'react';
import { View, Animated, StyleSheet, ViewStyle } from 'react-native';
import { Animated, StyleSheet, ViewStyle } from 'react-native';
import { COLORS } from '../theme/tokens';

interface SkeletonProps {
width?: number | string;
height?: number | string;
width?: ViewStyle['width'];
height?: ViewStyle['height'];
borderRadius?: number;
style?: ViewStyle;
}
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/navigation/MainTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import ScanScreen from '../screens/ScanScreen';
import DevCardViewScreen from '../screens/DevCardViewScreen';
import WebViewScreen from '../screens/WebViewScreen';

import ConnectPlatformsScreen from '../screens/ConnectPlatformsScreen';
import ViewsScreen from '../screens/ViewsScreen';
import { ConnectPlatformsScreen } from '../screens/ConnectPlatformsScreen';
import { ViewsScreen } from '../screens/ViewsScreen';

// ─── Types ───

Expand Down Expand Up @@ -76,7 +76,7 @@ function TabNavigator() {
component={ScanScreen}
options={{
tabBarLabel: '',
tabBarIcon: ({ focused }) => (
tabBarIcon: () => (
<View style={styles.scanButton}>
<Text style={styles.scanEmoji}>📷</Text>
</View>
Expand Down
57 changes: 49 additions & 8 deletions apps/mobile/src/screens/CardsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tok
import { useAuth } from '../context/AuthContext';
import { PLATFORMS } from '@devcard/shared';
import { API_BASE_URL } from '../config';
import { EmptyState } from '../components/EmptyState';
import { Skeleton } from '../components/Skeleton';

interface PlatformLink {
id: string;
Expand All @@ -39,8 +41,10 @@ export default function CardsScreen() {
const [newTitle, setNewTitle] = useState('');
const [selectedLinkIds, setSelectedLinkIds] = useState<string[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);

const fetchData = useCallback(async () => {
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const [cardsRes, profileRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/cards`, {
Expand All @@ -59,6 +63,7 @@ export default function CardsScreen() {
console.error('Failed to fetch:', err);
} finally {
setRefreshing(false);
if (showLoading) setLoading(false);
}
}, [token]);

Expand All @@ -70,7 +75,7 @@ export default function CardsScreen() {

const onRefresh = () => {
setRefreshing(true);
fetchData();
fetchData(false);
};

const createCard = async () => {
Expand All @@ -93,7 +98,7 @@ export default function CardsScreen() {
setSelectedLinkIds([]);
fetchData();
}
} catch (err) {
} catch {
Alert.alert('Error', 'Failed to create card');
}
};
Expand Down Expand Up @@ -131,6 +136,29 @@ export default function CardsScreen() {
);
};

if (loading) {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={COLORS.bgPrimary} />
<View style={styles.header}>
<Skeleton width={180} height={34} borderRadius={12} />
<Skeleton width={100} height={36} borderRadius={18} />
</View>
<View style={styles.loadingList}>
{[1, 2].map((item) => (
<View key={item} style={styles.loadingCard}>
<Skeleton width="100%" height={180} borderRadius={20} />
<View style={styles.loadingActionRow}>
<Skeleton width={120} height={36} borderRadius={16} />
<Skeleton width={80} height={30} borderRadius={16} />
</View>
</View>
))}
</View>
</SafeAreaView>
);
}

return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={COLORS.bgPrimary} />
Expand Down Expand Up @@ -211,11 +239,11 @@ export default function CardsScreen() {
</View>
)}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyEmoji}>💳</Text>
<Text style={styles.emptyText}>No cards yet</Text>
<Text style={styles.emptySubtext}>Create context cards for different situations</Text>
</View>
<EmptyState
emoji="💳"
title="No cards yet"
description="Create context cards for different situations"
/>
}
/>

Expand Down Expand Up @@ -283,6 +311,19 @@ const styles = StyleSheet.create({
emptyEmoji: { fontSize: 48, marginBottom: SPACING.md },
emptyText: { fontSize: FONT_SIZE.lg, fontWeight: '600', color: COLORS.textPrimary },
emptySubtext: { fontSize: FONT_SIZE.sm, color: COLORS.textMuted, marginTop: SPACING.xs },
loadingList: { paddingHorizontal: SPACING.lg },
loadingCard: {
borderRadius: BORDER_RADIUS.lg,
backgroundColor: COLORS.bgCard,
padding: SPACING.md,
marginBottom: SPACING.lg,
},
loadingActionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: SPACING.md,
},
modalOverlay: { flex: 1, backgroundColor: COLORS.overlay, justifyContent: 'flex-end' },
modalContent: {
backgroundColor: COLORS.bgSecondary, borderTopLeftRadius: BORDER_RADIUS.xl,
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/src/screens/ConnectPlatformsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, Alert, Linking } from 'react-native';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
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 { LoadingPlaceholder } from '../components/LoadingPlaceholder';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/MainTabs';

Expand Down Expand Up @@ -136,9 +137,9 @@ export const ConnectPlatformsScreen: React.FC<Props> = ({ navigation: _navigatio

if (loading) {
return (
<View style={[styles.container, styles.center]}>
<ActivityIndicator size="large" color={COLORS.primary} />
</View>
<SafeAreaView style={styles.container}>
<LoadingPlaceholder rows={3} />
</SafeAreaView>
);
}

Expand Down
30 changes: 22 additions & 8 deletions apps/mobile/src/screens/DevCardViewScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
Expand All @@ -15,6 +15,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens';
import { Skeleton } from '../components/Skeleton';
import { EmptyState } from '../components/EmptyState';
import { PLATFORMS, getProfileUrl, getWebViewUrl } from '@devcard/shared';
import { API_BASE_URL } from '../config';
import { useAuth } from '../context/AuthContext';
Expand Down Expand Up @@ -57,11 +58,7 @@ export default function DevCardViewScreen({ navigation, route }: Props) {
const [loading, setLoading] = useState(true);
const [followStates, setFollowStates] = useState<FollowState>({});

useEffect(() => {
fetchProfile();
}, [username]);

const fetchProfile = async () => {
const fetchProfile = useCallback(async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/u/${username}`);
if (res.ok) {
Expand All @@ -72,7 +69,11 @@ export default function DevCardViewScreen({ navigation, route }: Props) {
} finally {
setLoading(false);
}
};
}, [username]);

useEffect(() => {
fetchProfile();
}, [fetchProfile]);

// ─── Hybrid Follow Engine ───

Expand Down Expand Up @@ -287,7 +288,14 @@ export default function DevCardViewScreen({ navigation, route }: Props) {
{/* Platform Tiles Section */}
<View style={styles.tilesSection}>
<Text style={styles.tilesLabel}>Digital Touchpoints</Text>
{profile.links.map(link => {
{profile.links.length === 0 ? (
<View style={styles.emptyLinksCard}>
<EmptyState
title="No links shared yet"
description="This DevCard profile does not have any platform links available."
/>
</View>
) : profile.links.map(link => {
const platform = PLATFORMS[link.platform];
const state = followStates[link.id] || 'idle';
return (
Expand Down Expand Up @@ -493,6 +501,12 @@ const styles = StyleSheet.create({
tileActionLoading: { backgroundColor: COLORS.primaryDark },
tileActionText: { color: COLORS.white, fontWeight: '700', fontSize: FONT_SIZE.sm },
tileActionTextDone: {},
emptyLinksCard: {
backgroundColor: COLORS.bgCard,
borderRadius: BORDER_RADIUS.md,
borderWidth: 1,
borderColor: COLORS.border,
},
errorState: { flex: 1, alignItems: 'center', justifyContent: 'center' },
errorEmoji: { fontSize: 48, marginBottom: SPACING.md },
errorText: { fontSize: FONT_SIZE.lg, color: COLORS.textPrimary, fontWeight: '600' },
Expand Down
Loading