From e7832aaceebdf4759bce7b5c449cb92c36dff608 Mon Sep 17 00:00:00 2001 From: jamunatg2006-sys Date: Sun, 17 May 2026 22:01:32 +0530 Subject: [PATCH 1/2] feat(mobile): add skeleton loading and empty-state UI for smoother frontend experience --- apps/mobile/src/components/EmptyState.tsx | 42 +++++++++++ .../src/components/LoadingPlaceholder.tsx | 44 ++++++++++++ apps/mobile/src/screens/CardsScreen.tsx | 55 ++++++++++++-- .../src/screens/ConnectPlatformsScreen.tsx | 9 +-- apps/mobile/src/screens/HomeScreen.tsx | 72 +++++++++++++++---- apps/mobile/src/screens/LinksScreen.tsx | 34 +++++++-- apps/mobile/src/screens/ViewsScreen.tsx | 20 +++--- 7 files changed, 237 insertions(+), 39 deletions(-) create mode 100644 apps/mobile/src/components/EmptyState.tsx create mode 100644 apps/mobile/src/components/LoadingPlaceholder.tsx diff --git a/apps/mobile/src/components/EmptyState.tsx b/apps/mobile/src/components/EmptyState.tsx new file mode 100644 index 0000000..d2db278 --- /dev/null +++ b/apps/mobile/src/components/EmptyState.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; + +interface EmptyStateProps { + emoji?: string; + title: string; + description?: string; +} + +export const EmptyState: React.FC = ({ emoji, title, description }) => ( + + {emoji ? {emoji} : null} + {title} + {description ? {description} : null} + +); + +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, + }, +}); diff --git a/apps/mobile/src/components/LoadingPlaceholder.tsx b/apps/mobile/src/components/LoadingPlaceholder.tsx new file mode 100644 index 0000000..22f5b21 --- /dev/null +++ b/apps/mobile/src/components/LoadingPlaceholder.tsx @@ -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 = ({ rows = 3 }) => ( + + {Array.from({ length: rows }).map((_, index) => ( + + + + + + + + ))} + +); + +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, + }, +}); diff --git a/apps/mobile/src/screens/CardsScreen.tsx b/apps/mobile/src/screens/CardsScreen.tsx index 023ceb4..821ab05 100644 --- a/apps/mobile/src/screens/CardsScreen.tsx +++ b/apps/mobile/src/screens/CardsScreen.tsx @@ -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; @@ -39,8 +41,10 @@ export default function CardsScreen() { const [newTitle, setNewTitle] = useState(''); const [selectedLinkIds, setSelectedLinkIds] = useState([]); 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`, { @@ -59,6 +63,7 @@ export default function CardsScreen() { console.error('Failed to fetch:', err); } finally { setRefreshing(false); + if (showLoading) setLoading(false); } }, [token]); @@ -70,7 +75,7 @@ export default function CardsScreen() { const onRefresh = () => { setRefreshing(true); - fetchData(); + fetchData(false); }; const createCard = async () => { @@ -131,6 +136,29 @@ export default function CardsScreen() { ); }; + if (loading) { + return ( + + + + + + + + {[1, 2].map((item) => ( + + + + + + + + ))} + + + ); + } + return ( @@ -211,11 +239,11 @@ export default function CardsScreen() { )} ListEmptyComponent={ - - 💳 - No cards yet - Create context cards for different situations - + } /> @@ -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, diff --git a/apps/mobile/src/screens/ConnectPlatformsScreen.tsx b/apps/mobile/src/screens/ConnectPlatformsScreen.tsx index 8b359ca..c4e0b9a 100644 --- a/apps/mobile/src/screens/ConnectPlatformsScreen.tsx +++ b/apps/mobile/src/screens/ConnectPlatformsScreen.tsx @@ -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'; @@ -136,9 +137,9 @@ export const ConnectPlatformsScreen: React.FC = ({ navigation: _navigatio if (loading) { return ( - - - + + + ); } diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index 80de203..838213a 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -10,6 +10,7 @@ import { Image, RefreshControl, } from 'react-native'; +import { Skeleton } from '../components/Skeleton'; import { SafeAreaView } from 'react-native-safe-area-context'; import QRCode from 'react-native-qrcode-svg'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens'; @@ -37,6 +38,7 @@ export default function HomeScreen({ navigation }: Props) { const [analytics, setAnalytics] = useState(null); const [showQR, setShowQR] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [loading, setLoading] = useState(true); const profileUrl = user?.defaultCardId ? `${APP_URL}/devcard/${user.defaultCardId}` @@ -47,6 +49,7 @@ export default function HomeScreen({ navigation }: Props) { }, []); const fetchData = async () => { + setLoading(true); try { const [profileRes, analyticsRes] = await Promise.all([ fetch(`${API_BASE_URL}/api/profiles/me`, { @@ -66,6 +69,8 @@ export default function HomeScreen({ navigation }: Props) { } } catch (err) { console.error('Failed to fetch dashboard data:', err); + } finally { + setLoading(false); } }; @@ -86,6 +91,21 @@ export default function HomeScreen({ navigation }: Props) { } }; + if (loading) { + return ( + + + + + + + + + + + ); + } + return ( @@ -135,20 +155,26 @@ export default function HomeScreen({ navigation }: Props) { {/* Platform Links Summary */} - {links.slice(0, 4).map(link => { - const platform = PLATFORMS[link.platform]; - return ( - - - {platform?.name || link.platform} - - - ); - })} - {links.length > 4 && ( - - +{links.length - 4} - + {links.length > 0 ? ( + <> + {links.slice(0, 4).map(link => { + const platform = PLATFORMS[link.platform]; + return ( + + + {platform?.name || link.platform} + + + ); + })} + {links.length > 4 && ( + + +{links.length - 4} + + )} + + ) : ( + No platform links added yet. Add links in the Links tab to populate your preview. )} @@ -299,4 +325,22 @@ const styles = StyleSheet.create({ statNumber: { fontSize: FONT_SIZE.xl, fontWeight: '800', color: COLORS.primary }, statLabel: { fontSize: FONT_SIZE.xs, color: COLORS.textMuted, marginTop: 4 }, statDivider: { width: 1, backgroundColor: COLORS.border }, + loadingRoot: { + flex: 1, + padding: SPACING.lg, + backgroundColor: COLORS.bgPrimary, + }, + loadingSpacer: { + marginTop: SPACING.sm, + }, + loadingSection: { + marginTop: SPACING.lg, + }, + emptyHint: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.sm, + lineHeight: 20, + marginTop: SPACING.sm, + maxWidth: '70%', + }, }); diff --git a/apps/mobile/src/screens/LinksScreen.tsx b/apps/mobile/src/screens/LinksScreen.tsx index daded55..d819e52 100644 --- a/apps/mobile/src/screens/LinksScreen.tsx +++ b/apps/mobile/src/screens/LinksScreen.tsx @@ -15,6 +15,8 @@ import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; import { useAuth } from '../context/AuthContext'; import { PLATFORMS, getAllPlatforms } from '@devcard/shared'; import { API_BASE_URL } from '../config'; +import { EmptyState } from '../components/EmptyState'; +import { LoadingPlaceholder } from '../components/LoadingPlaceholder'; import type { PlatformDef } from '@devcard/shared'; interface PlatformLink { @@ -31,8 +33,10 @@ export default function LinksScreen() { const [showAddModal, setShowAddModal] = useState(false); const [selectedPlatform, setSelectedPlatform] = useState(null); const [usernameInput, setUsernameInput] = useState(''); + const [loading, setLoading] = useState(true); const fetchLinks = useCallback(async () => { + setLoading(true); try { const res = await fetch(`${API_BASE_URL}/api/profiles/me`, { headers: { Authorization: `Bearer ${token}` }, @@ -43,6 +47,8 @@ export default function LinksScreen() { } } catch (err) { console.error('Failed to fetch links:', err); + } finally { + setLoading(false); } }, [token]); @@ -50,6 +56,15 @@ export default function LinksScreen() { fetchLinks(); }, [fetchLinks]); + if (loading) { + return ( + + + + + ); + } + const addLink = async () => { if (!selectedPlatform || !usernameInput.trim()) return; try { @@ -96,6 +111,15 @@ export default function LinksScreen() { ]); }; + if (loading) { + return ( + + + + + ); + } + return ( @@ -131,11 +155,11 @@ export default function LinksScreen() { ); }} ListEmptyComponent={ - - 🔗 - No links yet - Add your first platform link - + } /> diff --git a/apps/mobile/src/screens/ViewsScreen.tsx b/apps/mobile/src/screens/ViewsScreen.tsx index 24dc79e..a930e99 100644 --- a/apps/mobile/src/screens/ViewsScreen.tsx +++ b/apps/mobile/src/screens/ViewsScreen.tsx @@ -1,10 +1,12 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { View, Text, StyleSheet, FlatList, ActivityIndicator, Image } from 'react-native'; +import { View, Text, StyleSheet, FlatList, Image } 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 { EmptyState } from '../components/EmptyState'; +import { LoadingPlaceholder } from '../components/LoadingPlaceholder'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/MainTabs'; @@ -92,20 +94,20 @@ export const ViewsScreen: React.FC = () => { if (loading) { return ( - - - + + + ); } return ( {views.length === 0 ? ( - - - No Views Yet - Share your card or QR code to start collecting analytics. - + ) : ( Date: Sun, 17 May 2026 22:45:41 +0530 Subject: [PATCH 2/2] Add mobile loading skeleton and empty states --- .../mobile/src/components/CardPickerSheet.tsx | 16 +++++----- apps/mobile/src/components/EmptyState.tsx | 2 +- apps/mobile/src/components/Skeleton.tsx | 6 ++-- apps/mobile/src/navigation/MainTabs.tsx | 6 ++-- apps/mobile/src/screens/CardsScreen.tsx | 2 +- apps/mobile/src/screens/DevCardViewScreen.tsx | 30 ++++++++++++++----- apps/mobile/src/screens/HomeScreen.tsx | 14 ++++----- apps/mobile/src/screens/LinksScreen.tsx | 17 ++--------- apps/mobile/src/screens/ScanScreen.tsx | 20 ++++++++++--- apps/mobile/src/screens/SettingsScreen.tsx | 4 ++- apps/mobile/src/screens/WebViewScreen.tsx | 21 ++++++++++--- 11 files changed, 84 insertions(+), 54 deletions(-) diff --git a/apps/mobile/src/components/CardPickerSheet.tsx b/apps/mobile/src/components/CardPickerSheet.tsx index 44af9d9..7cbb12d 100644 --- a/apps/mobile/src/components/CardPickerSheet.tsx +++ b/apps/mobile/src/components/CardPickerSheet.tsx @@ -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[]; @@ -50,7 +51,10 @@ const CardPickerSheet = React.forwardRef( {cards.length === 0 ? ( - No cards yet + ) : ( cards.map(card => { @@ -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', diff --git a/apps/mobile/src/components/EmptyState.tsx b/apps/mobile/src/components/EmptyState.tsx index d2db278..2ad886d 100644 --- a/apps/mobile/src/components/EmptyState.tsx +++ b/apps/mobile/src/components/EmptyState.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; -import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; +import { COLORS, SPACING, FONT_SIZE } from '../theme/tokens'; interface EmptyStateProps { emoji?: string; diff --git a/apps/mobile/src/components/Skeleton.tsx b/apps/mobile/src/components/Skeleton.tsx index 23f52d2..31d8aaa 100644 --- a/apps/mobile/src/components/Skeleton.tsx +++ b/apps/mobile/src/components/Skeleton.tsx @@ -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; } diff --git a/apps/mobile/src/navigation/MainTabs.tsx b/apps/mobile/src/navigation/MainTabs.tsx index 11e4e9a..b3c86b4 100644 --- a/apps/mobile/src/navigation/MainTabs.tsx +++ b/apps/mobile/src/navigation/MainTabs.tsx @@ -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 ─── @@ -76,7 +76,7 @@ function TabNavigator() { component={ScanScreen} options={{ tabBarLabel: '', - tabBarIcon: ({ focused }) => ( + tabBarIcon: () => ( 📷 diff --git a/apps/mobile/src/screens/CardsScreen.tsx b/apps/mobile/src/screens/CardsScreen.tsx index 821ab05..8ca931f 100644 --- a/apps/mobile/src/screens/CardsScreen.tsx +++ b/apps/mobile/src/screens/CardsScreen.tsx @@ -98,7 +98,7 @@ export default function CardsScreen() { setSelectedLinkIds([]); fetchData(); } - } catch (err) { + } catch { Alert.alert('Error', 'Failed to create card'); } }; diff --git a/apps/mobile/src/screens/DevCardViewScreen.tsx b/apps/mobile/src/screens/DevCardViewScreen.tsx index 46cf951..259856e 100644 --- a/apps/mobile/src/screens/DevCardViewScreen.tsx +++ b/apps/mobile/src/screens/DevCardViewScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, @@ -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'; @@ -57,11 +58,7 @@ export default function DevCardViewScreen({ navigation, route }: Props) { const [loading, setLoading] = useState(true); const [followStates, setFollowStates] = useState({}); - useEffect(() => { - fetchProfile(); - }, [username]); - - const fetchProfile = async () => { + const fetchProfile = useCallback(async () => { try { const res = await fetch(`${API_BASE_URL}/api/u/${username}`); if (res.ok) { @@ -72,7 +69,11 @@ export default function DevCardViewScreen({ navigation, route }: Props) { } finally { setLoading(false); } - }; + }, [username]); + + useEffect(() => { + fetchProfile(); + }, [fetchProfile]); // ─── Hybrid Follow Engine ─── @@ -287,7 +288,14 @@ export default function DevCardViewScreen({ navigation, route }: Props) { {/* Platform Tiles Section */} Digital Touchpoints - {profile.links.map(link => { + {profile.links.length === 0 ? ( + + + + ) : profile.links.map(link => { const platform = PLATFORMS[link.platform]; const state = followStates[link.id] || 'idle'; return ( @@ -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' }, diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index 838213a..3910cdd 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, @@ -44,11 +44,7 @@ export default function HomeScreen({ navigation }: Props) { ? `${APP_URL}/devcard/${user.defaultCardId}` : `${APP_URL}/u/${user?.username}`; - useEffect(() => { - fetchData(); - }, []); - - const fetchData = async () => { + const fetchData = useCallback(async () => { setLoading(true); try { const [profileRes, analyticsRes] = await Promise.all([ @@ -72,7 +68,11 @@ export default function HomeScreen({ navigation }: Props) { } finally { setLoading(false); } - }; + }, [token]); + + useEffect(() => { + fetchData(); + }, [fetchData]); const onRefresh = async () => { setRefreshing(true); diff --git a/apps/mobile/src/screens/LinksScreen.tsx b/apps/mobile/src/screens/LinksScreen.tsx index d819e52..27158bb 100644 --- a/apps/mobile/src/screens/LinksScreen.tsx +++ b/apps/mobile/src/screens/LinksScreen.tsx @@ -56,15 +56,6 @@ export default function LinksScreen() { fetchLinks(); }, [fetchLinks]); - if (loading) { - return ( - - - - - ); - } - const addLink = async () => { if (!selectedPlatform || !usernameInput.trim()) return; try { @@ -85,7 +76,7 @@ export default function LinksScreen() { setUsernameInput(''); fetchLinks(); } - } catch (err) { + } catch { Alert.alert('Error', 'Failed to add link'); } }; @@ -103,7 +94,7 @@ export default function LinksScreen() { headers: { Authorization: `Bearer ${token}` }, }); fetchLinks(); - } catch (err) { + } catch { Alert.alert('Error', 'Failed to remove link'); } }, @@ -242,10 +233,6 @@ const styles = StyleSheet.create({ username: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, marginTop: 2 }, deleteBtn: { padding: SPACING.sm }, deleteBtnText: { color: COLORS.error, fontSize: FONT_SIZE.md, fontWeight: '700' }, - empty: { alignItems: 'center', paddingVertical: SPACING.xxl }, - 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 }, modalOverlay: { flex: 1, backgroundColor: COLORS.overlay, justifyContent: 'flex-end', diff --git a/apps/mobile/src/screens/ScanScreen.tsx b/apps/mobile/src/screens/ScanScreen.tsx index b864cdd..b89d70b 100644 --- a/apps/mobile/src/screens/ScanScreen.tsx +++ b/apps/mobile/src/screens/ScanScreen.tsx @@ -7,13 +7,14 @@ import { TextInput, StatusBar, Alert, - ActivityIndicator, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; import QRCode from 'react-native-qrcode-svg'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; +import { EmptyState } from '../components/EmptyState'; +import { Skeleton } from '../components/Skeleton'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/MainTabs'; import type { BottomSheetModal } from '@gorhom/bottom-sheet'; @@ -182,7 +183,10 @@ export default function ScanScreen({ navigation }: Props) { {loadingCards ? ( - + + + + ) : qrUrl ? ( ) : ( - Create a card to generate a QR + )} {!!qrUrl && ( @@ -290,7 +297,12 @@ const styles = StyleSheet.create({ minHeight: 220, }, qrHint: { textAlign: 'center', color: COLORS.textMuted, fontSize: FONT_SIZE.sm }, - qrPlaceholder: { color: COLORS.textMuted, fontSize: FONT_SIZE.sm }, + qrSkeleton: { + alignItems: 'center', + }, + qrSkeletonText: { + marginTop: SPACING.md, + }, cameraArea: { flex: 1, maxHeight: 350, backgroundColor: COLORS.bgCard, borderRadius: BORDER_RADIUS.lg, diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 7d282a6..26e09e0 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -11,11 +11,13 @@ import { Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; import { useAuth } from '../context/AuthContext'; import { API_BASE_URL } from '../config'; export default function SettingsScreen() { + const navigation = useNavigation(); const { user, token, refreshUser, logout } = useAuth(); const [displayName, setDisplayName] = useState(user?.displayName || ''); const [bio, setBio] = useState(user?.bio || ''); @@ -47,7 +49,7 @@ export default function SettingsScreen() { } else { Alert.alert('Error', 'Failed to update profile'); } - } catch (err) { + } catch { Alert.alert('Error', 'Something went wrong'); } finally { setSaving(false); diff --git a/apps/mobile/src/screens/WebViewScreen.tsx b/apps/mobile/src/screens/WebViewScreen.tsx index 03806d8..39435b7 100644 --- a/apps/mobile/src/screens/WebViewScreen.tsx +++ b/apps/mobile/src/screens/WebViewScreen.tsx @@ -9,6 +9,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { WebView } from 'react-native-webview'; import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; +import { Skeleton } from '../components/Skeleton'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RouteProp } from '@react-navigation/native'; import type { RootStackParamList } from '../navigation/MainTabs'; @@ -30,7 +31,7 @@ type Props = { * - Clean close button to dismiss */ export default function WebViewScreen({ navigation, route }: Props) { - const { platform, profileUrl, displayName } = route.params; + const { profileUrl, displayName } = route.params; const webViewRef = useRef(null); return ( @@ -66,10 +67,14 @@ export default function WebViewScreen({ navigation, route }: Props) { startInLoadingState={true} renderLoading={() => ( + + + + Loading {displayName}... )} - onNavigationStateChange={(navState) => { + onNavigationStateChange={(_navState) => { // If user navigates away from the profile page, // they likely completed the action // We could auto-close here in the future @@ -104,8 +109,16 @@ const styles = StyleSheet.create({ bannerText: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, textAlign: 'center' }, bannerBold: { fontWeight: '700', color: COLORS.primary }, webview: { flex: 1 }, - loading: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: COLORS.bgPrimary }, - loadingText: { color: COLORS.textMuted, fontSize: FONT_SIZE.md }, + loading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: COLORS.bgPrimary, + padding: SPACING.lg, + }, + loadingBlock: { marginTop: SPACING.lg }, + loadingLine: { marginTop: SPACING.md }, + loadingText: { color: COLORS.textMuted, fontSize: FONT_SIZE.sm, marginTop: SPACING.lg }, footer: { padding: SPACING.md, borderTopWidth: 1, borderTopColor: COLORS.border, },