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
202 changes: 175 additions & 27 deletions app/(app)/(tabs)/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
* UI/UX design by Sandith Hewage (Y STEM and Chess)
*/

import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';

import { theme } from '@/design/theme';
Expand All @@ -16,6 +23,10 @@ import {
} from '@/design/tokens';
import { useSession } from '@/providers/SessionProvider';

import { ProfileError } from '@/features/profile/api/profile.errors';
import type { Profile } from '@/features/profile/api/profile.types';
import { useProfile } from '@/features/profile/hooks/useProfile';

type Badge = {
id: string;
emoji: string;
Expand Down Expand Up @@ -132,12 +143,8 @@ const MENU_SECTIONS: { title: string; items: MenuItem[] }[] = [
];

export default function ProfileRoute() {
const { session, signOut } = useSession();
const displayName =
session.status === 'authenticated' ? session.user.displayName : 'Student';
const role =
session.status === 'authenticated' ? session.user.role : 'student';
const initial = displayName.charAt(0).toUpperCase();
const { signOut } = useSession();
const { data: profile, isLoading, error, refetch, isFetching } = useProfile();
const earnedCount = BADGES.filter((b) => b.earned).length;

return (
Expand All @@ -147,27 +154,23 @@ export default function ProfileRoute() {
showsVerticalScrollIndicator={false}
>
{/* ── Hero ───────────────────────────────── */}
<View style={s.hero}>
<View style={s.avatarRing}>
<View style={s.avatar}>
<Text style={s.avatarText}>{initial}</Text>
</View>
</View>
<Text style={s.displayName}>{displayName}</Text>
<View style={s.roleBadge}>
<Text style={s.roleIcon}>{role === 'mentor' ? '🎓' : '🎒'}</Text>
<Text style={s.roleText}>
{role === 'mentor' ? 'Mentor' : 'Student'}
</Text>
{isLoading ? (
<View style={s.statusBox}>
<ActivityIndicator accessibilityLabel="Loading profile" />
<Text style={s.statusText}>Loading your profile…</Text>
</View>
<View style={s.xpRow}>
<View style={s.xpPill}>
<Text style={s.xpText}>250 XP</Text>
</View>
<Text style={s.xpSep}>·</Text>
<Text style={s.xpLevel}>Level 3</Text>
</View>
</View>
) : profile ? (
<>
<ProfileHero profile={profile} />
<ProfileDetails profile={profile} />
</>
) : (
<ProfileLoadError
error={error}
retrying={isFetching}
onRetry={() => refetch()}
/>
)}

{/* ── Stats ──────────────────────────────── */}
<View style={s.statsCard}>
Expand Down Expand Up @@ -273,6 +276,103 @@ export default function ProfileRoute() {
);
}

function ProfileHero({ profile }: { profile: Profile }) {
const name = profile.fullName ?? profile.username;
const initial = name.charAt(0).toUpperCase();
const isMentor = profile.role === 'mentor';

return (
<View style={s.hero}>
<View style={s.avatarRing}>
<View style={s.avatar}>
<Text style={s.avatarText}>{initial}</Text>
</View>
</View>
<Text style={s.displayName}>{name}</Text>
<View style={s.roleBadge}>
<Text style={s.roleIcon}>{isMentor ? '🎓' : '🎒'}</Text>
<Text style={s.roleText}>{isMentor ? 'Mentor' : 'Student'}</Text>
</View>
<View style={s.xpRow}>
<View style={s.xpPill}>
<Text style={s.xpText}>250 XP</Text>
</View>
<Text style={s.xpSep}>·</Text>
<Text style={s.xpLevel}>Level 3</Text>
</View>
</View>
);
}

function ProfileDetails({ profile }: { profile: Profile }) {
const rows = [
{ label: 'Username', value: profile.username },
{ label: 'Role', value: profile.role === 'mentor' ? 'Mentor' : 'Student' },
{ label: 'School', value: profile.school },
{ label: 'Grade level', value: profile.gradeLevel },
{ label: 'Location', value: profile.location, hideWhenEmpty: true },
].filter((r) => r.value || !r.hideWhenEmpty);

return (
<View style={s.section}>
<Text style={s.menuSectionTitle}>Profile</Text>
<View style={s.menuCard}>
{rows.map((row, i) => (
<View
key={row.label}
style={[s.detailRow, i < rows.length - 1 && s.menuRowBorder]}
>
<Text style={s.detailLabel}>{row.label}</Text>
<Text style={row.value ? s.detailValue : s.detailValueMissing}>
{row.value ?? 'Not provided'}
</Text>
</View>
))}
</View>
</View>
);
}

function ProfileLoadError({
error,
retrying,
onRetry,
}: {
error: unknown;
retrying: boolean;
onRetry: () => void;
}) {
const kind = error instanceof ProfileError ? error.kind : 'network';
const message =
kind === 'session-expired'
? 'Your session has expired. Please sign in again.'
: kind === 'invalid-data'
? "We couldn't read your profile information."
: "We couldn't load your profile. Please try again.";

return (
<View style={s.statusBox} accessibilityRole="alert">
<Text style={s.statusText}>{message}</Text>
{kind !== 'session-expired' && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Try again"
disabled={retrying}
onPress={onRetry}
style={({ pressed }) => [
s.retryBtn,
(pressed || retrying) && s.retryBtnPressed,
]}
>
<Text style={s.retryText}>
{retrying ? 'Retrying…' : 'Try again'}
</Text>
</Pressable>
)}
</View>
);
}

const SHADOW = {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
Expand Down Expand Up @@ -480,4 +580,52 @@ const s = StyleSheet.create({
fontWeight: fontWeights.semibold,
color: palette.error,
},

/* Profile status + details */
statusBox: {
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.xl,
},
statusText: {
fontSize: fontSizes.label,
color: palette.muted,
textAlign: 'center',
},
retryBtn: {
backgroundColor: palette.brandGreen,
borderRadius: radii.lg,
minHeight: 48,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
},
retryBtnPressed: { opacity: 0.7 },
retryText: {
fontSize: fontSizes.label,
fontWeight: fontWeights.semibold,
color: palette.white,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
},
detailLabel: { fontSize: fontSizes.label, color: palette.muted },
detailValue: {
flexShrink: 1,
fontSize: fontSizes.label,
fontWeight: fontWeights.semibold,
color: palette.ink,
textAlign: 'right',
},
detailValueMissing: {
flexShrink: 1,
fontSize: fontSizes.label,
color: palette.muted,
textAlign: 'right',
},
});
9 changes: 9 additions & 0 deletions src/features/profile/api/ApiProfileGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ProfileGateway } from './profile.gateway';

export function createApiProfileGateway(): ProfileGateway {
return {
async getProfile() {
throw new Error('ApiProfileGateway is not connected yet');
},
};
}
66 changes: 66 additions & 0 deletions src/features/profile/api/fakeProfileGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ProfileError } from './profile.errors';
import { ProfileGateway } from './profile.gateway';
import { parseProfile } from './profile.mapper';

export type FakeScenario =
| 'student'
| 'mentor'
| 'missing-data'
| 'invalid-data'
| 'failure'
| 'session-expired';

const samples = {
student: {
username: 'sample.student',
role: 'student',
first_name: 'Sample',
last_name: 'Student',
email: 'sample.student@example.invalid',
zip_code: '00000',
gender: 'Prefer not to say',
grade_level: '10',
country: 'Sampleland',
state: 'Sample State',
school: 'Sample High School',
},
mentor: {
username: 'sample.mentor',
role: 'mentor',
first_name: 'Sample',
last_name: 'Mentor',
email: 'sample.mentor@example.invalid',
country: 'Sampleland',
state: 'Sample State',
school: 'Sample University',
},
'missing-data': { username: 'sample.minimal', role: 'student' },
'invalid-data': { username: 123, role: 'wizard' },
};

export function createFakeProfileGateway(
opts: {
scenario?: FakeScenario;
delayMs?: number;
failTimes?: number;
} = {},
): ProfileGateway {
const { scenario = 'student', delayMs = 500, failTimes = 0 } = opts;
let failuresLeft = failTimes;
const wait = () => new Promise((r) => setTimeout(r, delayMs));

return {
async getProfile() {
await wait();
if (failuresLeft > 0) {
failuresLeft -= 1;
throw new ProfileError('network', 'Fake network failure');
}
if (scenario === 'failure')
throw new ProfileError('network', 'Fake network failure');
if (scenario === 'session-expired')
throw new ProfileError('session-expired', 'Fake expired session');
return parseProfile(samples[scenario]);
},
};
}
6 changes: 6 additions & 0 deletions src/features/profile/api/gatewayProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ProfileGateway } from './profile.gateway';
import { createFakeProfileGateway } from './fakeProfileGateway';

export const profileGateway: ProfileGateway = createFakeProfileGateway({
scenario: 'student',
});
11 changes: 11 additions & 0 deletions src/features/profile/api/profile.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type ProfileErrorKind = 'network' | 'invalid-data' | 'session-expired';

export class ProfileError extends Error {
constructor(
public kind: ProfileErrorKind,
message: string,
) {
super(message);
this.name = 'ProfileError';
}
}
5 changes: 5 additions & 0 deletions src/features/profile/api/profile.gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Profile } from './profile.types';

export interface ProfileGateway {
getProfile(): Promise<Profile>;
}
40 changes: 40 additions & 0 deletions src/features/profile/api/profile.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ProfileResponseSchema, type ProfileResponse } from './profile.schema';
import { ProfileError } from './profile.errors';
import type { Profile } from './profile.types';

const clean = (v?: string | null) => (v?.trim() ? v.trim() : null);
const join = (parts: (string | null)[], sep: string) =>
parts.filter(Boolean).join(sep) || null;

export function mapProfile(r: ProfileResponse): Profile {
const firstName = clean(r.first_name);
const lastName = clean(r.last_name);
const state = clean(r.state);
const country = clean(r.country);
return {
username: r.username,
role: r.role,
firstName,
lastName,
fullName: join([firstName, lastName], ' '),
email: clean(r.email),
zipCode: clean(r.zip_code),
gender: clean(r.gender),
gradeLevel: clean(r.grade_level),
country,
state,
school: clean(r.school),
location: join([state, country], ', '),
};
}

export function parseProfile(raw: unknown): Profile {
const result = ProfileResponseSchema.safeParse(raw);
if (!result.success) {
throw new ProfileError(
'invalid-data',
'Profile data was not in the expected format',
);
}
return mapProfile(result.data);
}
Loading
Loading