Skip to content

Commit c948cce

Browse files
authored
Merge pull request #9 from Retsomm/dev
implement unit and lesson screens
2 parents a0c5627 + 3e8ac8e commit c948cce

12 files changed

Lines changed: 1467 additions & 13 deletions

app/(tabs)/ai-teacher.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,29 @@
1-
import { TabPlaceholderScreen } from "@/components/tab-placeholder-screen";
1+
import { AudioTeacherSession } from "@/components/audio-teacher-session";
2+
import { defaultLanguageId } from "@/data/languages";
3+
import { lessons } from "@/data/lessons";
4+
import { useLanguageStore } from "@/store/UseLanguageStore";
5+
import type { Lesson } from "@/types/learning";
6+
import { useMemo } from "react";
27

38
export default function AiTeacherScreen() {
9+
const selectedLanguageId = useLanguageStore((state) => state.selectedLanguageId);
10+
11+
const lesson = useMemo(
12+
() => getTeacherLesson(selectedLanguageId ?? defaultLanguageId),
13+
[selectedLanguageId],
14+
);
15+
16+
return <AudioTeacherSession activeTabLabel="AI Teacher" lesson={lesson} />;
17+
}
18+
19+
function getTeacherLesson(languageId: string): Lesson | undefined {
20+
const languageLessons = lessons
21+
.filter((lesson) => lesson.languageId === languageId)
22+
.sort((a, b) => a.order - b.order);
23+
424
return (
5-
<TabPlaceholderScreen
6-
title="AI Teacher"
7-
subtitle="Video teacher sessions will live here."
8-
/>
25+
languageLessons.find(
26+
(lesson) => lesson.mode === "ai-teacher" || lesson.mode === "audio",
27+
) ?? languageLessons[0]
928
);
1029
}

app/(tabs)/learn.tsx

Lines changed: 281 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,286 @@
1-
import { TabPlaceholderScreen } from "@/components/tab-placeholder-screen";
1+
import { LessonCard, type LessonStatus } from "@/components/lesson-card";
2+
import { images } from "@/constants/images";
3+
import { defaultLanguageId, languages } from "@/data/languages";
4+
import { lessons } from "@/data/lessons";
5+
import { units } from "@/data/units";
6+
import { useLanguageStore } from "@/store/UseLanguageStore";
7+
import type { Lesson } from "@/types/learning";
8+
import { Ionicons } from "@expo/vector-icons";
9+
import { router } from "expo-router";
10+
import { useMemo, useState } from "react";
11+
import {
12+
ImageBackground,
13+
Pressable,
14+
ScrollView,
15+
StyleSheet,
16+
Text,
17+
View,
18+
useWindowDimensions,
19+
} from "react-native";
20+
import { useSafeAreaInsets } from "react-native-safe-area-context";
21+
22+
type LearnTab = "lessons" | "practice";
23+
24+
const lessonArt = [
25+
images.lessonArt.completed,
26+
images.lessonArt.practice,
27+
images.lessonArt.inProgress,
28+
images.lessonArt.cafe,
29+
images.lessonArt.notStarted,
30+
images.mascotWelcome,
31+
];
232

333
export default function LearnScreen() {
34+
const insets = useSafeAreaInsets();
35+
const { width } = useWindowDimensions();
36+
const selectedLanguageId = useLanguageStore((state) => state.selectedLanguageId);
37+
const [activeTab, setActiveTab] = useState<LearnTab>("lessons");
38+
const heroHeight = width * (272 / 546);
39+
40+
const selectedLanguage =
41+
languages.find((language) => language.id === selectedLanguageId) ??
42+
languages.find((language) => language.id === defaultLanguageId) ??
43+
languages[0];
44+
45+
const languageUnits = useMemo(
46+
() =>
47+
units
48+
.filter((unit) => unit.languageId === selectedLanguage.id)
49+
.sort((a, b) => a.order - b.order),
50+
[selectedLanguage.id],
51+
);
52+
53+
const currentUnit = languageUnits[0];
54+
const unitLessons = useMemo(() => {
55+
const lessonsForLanguage = lessons
56+
.filter((lesson) => lesson.languageId === selectedLanguage.id)
57+
.sort((a, b) => a.order - b.order);
58+
59+
if (!currentUnit) {
60+
return lessonsForLanguage;
61+
}
62+
63+
const lessonMap = new Map(
64+
lessonsForLanguage.map((lesson) => [lesson.id, lesson] as const),
65+
);
66+
const orderedUnitLessons = currentUnit.lessonIds
67+
.map((lessonId) => lessonMap.get(lessonId))
68+
.filter((lesson): lesson is Lesson => Boolean(lesson));
69+
70+
return orderedUnitLessons.length > 0 ? orderedUnitLessons : lessonsForLanguage;
71+
}, [currentUnit, selectedLanguage.id]);
72+
73+
const completedCount = Math.min(2, unitLessons.length);
74+
const activeLessonIndex = Math.min(completedCount, Math.max(unitLessons.length - 1, 0));
75+
const activeLesson = unitLessons[activeLessonIndex] ?? unitLessons[0];
76+
77+
return (
78+
<View className="flex-1 bg-white">
79+
<ScrollView
80+
contentContainerStyle={[
81+
styles.content,
82+
{
83+
paddingBottom: Math.max(insets.bottom, 10) + 122,
84+
paddingTop: Math.max(insets.top + 15, 30),
85+
},
86+
]}
87+
showsVerticalScrollIndicator={false}
88+
>
89+
<View className="flex-row items-center justify-between">
90+
<Pressable
91+
accessibilityLabel="Go back"
92+
hitSlop={12}
93+
onPress={() => router.back()}
94+
style={({ pressed }) => pressed && styles.pressed}
95+
>
96+
<Ionicons name="chevron-back" size={35} color="#0D132B" />
97+
</Pressable>
98+
99+
<View className="flex-1 px-[22px]">
100+
<Text className="font-poppins-semibold text-[25px] leading-[33px] text-lingua-text-primary">
101+
{activeLesson?.title ?? currentUnit?.title ?? selectedLanguage.name}
102+
</Text>
103+
<Text className="mt-[4px] font-poppins-medium text-[18px] leading-[25px] text-[#7A84A1]">
104+
Unit {currentUnit?.order ?? 1}{completedCount} / {unitLessons.length} lessons
105+
</Text>
106+
</View>
107+
108+
<Pressable
109+
accessibilityLabel="Save unit"
110+
hitSlop={12}
111+
style={({ pressed }) => pressed && styles.pressed}
112+
>
113+
<Ionicons name="bookmark-outline" size={34} color="#5B3BF6" />
114+
</Pressable>
115+
</View>
116+
117+
<ImageBackground
118+
source={images.lessonArt.cafeScene}
119+
imageStyle={styles.heroImage}
120+
resizeMode="contain"
121+
style={[styles.hero, { height: heroHeight, width }]}
122+
/>
123+
124+
<View
125+
className="h-[80px] flex-row overflow-hidden rounded-[22px] bg-white/95"
126+
style={styles.segmentContainer}
127+
>
128+
<SegmentButton
129+
isActive={activeTab === "lessons"}
130+
label="Lessons"
131+
onPress={() => setActiveTab("lessons")}
132+
/>
133+
<SegmentButton
134+
isActive={activeTab === "practice"}
135+
label="Practice"
136+
onPress={() => setActiveTab("practice")}
137+
/>
138+
</View>
139+
140+
{activeTab === "lessons" ? (
141+
<View className="mt-[28px] gap-[12px]">
142+
{unitLessons.map((lesson, index) => (
143+
<LessonCard
144+
key={lesson.id}
145+
imageSource={getLessonImageSource(index)}
146+
lesson={lesson}
147+
lessonNumber={index + 1}
148+
onPress={() => {
149+
if (getLessonStatus(index) !== "not-started") {
150+
router.push({
151+
pathname: "/lesson/[lessonId]",
152+
params: { lessonId: lesson.id },
153+
});
154+
}
155+
}}
156+
status={getLessonStatus(index)}
157+
/>
158+
))}
159+
</View>
160+
) : (
161+
<View className="mt-[28px] gap-[12px]">
162+
{unitLessons.slice(0, 4).map((lesson, index) => (
163+
<Pressable
164+
key={lesson.id}
165+
accessibilityLabel={`Practice ${lesson.title}`}
166+
accessibilityRole="button"
167+
className="min-h-[96px] flex-row items-center rounded-[18px] border border-[#EEF0F5] bg-white px-[22px] py-[18px]"
168+
onPress={() =>
169+
router.push({
170+
pathname: "/lesson/[lessonId]",
171+
params: { lessonId: lesson.id },
172+
})
173+
}
174+
style={({ pressed }) => [styles.practiceCard, pressed && styles.pressed]}
175+
>
176+
<View className="h-[48px] w-[48px] items-center justify-center rounded-[14px] bg-[#F2EFFF]">
177+
<Ionicons
178+
name={index % 2 === 0 ? "mic-outline" : "chatbubble-ellipses-outline"}
179+
size={27}
180+
color="#5B3BF6"
181+
/>
182+
</View>
183+
<View className="ml-[18px] flex-1">
184+
<Text className="font-poppins-semibold text-[18px] leading-[25px] text-lingua-text-primary">
185+
{lesson.title}
186+
</Text>
187+
<Text className="mt-[4px] font-poppins-medium text-[15px] leading-[22px] text-[#8B94AD]">
188+
{lesson.activities.length} quick practice prompts
189+
</Text>
190+
</View>
191+
<Ionicons name="chevron-forward" size={24} color="#8B94AD" />
192+
</Pressable>
193+
))}
194+
</View>
195+
)}
196+
</ScrollView>
197+
</View>
198+
);
199+
}
200+
201+
function getLessonStatus(index: number): LessonStatus {
202+
if (index < 2) {
203+
return "completed";
204+
}
205+
206+
if (index === 2) {
207+
return "in-progress";
208+
}
209+
210+
return "not-started";
211+
}
212+
213+
function getLessonImageSource(index: number) {
214+
const status = getLessonStatus(index);
215+
216+
if (status === "not-started") {
217+
return images.lessonArt.notStarted;
218+
}
219+
220+
return lessonArt[index % lessonArt.length];
221+
}
222+
223+
type SegmentButtonProps = {
224+
isActive: boolean;
225+
label: string;
226+
onPress: () => void;
227+
};
228+
229+
function SegmentButton({ isActive, label, onPress }: SegmentButtonProps) {
4230
return (
5-
<TabPlaceholderScreen
6-
title="Learn"
7-
subtitle="Lesson browsing and practice will live here."
8-
/>
231+
<Pressable
232+
accessibilityRole="button"
233+
accessibilityState={{ selected: isActive }}
234+
className="flex-1 items-center justify-center bg-white"
235+
onPress={onPress}
236+
style={({ pressed }) => [
237+
isActive && styles.activeSegment,
238+
pressed && styles.pressed,
239+
]}
240+
>
241+
<Text
242+
className={`font-poppins-semibold text-[20px] leading-[28px] ${
243+
isActive ? "text-lingua-deep-purple" : "text-[#65708E]"
244+
}`}
245+
>
246+
{label}
247+
</Text>
248+
{isActive ? <View className="absolute bottom-0 h-[4px] w-full rounded-full bg-lingua-deep-purple" /> : null}
249+
</Pressable>
9250
);
10251
}
252+
253+
const styles = StyleSheet.create({
254+
activeSegment: {
255+
borderRadius: 22,
256+
shadowColor: "#5B3BF6",
257+
shadowOffset: { height: 8, width: 0 },
258+
shadowOpacity: 0.12,
259+
shadowRadius: 16,
260+
},
261+
content: {
262+
paddingHorizontal: 24,
263+
},
264+
hero: {
265+
backgroundColor: "#FFFFFF",
266+
marginLeft: -24,
267+
marginTop: 19,
268+
overflow: "hidden",
269+
},
270+
heroImage: {
271+
borderRadius: 0,
272+
},
273+
practiceCard: {
274+
shadowColor: "#0D132B",
275+
shadowOffset: { height: 5, width: 0 },
276+
shadowOpacity: 0.035,
277+
shadowRadius: 12,
278+
},
279+
pressed: {
280+
opacity: 0.72,
281+
},
282+
segmentContainer: {
283+
marginHorizontal: -5,
284+
marginTop: -1,
285+
},
286+
});

0 commit comments

Comments
 (0)