├── bun.lockb
├── constants
├── layouts.ts
├── storage-key.ts
├── default.ts
└── colors.ts
├── assets
├── images
│ ├── icon.png
│ ├── splash.png
│ ├── favicon.png
│ └── adaptive-icon.png
├── public
│ ├── og.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ └── site.webmanifest
├── fonts
│ └── SpaceMono-Regular.ttf
└── audios
│ ├── sound
│ ├── files
│ │ ├── correct.mp3
│ │ └── wrong.mp3
│ └── index.ts
│ └── course
│ ├── files
│ ├── rice-jp-audio.mp3
│ ├── tea-jp-audio.mp3
│ ├── sushi-jp-audio.mp3
│ └── water-jp-audio.mp3
│ └── index.ts
├── tsconfig.json
├── vercel.json
├── app
├── (course)
│ ├── shop.tsx
│ ├── profile.tsx
│ ├── quests.tsx
│ ├── leaderboards.tsx
│ ├── _layout.tsx
│ ├── characters.tsx
│ └── learn.tsx
├── (guest)
│ ├── _layout.tsx
│ ├── register.tsx
│ └── index.tsx
├── (lesson)
│ ├── lesson.tsx
│ └── pratice
│ │ └── [sectionId]
│ │ └── [chapterId]
│ │ └── [lessonId]
│ │ └── [exerciseId]
│ │ └── index.tsx
├── [...missing].tsx
├── _layout.tsx
└── +html.tsx
├── content
├── translations
│ ├── index.ts
│ └── common
│ │ └── index.ts
└── courses
│ ├── data
│ ├── sections
│ │ └── 1
│ │ │ ├── chapters
│ │ │ └── 1
│ │ │ │ ├── lessons
│ │ │ │ └── 1
│ │ │ │ │ ├── exercises
│ │ │ │ │ └── 1
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ ├── index.ts
│ └── characters
│ │ └── index.ts
│ └── items
│ ├── flashcard
│ └── water.ts
│ └── translate
│ └── sushi-please.ts
├── metro.config.ts
├── babel.config.js
├── components
├── status-bar.tsx
├── metadata.tsx
├── container.tsx
├── shell.tsx
├── themed.tsx
├── exercise
│ ├── items
│ │ ├── exercise-items.tsx
│ │ ├── exercise-item-event.tsx
│ │ ├── flash-card-item.tsx
│ │ └── translate-item.tsx
│ └── screens
│ │ ├── exercise-outro.tsx
│ │ └── exercise.tsx
├── layouts
│ ├── main-header.tsx
│ ├── mobile-tabs-bar.tsx
│ ├── course-left-bar.tsx
│ └── course-right-bar.tsx
├── course-details-bar.tsx
├── ui
│ ├── dialog.tsx
│ └── button.tsx
├── select-language.tsx
├── select-course.tsx
├── lesson-item.tsx
└── icons.tsx
├── .gitignore
├── .prettierrc.js
├── hooks
└── audio.ts
├── config
├── site.ts
├── course.ts
└── language.ts
├── context
├── protected-route.tsx
├── theme.tsx
├── breakpoints.tsx
├── language.tsx
└── course.tsx
├── lib
├── local-storage.ts
└── utils.ts
├── LICENSE
├── app.config.ts
├── types
├── index.d.ts
└── course.d.ts
├── package.json
└── README.md
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/bun.lockb
--------------------------------------------------------------------------------
/constants/layouts.ts:
--------------------------------------------------------------------------------
1 | export const layouts = {
2 | padding: 14,
3 | borderWidth: 2,
4 | };
5 |
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/public/og.png
--------------------------------------------------------------------------------
/assets/images/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/images/splash.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/public/favicon-16x16.png
--------------------------------------------------------------------------------
/assets/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/public/favicon-32x32.png
--------------------------------------------------------------------------------
/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/assets/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/assets/audios/sound/files/correct.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/audios/sound/files/correct.mp3
--------------------------------------------------------------------------------
/assets/audios/sound/files/wrong.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/audios/sound/files/wrong.mp3
--------------------------------------------------------------------------------
/assets/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/assets/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/assets/audios/course/files/rice-jp-audio.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/audios/course/files/rice-jp-audio.mp3
--------------------------------------------------------------------------------
/assets/audios/course/files/tea-jp-audio.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/audios/course/files/tea-jp-audio.mp3
--------------------------------------------------------------------------------
/assets/audios/course/files/sushi-jp-audio.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/audios/course/files/sushi-jp-audio.mp3
--------------------------------------------------------------------------------
/assets/audios/course/files/water-jp-audio.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ikyawthetpaing/euolingo/HEAD/assets/audios/course/files/water-jp-audio.mp3
--------------------------------------------------------------------------------
/assets/audios/sound/index.ts:
--------------------------------------------------------------------------------
1 | export const sound = {
2 | correct: require("./files/correct.mp3"),
3 | wrong: require("./files/wrong.mp3"),
4 | };
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": ["./*"]
7 | }
8 | },
9 | "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "expo export -p web",
3 | "outputDirectory": "dist",
4 | "devCommand": "expo",
5 | "cleanUrls": true,
6 | "framework": null,
7 | "rewrites": [
8 | {
9 | "source": "/:path*",
10 | "destination": "/"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/constants/storage-key.ts:
--------------------------------------------------------------------------------
1 | import { SupportedLanguageCode } from "@/types";
2 |
3 | export const CURRENT_COURSE_ID_STORAGE_KEY = "CURRENT_COURSE_ID";
4 | export const COURSE_PROGRESS_STORAGE_KEY = (code: SupportedLanguageCode) =>
5 | `${code.toUpperCase()}_COURSE_PROGRESS`;
6 | export const LANGUAGE_ID_STORAGE_KEY = "LANGUAGE_ID";
7 |
--------------------------------------------------------------------------------
/app/(course)/shop.tsx:
--------------------------------------------------------------------------------
1 | import { Text, View } from "@/components/themed";
2 |
3 | export default function Shop() {
4 | return (
5 |
6 |
7 | Shop
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/app/(course)/profile.tsx:
--------------------------------------------------------------------------------
1 | import { Text, View } from "@/components/themed";
2 |
3 | export default function Profile() {
4 | return (
5 |
6 |
7 | Profile
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/app/(course)/quests.tsx:
--------------------------------------------------------------------------------
1 | import { Text, View } from "@/components/themed";
2 |
3 | export default function Quests() {
4 | return (
5 |
6 |
7 | Quests
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/content/translations/index.ts:
--------------------------------------------------------------------------------
1 | import { SupportedLanguageCode } from "@/types";
2 |
3 | import { commonTranslations } from "./common";
4 |
5 | export function getCommonTranslation(
6 | name: keyof typeof commonTranslations,
7 | language: SupportedLanguageCode
8 | ): string {
9 | return commonTranslations[name][language];
10 | }
11 |
--------------------------------------------------------------------------------
/app/(course)/leaderboards.tsx:
--------------------------------------------------------------------------------
1 | import { Text, View } from "@/components/themed";
2 |
3 | export default function Leaderboards() {
4 | return (
5 |
6 |
7 | Leaderboards
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/metro.config.ts:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | import { getDefaultConfig } from "expo/metro-config";
3 |
4 | /** @type {import('expo/metro-config').MetroConfig} */
5 | const config = getDefaultConfig(__dirname, {
6 | // [Web-only]: Enables CSS support in Metro.
7 | isCSSEnabled: true,
8 | });
9 |
10 | module.exports = config;
11 |
--------------------------------------------------------------------------------
/constants/default.ts:
--------------------------------------------------------------------------------
1 | import { SupportedLanguageCode } from "@/types";
2 | import { CourseProgression } from "@/types/course";
3 |
4 | export const DEFAULT_COURSE_PROGRESS: CourseProgression = {
5 | sectionId: 0,
6 | chapterId: 0,
7 | lessonId: 0,
8 | exerciseId: 0,
9 | };
10 |
11 | export const DEFAULT_COURSE_ID: SupportedLanguageCode = "en";
12 | export const DEFAULT_LANGUAGE_CODE: SupportedLanguageCode = "en";
13 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 | return {
4 | presets: ["babel-preset-expo"],
5 | plugins: [
6 | // Required for expo-router
7 | "expo-router/babel",
8 | [
9 | "module-resolver",
10 | {
11 | root: ["./"],
12 | alias: {
13 | "@": "./",
14 | },
15 | },
16 | ],
17 | ],
18 | };
19 | };
20 |
--------------------------------------------------------------------------------
/components/status-bar.tsx:
--------------------------------------------------------------------------------
1 | import { StatusBar as ExpoStatusBar } from "expo-status-bar";
2 | import { StatusBar as RNStatusBar } from "react-native";
3 |
4 | import { useTheme } from "@/context/theme";
5 |
6 | export const STATUSBAR_HEIGHT = RNStatusBar.currentHeight || 0;
7 |
8 | export function StatusBar() {
9 | const { background } = useTheme();
10 | return ;
11 | }
12 |
--------------------------------------------------------------------------------
/content/courses/data/sections/1/chapters/1/lessons/1/exercises/1/index.ts:
--------------------------------------------------------------------------------
1 | import { waterFlashCard } from "@/content/courses/items/flashcard/water";
2 | import { sushiPleaseTranslate } from "@/content/courses/items/translate/sushi-please";
3 | import { ExerciseSet } from "@/types/course";
4 |
5 | export const exerciseOne: ExerciseSet = {
6 | id: 1,
7 | xp: 10,
8 | difficulty: "easy",
9 | items: [sushiPleaseTranslate, waterFlashCard],
10 | };
11 |
--------------------------------------------------------------------------------
/app/(guest)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from "expo-router";
2 |
3 | import { MainHeader } from "@/components/layouts/main-header";
4 | import { Shell } from "@/components/shell";
5 | import { View } from "@/components/themed";
6 |
7 | export default function MainLayout() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/content/courses/data/sections/1/index.ts:
--------------------------------------------------------------------------------
1 | import { Section } from "@/types/course";
2 |
3 | import { chapterOne } from "./chapters/1";
4 |
5 | export const sectionOne: Section = {
6 | id: 1,
7 | title: {
8 | en: "Section 1: Rookie",
9 | ja: "セクション 1: ルーキー",
10 | my: "အပိုင်း 1- လူသစ်",
11 | th: "ส่วนที่ 1: มือใหม่",
12 | cn: "第 1 部分:新秀",
13 | es: "Sección 1: Novato",
14 | fr: "Section 1 : Recrue",
15 | hi: "धारा 1: नौसिखिया",
16 | ru: "Раздел 1: Новичок",
17 | },
18 | chapters: [chapterOne],
19 | };
20 |
--------------------------------------------------------------------------------
/content/courses/data/sections/1/chapters/1/lessons/1/index.ts:
--------------------------------------------------------------------------------
1 | import { Lesson } from "@/types/course";
2 |
3 | import { exerciseOne } from "./exercises/1";
4 |
5 | export const lessonOne: Lesson = {
6 | id: 1,
7 | description: {
8 | en: "Order food",
9 | ja: "注文します",
10 | my: "အစားအသောက်များ မှာယူပါ။",
11 | th: "สั่งอาหาร",
12 | cn: "点菜",
13 | es: "Ordenar comida",
14 | fr: "Commander de la nourriture",
15 | hi: "भोजन का आदेश करें",
16 | ru: "заказать еду",
17 | },
18 | exercises: [exerciseOne],
19 | };
20 |
--------------------------------------------------------------------------------
/components/metadata.tsx:
--------------------------------------------------------------------------------
1 | import Head from "expo-router/head";
2 |
3 | import { siteConfig } from "@/config/site";
4 |
5 | interface Props {
6 | title?: string;
7 | description?: string;
8 | }
9 |
10 | export function Metadata({ title, description }: Props) {
11 | return (
12 |
13 |
14 | {title ? `${title} - ${siteConfig.name}` : siteConfig.title}
15 |
16 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/components/container.tsx:
--------------------------------------------------------------------------------
1 | import { View, ViewProps } from "@/components/themed";
2 |
3 | interface Props extends ViewProps {
4 | children: React.ReactNode;
5 | layout?: "sm" | "lg";
6 | }
7 | export function Container({ children, layout = "sm", style, ...props }: Props) {
8 | return (
9 |
21 | {children}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/shell.tsx:
--------------------------------------------------------------------------------
1 | import { useWindowDimensions } from "react-native";
2 |
3 | import { STATUSBAR_HEIGHT } from "@/components/status-bar";
4 | import { View, ViewProps } from "@/components/themed";
5 |
6 | interface Props extends ViewProps {
7 | children: React.ReactNode;
8 | }
9 |
10 | export function Shell({ children, style, ...props }: Props) {
11 | const screen = useWindowDimensions();
12 | return (
13 |
24 | {children}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/app/(lesson)/lesson.tsx:
--------------------------------------------------------------------------------
1 | import ExerciseScreen from "@/components/exercise/screens/exercise";
2 | import { Metadata } from "@/components/metadata";
3 | import { getExercise } from "@/content/courses/data";
4 | import { useCourse } from "@/context/course";
5 |
6 | export default function Lesson() {
7 | const { courseId, courseProgress } = useCourse();
8 | if (!courseId) return null;
9 |
10 | const exercise = getExercise(courseProgress);
11 | if (!exercise) return null;
12 |
13 | return (
14 | <>
15 |
19 |
20 | >
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 |
11 | # Native
12 | *.orig.*
13 | *.jks
14 | *.p8
15 | *.p12
16 | *.key
17 | *.mobileprovision
18 |
19 | # Metro
20 | .metro-health-check*
21 |
22 | # debug
23 | npm-debug.*
24 | yarn-debug.*
25 | yarn-error.*
26 |
27 | # macOS
28 | .DS_Store
29 | *.pem
30 |
31 | # local env files
32 | .env*.local
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
37 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
38 | # The following patterns were generated by expo-cli
39 |
40 | expo-env.d.ts
41 | # @end expo-cli
42 |
43 | dist/
44 | archive/
45 | .vercel
46 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config} */
2 | module.exports = {
3 | endOfLine: "lf",
4 | semi: true,
5 | singleQuote: false,
6 | tabWidth: 2,
7 | trailingComma: "es5",
8 | importOrder: [
9 | "^(react/(.*)$)|^(react$)",
10 | "",
11 | "",
12 | "^types$",
13 | "^@/(.*)$",
14 | "",
15 | "^@/components/(.*)$",
16 | "",
17 | "^[./]",
18 | ],
19 | importOrderSeparation: false,
20 | importOrderSortSpecifiers: true,
21 | importOrderBuiltinModulesToTop: true,
22 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"],
23 | importOrderMergeDuplicateImports: true,
24 | importOrderCombineTypeAndValueImports: true,
25 | plugins: ["@ianvs/prettier-plugin-sort-imports"],
26 | };
27 |
--------------------------------------------------------------------------------
/hooks/audio.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Audio, AVPlaybackSource } from "expo-av";
3 |
4 | interface Props {
5 | source?: AVPlaybackSource;
6 | }
7 |
8 | export function useAudio({ source }: Props) {
9 | const [sound, setSound] = useState(undefined);
10 |
11 | async function playSound() {
12 | if (source) {
13 | const { sound } = await Audio.Sound.createAsync(source);
14 | setSound(sound);
15 | await sound.playAsync();
16 | }
17 | }
18 |
19 | useEffect(() => {
20 | return sound
21 | ? () => {
22 | console.log("Unloading Sound");
23 | sound.unloadAsync();
24 | }
25 | : undefined;
26 | }, [sound]);
27 |
28 | return { playSound };
29 | }
30 |
--------------------------------------------------------------------------------
/config/site.ts:
--------------------------------------------------------------------------------
1 | import { SiteConfig } from "@/types";
2 |
3 | const ASSETS_URL =
4 | "https://raw.githubusercontent.com/ikyawthetpaing/euolingo/main/assets";
5 |
6 | export const siteConfig: SiteConfig = {
7 | name: "Euolingo",
8 | title: "Euolingo: The best way to learn a language",
9 | description: "The free, fun, and effective way to learn a language.",
10 | url: "https://euolingo.vercel.app",
11 | author: {
12 | name: "Kyaw Thet Paing",
13 | username: "@ikyawthetpaing",
14 | url: "https://ikyawthetpaing.vercel.app",
15 | },
16 | ogImage: `${ASSETS_URL}/public/og.png`,
17 | appleTouchIcon: `${ASSETS_URL}/public/apple-touch-icon.png`,
18 | icon16x16: `${ASSETS_URL}/public/favicon-16x16.png`,
19 | icon32x32: `${ASSETS_URL}/public/favicon-32x32.png`,
20 | manifest: `${ASSETS_URL}/public/site.webmanifest`,
21 | };
22 |
--------------------------------------------------------------------------------
/context/protected-route.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { router, useSegments } from "expo-router";
3 |
4 | import { useCourse } from "@/context/course";
5 |
6 | interface Props {
7 | children: React.ReactNode;
8 | }
9 |
10 | export function ProtectedRouteProvider({ children }: Props) {
11 | const segments = useSegments();
12 | const { courseId } = useCourse();
13 |
14 | const inCourseGroup = segments[0] === "(course)";
15 | const inLessonGroup = segments[0] === "(lesson)";
16 |
17 | useEffect(() => {
18 | if (!courseId && (inCourseGroup || inLessonGroup)) {
19 | router.replace("/register");
20 | } else if (courseId && !(inCourseGroup || inLessonGroup)) {
21 | router.replace("/learn");
22 | }
23 | console.log("run case of: segments");
24 | }, [segments]);
25 |
26 | return <>{children}>;
27 | }
28 |
--------------------------------------------------------------------------------
/content/courses/data/sections/1/chapters/1/index.ts:
--------------------------------------------------------------------------------
1 | import { Chapter } from "@/types/course";
2 |
3 | import { lessonOne } from "./lessons/1";
4 |
5 | export const chapterOne: Chapter = {
6 | id: 1,
7 | title: {
8 | en: "Unit 1",
9 | ja: "ユニット1",
10 | my: "ယူနစ် ၁",
11 | th: "บทที่ 1",
12 | cn: "单元1",
13 | es: "Unidad 1",
14 | fr: "Unité 1",
15 | hi: "यूनिट 1",
16 | ru: "Раздел 1",
17 | },
18 | description: {
19 | en: "Order food, describe people",
20 | ja: "食べ物を注文し、人々について説明する",
21 | my: "အစားအစာကို မှာယူပါ၊ လူများကို ဖော်ပြပါ။",
22 | th: "สั่งอาหารบรรยายคน",
23 | cn: "点菜、描述人",
24 | es: "Ordenar comida, describir personas.",
25 | fr: "Commander de la nourriture, décrire les gens",
26 | hi: "खाना ऑर्डर करें, लोगों का वर्णन करें",
27 | ru: "Заказать еду, описать людей",
28 | },
29 | lessons: [lessonOne, lessonOne, lessonOne, lessonOne, lessonOne],
30 | };
31 |
--------------------------------------------------------------------------------
/components/themed.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from "react";
2 | import { Text as DefaultText, View as DefaultView } from "react-native";
3 |
4 | import { useTheme } from "@/context/theme";
5 |
6 | export type TextProps = DefaultText["props"];
7 | export type ViewProps = DefaultView["props"];
8 |
9 | export const Text = forwardRef(
10 | ({ style, ...props }, ref) => {
11 | const { foreground } = useTheme();
12 | return (
13 |
18 | );
19 | }
20 | );
21 |
22 | export const View = forwardRef(
23 | ({ style, ...props }, ref) => {
24 | const { background } = useTheme();
25 | return (
26 |
31 | );
32 | }
33 | );
34 |
--------------------------------------------------------------------------------
/app/[...missing].tsx:
--------------------------------------------------------------------------------
1 | import { Link, Stack } from "expo-router";
2 | import { StyleSheet } from "react-native";
3 |
4 | import { Text, View } from "@/components/themed";
5 |
6 | export default function NotFoundScreen() {
7 | return (
8 | <>
9 |
10 |
11 | This screen doesn't exist.
12 |
13 |
14 | Go to home screen!
15 |
16 |
17 | >
18 | );
19 | }
20 |
21 | const styles = StyleSheet.create({
22 | container: {
23 | flex: 1,
24 | alignItems: "center",
25 | justifyContent: "center",
26 | padding: 20,
27 | },
28 | title: {
29 | fontSize: 20,
30 | fontWeight: "bold",
31 | },
32 | link: {
33 | marginTop: 15,
34 | paddingVertical: 15,
35 | },
36 | linkText: {
37 | fontSize: 14,
38 | color: "#2e78b7",
39 | },
40 | });
41 |
--------------------------------------------------------------------------------
/lib/local-storage.ts:
--------------------------------------------------------------------------------
1 | import AsyncStorage from "@react-native-async-storage/async-storage";
2 | import { Platform } from "react-native";
3 |
4 | export async function getLocalData(id: string): Promise {
5 | try {
6 | let value;
7 | if (Platform.OS === "web") {
8 | value = localStorage.getItem(id);
9 | } else {
10 | const storedValue = await AsyncStorage.getItem(id);
11 | value = storedValue !== null ? JSON.parse(storedValue) : null;
12 | }
13 | return value;
14 | } catch (error) {
15 | console.error(`Error getting data for ID ${id}:`, error);
16 | return null;
17 | }
18 | }
19 |
20 | export async function setLocalData(id: string, value: string) {
21 | try {
22 | if (Platform.OS === "web") {
23 | localStorage.setItem(id, value);
24 | } else {
25 | await AsyncStorage.setItem(id, JSON.stringify(value));
26 | }
27 | console.log(`Data for ID ${id} set successfully.`);
28 | } catch (error) {
29 | console.error(`Error setting data for ID ${id}:`, error);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/(lesson)/pratice/[sectionId]/[chapterId]/[lessonId]/[exerciseId]/index.tsx:
--------------------------------------------------------------------------------
1 | import { useLocalSearchParams } from "expo-router";
2 |
3 | import ExerciseScreen from "@/components/exercise/screens/exercise";
4 | import { Metadata } from "@/components/metadata";
5 | import { getExercise } from "@/content/courses/data";
6 |
7 | export default function Practice() {
8 | const { sectionId, chapterId, lessonId, exerciseId } = useLocalSearchParams();
9 |
10 | const toNumber = (value: any) =>
11 | typeof value === "string" ? Number(value) : -1;
12 |
13 | const exercise = getExercise({
14 | sectionId: toNumber(sectionId),
15 | chapterId: toNumber(chapterId),
16 | lessonId: toNumber(lessonId),
17 | exerciseId: toNumber(exerciseId),
18 | });
19 | if (!exercise) return null;
20 |
21 | return (
22 | <>
23 |
27 |
28 | >
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/assets/audios/course/index.ts:
--------------------------------------------------------------------------------
1 | // import { CourseAudios } from "@/types/course";
2 |
3 | // export const courseAudio = {
4 | // rice: {
5 | // en: require("./files/rice-jp-audio.mp3"),
6 | // ja: require("./files/rice-jp-audio.mp3"),
7 | // my: require("./files/rice-jp-audio.mp3"),
8 | // th: require("./files/rice-jp-audio.mp3"),
9 | // },
10 | // sushi: {
11 | // en: require("./files/sushi-jp-audio.mp3"),
12 | // ja: require("./files/sushi-jp-audio.mp3"),
13 | // my: require("./files/sushi-jp-audio.mp3"),
14 | // th: require("./files/sushi-jp-audio.mp3"),
15 | // },
16 | // tea: {
17 | // en: require("./files/tea-jp-audio.mp3"),
18 | // ja: require("./files/tea-jp-audio.mp3"),
19 | // my: require("./files/tea-jp-audio.mp3"),
20 | // th: require("./files/tea-jp-audio.mp3"),
21 | // },
22 | // water: {
23 | // en: require("./files/water-jp-audio.mp3"),
24 | // ja: require("./files/water-jp-audio.mp3"),
25 | // my: require("./files/water-jp-audio.mp3"),
26 | // th: require("./files/water-jp-audio.mp3"),
27 | // },
28 | // } satisfies CourseAudios;
29 |
--------------------------------------------------------------------------------
/assets/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Euolingo",
3 | "name": "Euolingo: The best way to learn a language",
4 | "description": "The free, fun, and effective way to learn a language.",
5 | "icons": [
6 | {
7 | "src": "https://raw.githubusercontent.com/ikyawthetpaing/euolingo/main/assets/public/android-chrome-192x192.png",
8 | "sizes": "192x192",
9 | "type": "image/png"
10 | },
11 | {
12 | "src": "https://raw.githubusercontent.com/ikyawthetpaing/euolingo/main/assets/public/android-chrome-512x512.png",
13 | "sizes": "512x512",
14 | "type": "image/png"
15 | }
16 | ],
17 | "theme_color": "#ffffff",
18 | "background_color": "#ffffff",
19 | "display": "standalone",
20 | "orientation": "any",
21 | "start_url": "https://euolingo.vercel.app/",
22 | "prefer_related_applications": true,
23 | "related_applications": [
24 | {
25 | "platform": "web",
26 | "url": "https://ikyawthetpaing.vercel.app"
27 | },
28 | {
29 | "platform": "web",
30 | "url": "https://voxellax.vercel.app"
31 | }
32 | ],
33 | "lang": "en-US"
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Kyaw Thet Paing
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/components/exercise/items/exercise-items.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from "@/components/themed";
2 | import {
3 | ExerciseItemProps,
4 | ExerciseItemVariant as ExerciseItemType,
5 | FlashCardExercise,
6 | TranslateExercise,
7 | } from "@/types/course";
8 |
9 | import { FlashCardItem } from "./flash-card-item";
10 | import { TranslateItem } from "./translate-item";
11 |
12 | interface Props extends ExerciseItemProps {
13 | exerciseItem: ExerciseItemType;
14 | }
15 |
16 | export default function ExerciseItems({
17 | exerciseItem,
18 | onContinue,
19 | onResult,
20 | }: Props) {
21 | if (exerciseItem.type === "flashCard") {
22 | return (
23 |
28 | );
29 | } else if (exerciseItem.type === "translate") {
30 | return (
31 |
36 | );
37 | } else {
38 | return Unknown exercise;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app.config.ts:
--------------------------------------------------------------------------------
1 | import { ExpoConfig } from "expo/config";
2 |
3 | // In SDK 46 and lower, use the following import instead:
4 | // import { ExpoConfig } from '@expo/config-types';
5 |
6 | const config: ExpoConfig = {
7 | name: "Euolingo",
8 | description: "The free, fun, and effective way to learn a language.",
9 | slug: "euolingo",
10 | version: "1.0.0",
11 | orientation: "portrait",
12 | icon: "./assets/images/icon.png",
13 | scheme: "myapp",
14 | userInterfaceStyle: "automatic",
15 | splash: {
16 | image: "./assets/images/splash.png",
17 | resizeMode: "contain",
18 | backgroundColor: "#DFEBF7",
19 | },
20 | assetBundlePatterns: ["**/*"],
21 | ios: {
22 | supportsTablet: true,
23 | },
24 | android: {
25 | adaptiveIcon: {
26 | foregroundImage: "./assets/images/adaptive-icon.png",
27 | backgroundColor: "#DFEBF7",
28 | },
29 | },
30 | web: {
31 | bundler: "metro",
32 | output: "static",
33 | favicon: "./assets/images/favicon.png",
34 | },
35 | plugins: ["expo-router"],
36 | experiments: {
37 | typedRoutes: true,
38 | },
39 | owner: "@ikyawthetpaing",
40 | };
41 |
42 | export default config;
43 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { Platform } from "react-native";
2 |
3 | export function isWeb() {
4 | return Platform.OS === "web";
5 | }
6 |
7 | export function changeColorOpacity(rgbColor: string, opacity: number): string {
8 | const regex = /(\d+),\s*(\d+),\s*(\d+)/;
9 | const match = rgbColor.match(regex);
10 |
11 | if (match) {
12 | const red = parseInt(match[1], 10);
13 | const green = parseInt(match[2], 10);
14 | const blue = parseInt(match[3], 10);
15 |
16 | const validOpacity = Math.min(1, Math.max(0, opacity));
17 | const rgbaColor = `rgba(${red}, ${green}, ${blue}, ${validOpacity})`;
18 |
19 | return rgbaColor;
20 | } else {
21 | throw new Error("Invalid RGB color format");
22 | }
23 | }
24 |
25 | export function shuffleArray(array: T[]): T[] {
26 | const shuffledArray = array.slice();
27 | for (let i = shuffledArray.length - 1; i > 0; i--) {
28 | const j = Math.floor(Math.random() * (i + 1));
29 | [shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]];
30 | }
31 |
32 | return shuffledArray;
33 | }
34 |
35 | export function calculatePrecentage(part: number, whole: number) {
36 | return (part / whole) * 100;
37 | }
38 |
--------------------------------------------------------------------------------
/context/theme.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useEffect, useState } from "react";
2 | import {
3 | DarkTheme,
4 | DefaultTheme,
5 | ThemeProvider as DefaultThemeProvider,
6 | } from "@react-navigation/native";
7 | import { useColorScheme } from "react-native";
8 |
9 | import { themeColors } from "@/constants/colors";
10 | import { Colors } from "@/types";
11 |
12 | type ThemeContextType = Colors;
13 |
14 | const ThemeContext = createContext(undefined);
15 |
16 | export function useTheme() {
17 | const context = useContext(ThemeContext);
18 | if (!context) {
19 | throw new Error("useTheme must be used within an ThemeProvider");
20 | }
21 | return context;
22 | }
23 |
24 | interface Props {
25 | children: React.ReactNode;
26 | }
27 |
28 | export function ThemeProvider({ children }: Props) {
29 | const colorScheme = useColorScheme();
30 |
31 | const [colors, setColors] = useState(themeColors.light); // default light theme
32 |
33 | useEffect(() => {
34 | setColors(themeColors[colorScheme ?? "light"]);
35 | }, [colorScheme]);
36 |
37 | const userContext: ThemeContextType = colors;
38 |
39 | return (
40 |
43 |
44 | {children}
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/config/course.ts:
--------------------------------------------------------------------------------
1 | import { NavItem } from "@/types";
2 |
3 | type CourseConfig = {
4 | sidebarNavItems: NavItem[];
5 | mobileNavItems: NavItem[];
6 | };
7 |
8 | export const courseConfig: CourseConfig = {
9 | sidebarNavItems: [
10 | {
11 | icon: "home",
12 | label: "Learn",
13 | href: "/learn",
14 | },
15 | {
16 | icon: "languageSquare",
17 | label: "Characters",
18 | href: "/characters",
19 | },
20 | {
21 | icon: "shieldStar",
22 | label: "Leaderboards",
23 | href: "/leaderboards",
24 | },
25 | {
26 | icon: "box",
27 | label: "Quests",
28 | href: "/quests",
29 | },
30 | {
31 | icon: "shop",
32 | label: "Shop",
33 | href: "/shop",
34 | },
35 | {
36 | icon: "profile",
37 | label: "Profile",
38 | href: "/profile",
39 | },
40 | ],
41 | mobileNavItems: [
42 | {
43 | icon: "home",
44 | label: "Learn",
45 | href: "/learn",
46 | },
47 | {
48 | icon: "languageSquare",
49 | label: "Characters",
50 | href: "/characters",
51 | },
52 | {
53 | icon: "shieldStar",
54 | label: "Leaderboards",
55 | href: "/leaderboards",
56 | },
57 | {
58 | icon: "box",
59 | label: "Quests",
60 | href: "/quests",
61 | },
62 | {
63 | icon: "profile",
64 | label: "Profile",
65 | href: "/profile",
66 | },
67 | ],
68 | };
69 |
--------------------------------------------------------------------------------
/config/language.ts:
--------------------------------------------------------------------------------
1 | import { Languages, SupportedLanguageCode } from "@/types";
2 |
3 | export const languages = {
4 | en: {
5 | name: "English",
6 | flag: "https://www.svgrepo.com/show/405645/flag-for-flag-united-states.svg",
7 | },
8 | ja: {
9 | name: "日本語",
10 | flag: "https://www.svgrepo.com/show/405519/flag-for-flag-japan.svg",
11 | },
12 | es: {
13 | name: "Español",
14 | flag: "https://www.svgrepo.com/show/405610/flag-for-flag-spain.svg",
15 | },
16 | fr: {
17 | name: "Français",
18 | flag: "https://www.svgrepo.com/show/405485/flag-for-flag-france.svg",
19 | },
20 | cn: {
21 | name: "普通话",
22 | flag: "https://www.svgrepo.com/show/405448/flag-for-flag-china.svg",
23 | },
24 | ru: {
25 | name: "Русский",
26 | flag: "https://www.svgrepo.com/show/405590/flag-for-flag-russia.svg",
27 | },
28 | my: {
29 | name: "မြန်မာ",
30 | flag: "https://www.svgrepo.com/show/405559/flag-for-flag-myanmar-burma.svg",
31 | },
32 | th: {
33 | name: "ไทย",
34 | flag: "https://www.svgrepo.com/show/405628/flag-for-flag-thailand.svg",
35 | },
36 | hi: {
37 | name: "हिंदी",
38 | flag: "https://www.svgrepo.com/show/405510/flag-for-flag-india.svg",
39 | },
40 | } satisfies Languages;
41 |
42 | export function getLanguage(code: SupportedLanguageCode) {
43 | return languages[code];
44 | }
45 |
46 | export const validLanguages: SupportedLanguageCode[] = Object.keys(
47 | languages
48 | ).map((key) => key as SupportedLanguageCode);
49 |
--------------------------------------------------------------------------------
/constants/colors.ts:
--------------------------------------------------------------------------------
1 | import { ThemeColors } from "@/types";
2 |
3 | export const themeColors: ThemeColors = {
4 | light: {
5 | background: "rgb(255, 255, 255)",
6 | foreground: "rgb(10, 10, 10)",
7 | primary: "rgb(23, 23, 23)",
8 | primaryForeground: "rgb(250, 250, 250)",
9 | secondary: "rgb(245, 245, 245)",
10 | secondaryForeground: "rgb(23, 23, 23)",
11 | muted: "rgb(245, 245, 245)",
12 | mutedForeground: "rgb(115, 115, 115)",
13 | accent: "rgb(245, 245, 245)",
14 | accentForeground: "rgb(23, 23, 23)",
15 | destructive: "rgb(255,223, 224)",
16 | destructiveForeground: "rgb(225, 75, 75)",
17 | sucess: "rgb(215, 255, 184)",
18 | sucessForeground: "rgb(88, 204, 2)",
19 | border: "rgb(229, 229, 229)",
20 | },
21 | dark: {
22 | background: "rgb(10, 10, 10)",
23 | foreground: "rgb(250, 250, 250)",
24 | primary: "rgb(250, 250, 250)",
25 | primaryForeground: "rgb(23, 23, 23)",
26 | secondary: "rgb(38, 38, 38)",
27 | secondaryForeground: "rgb(250, 250, 250)",
28 | muted: "rgb(38, 38, 38)",
29 | mutedForeground: "rgb(163, 163, 163)",
30 | accent: "rgb(38, 38, 38)",
31 | accentForeground: "rgb(250, 250, 250)",
32 | destructive: "rgb(255,223, 224)",
33 | destructiveForeground: "rgb(225, 75, 75)",
34 | sucess: "rgb(215, 255, 184)",
35 | sucessForeground: "rgb(88, 204, 2)",
36 | border: "rgb(38, 38, 38)",
37 | },
38 | };
39 |
40 | export const colors = {
41 | transparent: "rgba(0, 0, 0, 0)",
42 | };
43 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import { Href } from "expo-router";
2 |
3 | import { Icons } from "@/components/icons";
4 |
5 | export type SupportedLanguageCode =
6 | | "en"
7 | | "my"
8 | | "ja"
9 | | "th"
10 | | "es"
11 | | "fr"
12 | | "cn"
13 | | "ru"
14 | | "hi";
15 |
16 | export type Languages = {
17 | [key in SupportedLanguageCode]: { name: string; flag: string };
18 | };
19 |
20 | export type Translations = {
21 | [key in SupportedLanguageCode]: string;
22 | };
23 |
24 | export type CommonTranslations = {
25 | [key: string]: Translations;
26 | };
27 |
28 | export type IconName = keyof typeof Icons;
29 |
30 | export type NavItem = {
31 | icon: keyof typeof Icons;
32 | label: string;
33 | href: Href;
34 | };
35 |
36 | export type SiteConfig = {
37 | name: string;
38 | title: string;
39 | description: string | undefined;
40 | url: string;
41 | author: { name: string; username: string; url: string };
42 | ogImage: string;
43 | appleTouchIcon: string;
44 | icon32x32: string;
45 | icon16x16: string;
46 | manifest: string;
47 | };
48 |
49 | export type Colors = {
50 | background: string;
51 | foreground: string;
52 | primary: string;
53 | primaryForeground: string;
54 | secondary: string;
55 | secondaryForeground: string;
56 | muted: string;
57 | mutedForeground: string;
58 | accent: string;
59 | accentForeground: string;
60 | destructive: string;
61 | destructiveForeground: string;
62 | sucess: string;
63 | sucessForeground: string;
64 | border: string;
65 | };
66 |
67 | export type ThemeColors = {
68 | light: Colors;
69 | dark: Colors;
70 | };
71 |
--------------------------------------------------------------------------------
/app/(course)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from "expo-router";
2 |
3 | import { Container } from "@/components/container";
4 | import { CourseLeftBar } from "@/components/layouts/course-left-bar";
5 | import { CourseRightBar } from "@/components/layouts/course-right-bar";
6 | import { MobileTabsBar } from "@/components/layouts/mobile-tabs-bar";
7 | import { Shell } from "@/components/shell";
8 | import { View } from "@/components/themed";
9 | import { courseConfig } from "@/config/course";
10 | import { siteConfig } from "@/config/site";
11 | import { useBreakpoint } from "@/context/breakpoints";
12 | import { useCourse } from "@/context/course";
13 |
14 | export default function CourseLayout() {
15 | const { courseId } = useCourse();
16 | const breakpoint = useBreakpoint();
17 |
18 | if (!courseId) return null;
19 |
20 | return (
21 |
22 |
23 |
24 | {breakpoint !== "sm" && (
25 |
29 | )}
30 |
31 |
32 |
33 |
34 | {breakpoint === "sm" && (
35 |
36 | )}
37 |
38 | {(breakpoint === "lg" ||
39 | breakpoint === "xl" ||
40 | breakpoint === "2xl") && }
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/content/translations/common/index.ts:
--------------------------------------------------------------------------------
1 | import { CommonTranslations } from "@/types";
2 |
3 | export const commonTranslations = {
4 | getStarted: {
5 | en: "Get started",
6 | ja: "始めましょう",
7 | my: "စတင်လိုက်ပါ",
8 | th: "เริ่ม",
9 | cn: "开始使用",
10 | es: "Empezar",
11 | fr: "Commencer",
12 | hi: "शुरू हो जाओ",
13 | ru: "Начать",
14 | },
15 | iAlreadyHaveAnAccount: {
16 | en: "I already have an account",
17 | ja: "すでにアカウントを持っています",
18 | my: "ကျွန်တော့်မှာ အကောင့်ရှိပြီးသားပါ",
19 | th: "ฉันมีบัญชี",
20 | cn: "我已经有一个帐户",
21 | es: "ya tengo una cuenta",
22 | fr: "Je ai déjà un compte",
23 | hi: "मेरा पहले से ही खाता है",
24 | ru: "у меня уже есть аккаунт",
25 | },
26 | landingPageContent: {
27 | en: "A fun and effective language learning experience that's free!",
28 | ja: "楽しくて効果的な語学学習体験を無料で!",
29 | my: "အခမဲ့ဖြစ်ပြီး ပျော်ရွှင်စရာကောင်းပြီး ထိရောက်သော ဘာသာစကားသင်ယူမှုအတွေ့အကြုံ",
30 | th: "ประสบการณ์การเรียนรู้ภาษาที่สนุกสนานและมีประสิทธิภาพฟรี!",
31 | cn: "有趣且有效的免费语言学习体验!",
32 | es: "¡Una experiencia de aprendizaje de idiomas divertida y efectiva que es gratis!",
33 | fr: "Une expérience d'apprentissage des langues amusante, efficace et gratuite !",
34 | hi: "एक मज़ेदार और प्रभावी भाषा सीखने का अनुभव जो निःशुल्क है!",
35 | ru: "Веселое и эффективное изучение языка бесплатно!",
36 | },
37 | iWantToLearn: {
38 | en: "I want to learn",
39 | ja: "学びたい",
40 | my: "ငါလေ့လာချင်တယ်",
41 | th: "ฉันต้องการที่จะเรียนรู้",
42 | cn: "我想学习",
43 | es: "quiero aprender",
44 | fr: "je veux apprendre",
45 | hi: "मैं सीखना चाहता हूँ",
46 | ru: "я хочу учиться",
47 | },
48 | } satisfies CommonTranslations;
49 |
--------------------------------------------------------------------------------
/components/layouts/main-header.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "expo-router";
2 |
3 | import { Container } from "@/components/container";
4 | import { SelectLanguage } from "@/components/select-language";
5 | import { Text, View, ViewProps } from "@/components/themed";
6 | import { siteConfig } from "@/config/site";
7 | import { layouts } from "@/constants/layouts";
8 | import { useBreakpoint } from "@/context/breakpoints";
9 | import { useTheme } from "@/context/theme";
10 |
11 | export const MAIN_HEADER_HEIGHT = 60;
12 |
13 | interface Props extends ViewProps {}
14 |
15 | export function MainHeader({ style, ...props }: Props) {
16 | const { border } = useTheme();
17 | const breakpoint = useBreakpoint();
18 |
19 | return (
20 |
31 |
32 |
42 |
43 |
49 | {siteConfig.name.toLowerCase()}
50 |
51 |
52 |
53 |
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/components/course-details-bar.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from "@/components/icons";
2 | import { Text, View, ViewProps } from "@/components/themed";
3 | import { layouts } from "@/constants/layouts";
4 | import { useLanguageCode } from "@/context/language";
5 | import { SupportedLanguageCode } from "@/types";
6 |
7 | import { SelectCourse } from "./select-course";
8 |
9 | interface Props extends ViewProps {
10 | courseId: SupportedLanguageCode;
11 | }
12 | export function CourseDetailsBar({ courseId, style, ...props }: Props) {
13 | const { languageCode } = useLanguageCode();
14 | return (
15 |
26 |
27 |
34 |
35 | 356
36 |
37 |
44 |
45 | 500
46 |
47 |
54 |
55 | 5
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "euolingo",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo start --android",
8 | "ios": "expo start --ios",
9 | "web": "expo start --web",
10 | "test": "jest --watchAll",
11 | "format": "prettier --write . --ignore-path .gitignore",
12 | "check:format": "prettier --check . --ignore-path .gitignore",
13 | "check:types": "tsc --pretty --noEmit"
14 | },
15 | "jest": {
16 | "preset": "jest-expo"
17 | },
18 | "dependencies": {
19 | "@expo/match-media": "^0.4.0",
20 | "@react-native-async-storage/async-storage": "1.18.2",
21 | "expo": "~49.0.15",
22 | "expo-av": "~13.4.1",
23 | "expo-font": "~11.4.0",
24 | "expo-image": "~1.3.5",
25 | "expo-router": "^2.0.0",
26 | "expo-splash-screen": "~0.20.5",
27 | "expo-status-bar": "~1.6.0",
28 | "react": "18.2.0",
29 | "react-dom": "18.2.0",
30 | "react-native": "0.72.6",
31 | "react-native-gesture-handler": "~2.12.0",
32 | "react-native-popover-view": "^5.1.8",
33 | "react-native-safe-area-context": "4.6.3",
34 | "react-native-svg": "13.9.0",
35 | "react-native-web": "~0.19.6",
36 | "react-responsive": "^9.0.2"
37 | },
38 | "devDependencies": {
39 | "@babel/core": "^7.20.0",
40 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
41 | "@types/react": "~18.2.14",
42 | "babel-plugin-module-resolver": "^5.0.0",
43 | "jest": "^29.2.1",
44 | "jest-expo": "~49.0.0",
45 | "prettier": "^3.0.3",
46 | "react-test-renderer": "18.2.0",
47 | "ts-node": "^10.9.1",
48 | "typescript": "^5.1.3"
49 | },
50 | "overrides": {
51 | "react-refresh": "~0.14.0"
52 | },
53 | "resolutions": {
54 | "react-refresh": "~0.14.0"
55 | },
56 | "private": true
57 | }
58 |
--------------------------------------------------------------------------------
/content/courses/data/index.ts:
--------------------------------------------------------------------------------
1 | import { Course, CourseProgression, ExerciseSet } from "@/types/course";
2 |
3 | import { characters } from "./characters";
4 | import { sectionOne } from "./sections/1";
5 |
6 | export const courseContent: Course = {
7 | sections: [sectionOne],
8 | characters: characters,
9 | };
10 |
11 | export function getExercise({
12 | sectionId,
13 | chapterId,
14 | lessonId,
15 | exerciseId,
16 | }: CourseProgression): ExerciseSet | null {
17 | const section = courseContent.sections[sectionId];
18 | if (section) {
19 | const chapter = section.chapters[chapterId];
20 | if (chapter) {
21 | const lesson = chapter.lessons[lessonId];
22 | if (lesson && lesson.exercises && lesson.exercises.length > exerciseId) {
23 | return lesson.exercises[exerciseId];
24 | }
25 | }
26 | }
27 | return null;
28 | }
29 |
30 | export function nextProgress(
31 | current: CourseProgression
32 | ): CourseProgression | null {
33 | const { sectionId, chapterId, lessonId, exerciseId } = current;
34 | const section = courseContent.sections[sectionId];
35 | const chapter = section.chapters[chapterId];
36 | const lesson = chapter.lessons[lessonId];
37 | const exercisesCount = lesson.exercises.length;
38 |
39 | if (exerciseId < exercisesCount - 1) {
40 | return { ...current, exerciseId: exerciseId + 1 };
41 | } else if (lessonId < chapter.lessons.length - 1) {
42 | return { ...current, lessonId: lessonId + 1, exerciseId: 0 };
43 | } else if (chapterId < section.chapters.length - 1) {
44 | return { ...current, chapterId: chapterId + 1, lessonId: 0, exerciseId: 0 };
45 | } else if (sectionId < courseContent.sections.length - 1) {
46 | return {
47 | ...current,
48 | sectionId: sectionId + 1,
49 | chapterId: 0,
50 | lessonId: 0,
51 | exerciseId: 0,
52 | };
53 | }
54 |
55 | return null;
56 | }
57 |
--------------------------------------------------------------------------------
/components/layouts/mobile-tabs-bar.tsx:
--------------------------------------------------------------------------------
1 | import { router, usePathname } from "expo-router";
2 | import { Pressable } from "react-native";
3 |
4 | import { Icon } from "@/components/icons";
5 | import { View } from "@/components/themed";
6 | import { colors } from "@/constants/colors";
7 | import { layouts } from "@/constants/layouts";
8 | import { useTheme } from "@/context/theme";
9 | import { NavItem } from "@/types";
10 |
11 | interface Props {
12 | navItems: NavItem[];
13 | }
14 |
15 | export function MobileTabsBar({ navItems }: Props) {
16 | const pathname = usePathname();
17 | const { border, accent, foreground } = useTheme();
18 | return (
19 |
28 | {navItems.map((navItem, index) => {
29 | const isActive =
30 | pathname === navItem.href || pathname.startsWith(navItem.href);
31 | return (
32 | router.push(navItem.href)}>
33 | {({ pressed, hovered }) => (
34 |
44 |
48 |
49 | )}
50 |
51 | );
52 | })}
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import FontAwesome from "@expo/vector-icons/FontAwesome";
3 | import { useFonts } from "expo-font";
4 | import { SplashScreen, Stack } from "expo-router";
5 |
6 | import { StatusBar } from "@/components/status-bar";
7 | import { BreakpointsProvider } from "@/context/breakpoints";
8 | import { CourseProvider } from "@/context/course";
9 | import { LanguageCodeProvider } from "@/context/language";
10 | import { ProtectedRouteProvider } from "@/context/protected-route";
11 | import { ThemeProvider } from "@/context/theme";
12 |
13 | export {
14 | // Catch any errors thrown by the Layout component.
15 | ErrorBoundary,
16 | } from "expo-router";
17 |
18 | export const unstable_settings = {
19 | // Ensure that reloading on `/modal` keeps a back button present.
20 | initialRouteName: "(guest)",
21 | };
22 |
23 | // Prevent the splash screen from auto-hiding before asset loading is complete.
24 | SplashScreen.preventAutoHideAsync();
25 |
26 | export default function RootLayout() {
27 | const [loaded, error] = useFonts({
28 | SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
29 | ...FontAwesome.font,
30 | });
31 |
32 | // Expo Router uses Error Boundaries to catch errors in the navigation tree.
33 | useEffect(() => {
34 | if (error) throw error;
35 | }, [error]);
36 |
37 | useEffect(() => {
38 | if (loaded) {
39 | SplashScreen.hideAsync();
40 | }
41 | }, [loaded]);
42 |
43 | if (!loaded) {
44 | return null;
45 | }
46 |
47 | return (
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/content/courses/items/flashcard/water.ts:
--------------------------------------------------------------------------------
1 | import { FlashCardExercise } from "@/types/course";
2 |
3 | export const waterFlashCard: FlashCardExercise = {
4 | id: 1,
5 | type: "flashCard",
6 | question: {
7 | en: 'Which one of these is "water"?',
8 | ja: "この中で「水」はどれ?",
9 | my: 'အဲဒီထဲက ဘယ်ဟာက "ရေ" လဲ။',
10 | th: 'ข้อใดคือ "น้ำ"?',
11 | cn: "其中哪一项是“水”?",
12 | es: '¿Cuál de estos es "agua"?',
13 | fr: "Lequel de ces éléments est « l’eau » ?",
14 | hi: 'इनमें से कौन सा "पानी" है?',
15 | ru: "Что из этого является «водой»?",
16 | },
17 | words: [
18 | {
19 | id: 0,
20 | content: {
21 | en: "tea",
22 | ja: "おちゃ",
23 | my: "လက်ဖက်ရည်",
24 | th: "ชา",
25 | cn: "茶",
26 | es: "té",
27 | fr: "thé",
28 | hi: "चाय",
29 | ru: "чай",
30 | },
31 | image: "https://www.svgrepo.com/show/475139/tea.svg",
32 | },
33 | {
34 | id: 1,
35 | content: {
36 | en: "water",
37 | ja: "みず",
38 | my: "ရေ",
39 | th: "น้ำ",
40 | cn: "水",
41 | es: "agua",
42 | fr: "eau",
43 | hi: "पानी",
44 | ru: "вода",
45 | },
46 | image: "https://www.svgrepo.com/show/218416/water.svg",
47 | },
48 | {
49 | id: 2,
50 | content: {
51 | en: "sushi",
52 | ja: "すし",
53 | my: "ဆူရှီ",
54 | th: "ซูชิ",
55 | cn: "寿司",
56 | es: "Sushi",
57 | fr: "Sushi",
58 | hi: "सुशी",
59 | ru: "суши",
60 | },
61 | image: "https://www.svgrepo.com/show/402766/sushi.svg",
62 | },
63 | {
64 | id: 3,
65 | content: {
66 | en: "rice",
67 | ja: "ごはん",
68 | my: "ထမင်း",
69 | th: "ข้าว",
70 | cn: "米",
71 | es: "arroz",
72 | fr: "riz",
73 | hi: "चावल",
74 | ru: "рис",
75 | },
76 | image: "https://www.svgrepo.com/show/505200/rice.svg",
77 | },
78 | ],
79 | correctWordId: 1,
80 | };
81 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Euolingo: Learning Languages
2 |
3 | Euolingo is a fun and interactive language learning app, designed to make learning a new language enjoyable and effective. Similar to Duolingo, Euolingo offers a user-friendly experience for language enthusiasts of all levels.
4 |
5 | ## Features
6 |
7 | - **Engaging Lessons:** Learn languages through interactive and gamified lessons.
8 | - **Progress Tracking:** Monitor your progress with intuitive statistics and milestones.
9 | - **Rewards and Achievements:** Earn rewards and achievements to stay motivated.
10 | - **Multi-language Support:** Learn a variety of languages.
11 | - **Community:** Connect with fellow learners in our community.
12 |
13 | ## Tech Stack
14 |
15 | - React Native
16 | - Expo
17 | - Firebase
18 |
19 | ## Getting Started
20 |
21 | Follow these steps to get Euolingo up and running on your local machine:
22 |
23 | 1. **Clone the Repository:**
24 |
25 | ```
26 | git clone https://github.com/ikyawthetpaing/euolingo.git
27 | cd euolingo
28 | ```
29 |
30 | 2. **Install Dependencies:**
31 |
32 | ```
33 | npm install
34 | ```
35 |
36 | 3. **Set Up Firebase:**
37 |
38 | - Create a Firebase project at [https://console.firebase.google.com/](https://console.firebase.google.com/).
39 | - Add your Firebase configuration in `firebase.js`.
40 |
41 | 4. **Start the App:**
42 |
43 | ```
44 | expo start
45 | ```
46 |
47 | 5. **Connect a Device or Emulator:**
48 | - Install the Expo Go app on your mobile device.
49 | - Use Expo CLI to run the app on an emulator.
50 |
51 | ## Contributing
52 |
53 | We welcome contributions from the community! If you'd like to improve Euolingo, please check out our [Contribution Guidelines](CONTRIBUTING.md).
54 |
55 | ## License
56 |
57 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
58 |
59 | ## Contact
60 |
61 | For questions or support, please contact [ikyawthetpaing@gmail.com](mailto:ikyawthetpaing@gmail.com).
62 |
63 | Happy Learning with Euolingo! 🌍📚🗣️
64 |
--------------------------------------------------------------------------------
/context/breakpoints.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useEffect, useState } from "react";
2 | import { Dimensions } from "react-native";
3 |
4 | import { isWeb } from "@/lib/utils";
5 |
6 | type Breakpoints = "sm" | "md" | "lg" | "xl" | "2xl";
7 |
8 | const BreakpointsContext = createContext(undefined);
9 |
10 | export function useBreakpoint() {
11 | const context = useContext(BreakpointsContext);
12 | if (!context) {
13 | throw new Error("useBreakpoint must be used within a BreakpointsProvider");
14 | }
15 | return context;
16 | }
17 |
18 | interface Props {
19 | children: React.ReactNode;
20 | }
21 |
22 | function useDevice() {
23 | const breakpoints = [
24 | { name: "sm", maxWidth: 640 },
25 | { name: "md", maxWidth: 768 },
26 | { name: "lg", maxWidth: 1024 },
27 | { name: "xl", maxWidth: 1280 },
28 | { name: "2xl", maxWidth: 1536 },
29 | ];
30 |
31 | const getActiveBreakpoint = () => {
32 | const screenWidth = isWeb()
33 | ? window.innerWidth
34 | : Dimensions.get("window").width;
35 |
36 | const matchingBreakpoint = breakpoints.find(
37 | (breakpoint) => screenWidth <= breakpoint.maxWidth
38 | );
39 | return matchingBreakpoint
40 | ? (matchingBreakpoint.name as Breakpoints)
41 | : (breakpoints[breakpoints.length - 1].name as Breakpoints);
42 | };
43 |
44 | const [activeBreakpoint, setActiveBreakpoint] = useState(
45 | getActiveBreakpoint()
46 | );
47 |
48 | useEffect(() => {
49 | const handleResize = () => {
50 | const newBreakpoint = getActiveBreakpoint();
51 | if (newBreakpoint !== activeBreakpoint) {
52 | setActiveBreakpoint(newBreakpoint);
53 | }
54 | };
55 |
56 | if (isWeb()) {
57 | window.addEventListener("resize", handleResize);
58 | return () => {
59 | window.removeEventListener("resize", handleResize);
60 | };
61 | } else {
62 | const { Dimensions } = require("react-native");
63 | Dimensions.addEventListener("change", handleResize);
64 | return () => {
65 | Dimensions.removeEventListener("change", handleResize);
66 | };
67 | }
68 | }, [activeBreakpoint]);
69 |
70 | return activeBreakpoint;
71 | }
72 |
73 | export function BreakpointsProvider({ children }: Props) {
74 | const breakpoint = useDevice();
75 |
76 | return (
77 |
78 | {children}
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/types/course.d.ts:
--------------------------------------------------------------------------------
1 | import { AVPlaybackSource } from "expo-av";
2 |
3 | import { SupportedLanguageCode, Translations } from "@/types";
4 |
5 | export interface ExerciseItemProps {
6 | onResult: (sucess: boolean) => void;
7 | onContinue: () => void;
8 | }
9 |
10 | export interface Exercise {
11 | id: number;
12 | question: Translations;
13 | type: "flashCard" | "translate";
14 | }
15 |
16 | export interface ExerciseWord {
17 | content: Translations;
18 | audio?: AudioSources;
19 | }
20 |
21 | export interface FlashCardExerciseWord extends ExerciseWord {
22 | id: number;
23 | image: string;
24 | }
25 |
26 | export interface FlashCardExercise extends Exercise {
27 | words: FlashCardExerciseWord[];
28 | correctWordId: number;
29 | }
30 |
31 | export interface TranslateExerciseWord extends ExerciseWord {}
32 |
33 | export type TranslateExerciseOption = {
34 | id: number;
35 | word: TranslateExerciseWord;
36 | };
37 |
38 | export interface TranslateExercise extends Exercise {
39 | sentence: TranslateExerciseWord;
40 | options: TranslateExerciseOption[];
41 | correctOrderIds: { [key in SupportedLanguageCode]: number[] };
42 | }
43 |
44 | export type ExerciseItemVariant = FlashCardExercise | TranslateExercise;
45 |
46 | export type ExerciseSet = {
47 | id: number;
48 | xp: number;
49 | difficulty: "easy" | "medium" | "hard";
50 | items: ExerciseItemVariant[];
51 | };
52 |
53 | export type Lesson = {
54 | id: number;
55 | description: Translations;
56 | exercises: ExerciseSet[];
57 | };
58 |
59 | export type Chapter = {
60 | id: number;
61 | title: Translations;
62 | description: Translations;
63 | lessons: Lesson[];
64 | };
65 |
66 | export type Section = {
67 | id: number;
68 | title: Translations;
69 | chapters: Chapter[];
70 | };
71 |
72 | export type Character = {
73 | role: string;
74 | dialogueItems: string[];
75 | };
76 |
77 | export type LanguageCharacters = {
78 | [key in SupportedLanguageCode]: Character[];
79 | };
80 |
81 | export type Course = {
82 | sections: Section[];
83 | characters: LanguageCharacters;
84 | };
85 |
86 | export type AudioSources = {
87 | [key in SupportedLanguageCode]: AVPlaybackSource;
88 | };
89 |
90 | export type CourseAudios = {
91 | [key: string]: AudioSources;
92 | };
93 |
94 | export type CourseProgression = {
95 | sectionId: number;
96 | chapterId: number;
97 | lessonId: number;
98 | exerciseId: number;
99 | };
100 |
--------------------------------------------------------------------------------
/content/courses/items/translate/sushi-please.ts:
--------------------------------------------------------------------------------
1 | import { TranslateExercise } from "@/types/course";
2 |
3 | export const sushiPleaseTranslate: TranslateExercise = {
4 | id: 0,
5 | type: "translate",
6 | question: {
7 | en: "Translate this sentence",
8 | ja: "この文を翻訳してください",
9 | my: "ဒီစာကြောင်းကို ဘာသာပြန်ပါ။",
10 | th: "แปลประโยคนี้",
11 | cn: "翻译这句话",
12 | es: "Traducir esta frase",
13 | fr: "Traduisez cette phrase",
14 | hi: "इस वाक्य का अनुवाद करें",
15 | ru: "Переведите это предложение",
16 | },
17 | sentence: {
18 | content: {
19 | en: "Sushi, please",
20 | ja: "お寿司をください",
21 | my: "ကျေးဇူးပြုပြီး ဆူရှီ",
22 | th: "ซูชิโปรด",
23 | cn: "寿司,请",
24 | es: "sushi, por favor",
25 | fr: "Des sushis, s'il vous plaît",
26 | hi: "सुशी, कृपया",
27 | ru: "Суши, пожалуйста",
28 | },
29 | },
30 | options: [
31 | {
32 | id: 0,
33 | word: {
34 | content: {
35 | en: "tea",
36 | ja: "おちゃ",
37 | my: "လက်ဖက်ရည်",
38 | th: "ชา",
39 | cn: "茶",
40 | es: "té",
41 | fr: "thé",
42 | hi: "चाय",
43 | ru: "чай",
44 | },
45 | },
46 | },
47 | {
48 | id: 1,
49 | word: {
50 | content: {
51 | en: "sushi",
52 | ja: "すし",
53 | my: "ဆူရှီ",
54 | th: "ซูชิ",
55 | cn: "寿司",
56 | es: "Sushi",
57 | fr: "Sushi",
58 | hi: "सुशी",
59 | ru: "суши",
60 | },
61 | },
62 | },
63 | {
64 | id: 2,
65 | word: {
66 | content: {
67 | en: "please",
68 | ja: "ください",
69 | my: "ကျေးဇူးပြုပြီး",
70 | th: "โปรด",
71 | cn: "请",
72 | es: "por favor",
73 | fr: "s'il te plaît",
74 | hi: "कृपया",
75 | ru: "пожалуйста",
76 | },
77 | },
78 | },
79 | {
80 | id: 3,
81 | word: {
82 | content: {
83 | en: "water",
84 | ja: "みず",
85 | my: "ရေ",
86 | th: "น้ำ",
87 | cn: "水",
88 | es: "agua",
89 | fr: "eau",
90 | hi: "पानी",
91 | ru: "вода",
92 | },
93 | },
94 | },
95 | ],
96 | correctOrderIds: {
97 | en: [1, 2],
98 | ja: [1, 2],
99 | my: [2, 1],
100 | th: [1, 2],
101 | cn: [1, 2],
102 | es: [1, 2],
103 | fr: [1, 2],
104 | hi: [1, 2],
105 | ru: [1, 2],
106 | },
107 | };
108 |
--------------------------------------------------------------------------------
/context/language.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | Dispatch,
4 | SetStateAction,
5 | useContext,
6 | useEffect,
7 | useState,
8 | } from "react";
9 |
10 | import { validLanguages } from "@/config/language";
11 | import { DEFAULT_LANGUAGE_CODE } from "@/constants/default";
12 | import { LANGUAGE_ID_STORAGE_KEY } from "@/constants/storage-key";
13 | import { getLocalData, setLocalData } from "@/lib/local-storage";
14 | import { SupportedLanguageCode } from "@/types";
15 |
16 | type LanguageCodeContextType = {
17 | languageCode: SupportedLanguageCode;
18 | setLanguageCode: Dispatch>;
19 | };
20 |
21 | const LanguageCodeContext = createContext(
22 | undefined
23 | );
24 |
25 | export function useLanguageCode() {
26 | const context = useContext(LanguageCodeContext);
27 | if (!context) {
28 | throw new Error("useLanguage must be used within a LanguageProvider");
29 | }
30 | return context;
31 | }
32 |
33 | interface Props {
34 | children: React.ReactNode;
35 | }
36 |
37 | export function LanguageCodeProvider({ children }: Props) {
38 | const [language, setLanguage] = useState(
39 | DEFAULT_LANGUAGE_CODE
40 | );
41 | const [isInitialized, setIsInitialized] = useState(false);
42 |
43 | useEffect(() => {
44 | const initializeLanguage = async () => {
45 | try {
46 | let languageKey = await getLocalData(LANGUAGE_ID_STORAGE_KEY);
47 |
48 | // Validate if the stored languageKey is a valid language
49 | if (
50 | languageKey &&
51 | validLanguages.includes(languageKey as SupportedLanguageCode)
52 | ) {
53 | setLanguage(languageKey as SupportedLanguageCode);
54 | } else {
55 | // If languageKey is not valid, set the default language to "en"
56 | setLanguage(DEFAULT_LANGUAGE_CODE);
57 | await setLocalData(LANGUAGE_ID_STORAGE_KEY, DEFAULT_LANGUAGE_CODE);
58 | }
59 | } catch (error) {
60 | console.error("Error fetching language:", error);
61 | throw error;
62 | } finally {
63 | setIsInitialized(true);
64 | }
65 | };
66 |
67 | initializeLanguage();
68 | }, []);
69 |
70 | useEffect(() => {
71 | if (isInitialized) {
72 | setLocalData(LANGUAGE_ID_STORAGE_KEY, language);
73 | }
74 | }, [language, isInitialized]);
75 |
76 | const languageContextValue: LanguageCodeContextType = {
77 | languageCode: language,
78 | setLanguageCode: setLanguage,
79 | };
80 |
81 | return (
82 |
83 | {isInitialized && children}
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Modal, Pressable, StyleProp, ViewStyle } from "react-native";
3 |
4 | import { layouts } from "@/constants/layouts";
5 | import { useTheme } from "@/context/theme";
6 | import { changeColorOpacity } from "@/lib/utils";
7 |
8 | import { Text, View } from "../themed";
9 |
10 | interface Props {
11 | trigger: React.ReactNode;
12 | children: React.ReactNode;
13 | title?: string;
14 | contentContainerStyle?: StyleProp;
15 | }
16 |
17 | export function Dialog({
18 | children,
19 | trigger,
20 | title,
21 | contentContainerStyle,
22 | }: Props) {
23 | const { border, background, mutedForeground } = useTheme();
24 | const [isVisible, setIsVisible] = useState(false);
25 | const openModal = () => setIsVisible(true);
26 | const closeModal = () => setIsVisible(false);
27 | return (
28 | <>
29 | {trigger}
30 |
36 |
46 |
47 |
56 | {title && (
57 |
64 |
72 | Settings
73 |
74 |
75 | )}
76 |
79 | {children}
80 |
81 |
82 |
83 |
84 |
85 | >
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/components/layouts/course-left-bar.tsx:
--------------------------------------------------------------------------------
1 | import { Link, router, usePathname } from "expo-router";
2 | import { Pressable } from "react-native";
3 |
4 | import { Icon, Icons } from "@/components/icons";
5 | import { Text, View } from "@/components/themed";
6 | import { colors } from "@/constants/colors";
7 | import { layouts } from "@/constants/layouts";
8 | import { useBreakpoint } from "@/context/breakpoints";
9 | import { useTheme } from "@/context/theme";
10 | import { NavItem } from "@/types";
11 |
12 | interface Props {
13 | navItems: NavItem[];
14 | appName: string;
15 | }
16 |
17 | export function CourseLeftBar({ navItems, appName }: Props) {
18 | const { border, accent, foreground } = useTheme();
19 | const breakpoint = useBreakpoint();
20 | const pathname = usePathname();
21 |
22 | return (
23 |
31 |
38 | {breakpoint == "xl" || breakpoint == "2xl" ? (
39 |
40 | ) : (
41 |
48 | {appName.charAt(0).toLowerCase()}
49 |
50 | )}
51 |
52 | {navItems.map((navItem, index) => {
53 | const isActive =
54 | pathname === navItem.href || pathname.startsWith(navItem.href);
55 | return (
56 | router.push(navItem.href)}>
57 | {({ pressed, hovered }) => (
58 |
75 |
79 | {(breakpoint == "xl" || breakpoint == "2xl") && (
80 |
86 | {navItem.label}
87 |
88 | )}
89 |
90 | )}
91 |
92 | );
93 | })}
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/components/layouts/course-right-bar.tsx:
--------------------------------------------------------------------------------
1 | import { CourseDetailsBar } from "@/components/course-details-bar";
2 | import { Icon } from "@/components/icons";
3 | import { Text, View } from "@/components/themed";
4 | import { Button } from "@/components/ui/button";
5 | import { layouts } from "@/constants/layouts";
6 | import { useTheme } from "@/context/theme";
7 | import { SupportedLanguageCode } from "@/types";
8 |
9 | interface Props {
10 | courseId: SupportedLanguageCode;
11 | }
12 |
13 | export function CourseRightBar({ courseId }: Props) {
14 | const { border, muted, mutedForeground } = useTheme();
15 | return (
16 |
25 |
26 |
35 |
41 | Daily Quests
42 |
49 | View all
50 |
51 |
52 |
53 |
54 |
55 | Earn 10 XP
56 |
57 |
58 |
68 |
69 | 0 / 10
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
87 |
94 | Create a profile to save your progress!
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Pressable,
3 | PressableProps,
4 | StyleProp,
5 | StyleSheet,
6 | Text,
7 | TextStyle,
8 | View,
9 | ViewStyle,
10 | } from "react-native";
11 |
12 | import { colors } from "@/constants/colors";
13 | import { layouts } from "@/constants/layouts";
14 | import { useTheme } from "@/context/theme";
15 | import { changeColorOpacity } from "@/lib/utils";
16 |
17 | type Variant = "default" | "outline" | "ghost";
18 |
19 | export interface ButtonProps extends PressableProps {
20 | children?: React.ReactNode;
21 | variant?: Variant;
22 | viewStyle?: StyleProp;
23 | textStyle?: StyleProp;
24 | }
25 |
26 | export function Button({
27 | children,
28 | variant = "default",
29 | viewStyle,
30 | textStyle,
31 | ...props
32 | }: ButtonProps) {
33 | const { foreground, primaryForeground, mutedForeground, accentForeground } =
34 | useTheme();
35 |
36 | const isJustText = typeof children === "string";
37 |
38 | return (
39 |
40 | {({ pressed, hovered }) => (
41 |
52 | {isJustText ? (
53 |
65 | {children}
66 |
67 | ) : (
68 | children
69 | )}
70 |
71 | )}
72 |
73 | );
74 | }
75 |
76 | const useThemedStyles = ({
77 | variant,
78 | hovered,
79 | pressed,
80 | disabled,
81 | }: {
82 | variant: Variant;
83 | pressed: boolean;
84 | hovered: boolean;
85 | disabled?: boolean | null;
86 | }) => {
87 | const { background, border, primary, accent, muted, accentForeground } =
88 | useTheme();
89 |
90 | const styles = StyleSheet.create({
91 | common: {
92 | backgroundColor: background,
93 | alignItems: "center",
94 | padding: layouts.padding,
95 | borderRadius: layouts.padding,
96 | transitionDelay: "100ms",
97 | },
98 | default: {
99 | backgroundColor:
100 | hovered || pressed ? changeColorOpacity(primary, 0.75) : primary,
101 | },
102 | outline: {
103 | borderWidth: layouts.borderWidth,
104 | borderColor: border,
105 | backgroundColor: hovered || pressed ? accent : colors.transparent,
106 | },
107 | ghost: {
108 | backgroundColor:
109 | pressed || hovered
110 | ? changeColorOpacity(accentForeground, 0.15)
111 | : colors.transparent,
112 | },
113 | });
114 |
115 | const variantStyles =
116 | variant === "default"
117 | ? styles.default
118 | : variant === "outline"
119 | ? styles.outline
120 | : variant === "ghost"
121 | ? styles.ghost
122 | : {};
123 |
124 | const themedStyles = {
125 | ...styles.common,
126 | ...variantStyles,
127 | ...(pressed && { transform: "scale(0.98)" }),
128 | ...(disabled && { backgroundColor: muted }),
129 | };
130 | return themedStyles;
131 | };
132 |
--------------------------------------------------------------------------------
/app/(course)/characters.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Pressable, ScrollView } from "react-native";
3 |
4 | import { Text, View } from "@/components/themed";
5 | import { layouts } from "@/constants/layouts";
6 | import { courseContent } from "@/content/courses/data";
7 | import { useBreakpoint } from "@/context/breakpoints";
8 | import { useCourse } from "@/context/course";
9 | import { useTheme } from "@/context/theme";
10 |
11 | export default function Characters() {
12 | const { courseId } = useCourse();
13 | const breakpoint = useBreakpoint();
14 | const { mutedForeground, border, foreground } = useTheme();
15 | const [activeIndex, setActiveIndex] = useState(0);
16 | const [containerWidth, setContainerWidth] = useState(0);
17 |
18 | if (!courseId) return null;
19 |
20 | const characters = courseContent.characters[courseId];
21 |
22 | return (
23 |
24 |
29 | {characters.map(({ role }, index) => (
30 | (activeIndex !== index ? setActiveIndex(index) : {})}
45 | >
46 |
55 | {role}
56 |
57 |
58 | ))}
59 |
60 | setContainerWidth(e.nativeEvent.layout.width)}
62 | contentContainerStyle={{
63 | flexDirection: "row",
64 | flexWrap: "wrap",
65 | padding: breakpoint === "sm" ? layouts.padding : layouts.padding * 2,
66 | gap: breakpoint === "sm" ? layouts.padding / 2 : layouts.padding,
67 | justifyContent: "center",
68 | }}
69 | showsVerticalScrollIndicator={false}
70 | >
71 | {characters[activeIndex].dialogueItems.map((item, index) => {
72 | const size =
73 | breakpoint === "sm"
74 | ? (containerWidth -
75 | ((layouts.padding / 2) * 4 + layouts.padding * 2)) /
76 | 5
77 | : (containerWidth -
78 | (layouts.padding * 4 + layouts.padding * 2.0079 * 2)) /
79 | 5;
80 |
81 | return (
82 |
94 |
95 | {item}
96 |
97 |
98 | );
99 | })}
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/components/select-language.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Image } from "expo-image";
3 | import { Pressable } from "react-native";
4 | import Popover from "react-native-popover-view/dist/Popover";
5 | import { Placement } from "react-native-popover-view/dist/Types";
6 |
7 | import { Text, View } from "@/components/themed";
8 | import { getLanguage, languages } from "@/config/language";
9 | import { layouts } from "@/constants/layouts";
10 | import { useLanguageCode } from "@/context/language";
11 | import { useTheme } from "@/context/theme";
12 | import { SupportedLanguageCode } from "@/types";
13 |
14 | interface Props {
15 | excludes?: SupportedLanguageCode[];
16 | }
17 |
18 | export function SelectLanguage({ excludes }: Props) {
19 | const { border, mutedForeground, accent, background } = useTheme();
20 | const { languageCode, setLanguageCode } = useLanguageCode();
21 | const [isVisiable, setIsVisiable] = useState(false);
22 |
23 | return (
24 | setIsVisiable(false)}
28 | popoverStyle={{
29 | borderRadius: layouts.padding,
30 | backgroundColor: border,
31 | }}
32 | backgroundStyle={{
33 | backgroundColor: background,
34 | opacity: 0.5,
35 | }}
36 | from={
37 | setIsVisiable(!isVisiable)}
44 | >
45 |
46 |
54 | {getLanguage(languageCode)?.name}
55 |
56 |
57 |
58 | }
59 | >
60 |
69 | {Object.keys(languages).map((key, index) => {
70 | const code = key as SupportedLanguageCode;
71 | const language = languages[code];
72 |
73 | if (excludes?.includes(code)) {
74 | return null;
75 | }
76 |
77 | return (
78 | {
81 | setIsVisiable(false);
82 | setLanguageCode(code);
83 | }}
84 | >
85 | {({ hovered, pressed }) => (
86 |
95 |
105 | {language.name}
106 |
107 | )}
108 |
109 | );
110 | })}
111 |
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/app/+html.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollViewStyleReset } from "expo-router/html";
2 |
3 | import { siteConfig } from "@/config/site";
4 |
5 | // This file is web-only and used to configure the root HTML for every
6 | // web page during static rendering.
7 | // The contents of this function only run in Node.js environments and
8 | // do not have access to the DOM or browser APIs.
9 | export default function Root({ children }: { children: React.ReactNode }) {
10 | return (
11 |
12 |
13 |
14 |
15 | {/*
16 | This viewport disables scaling which makes the mobile website act more like a native app.
17 | However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
18 |
19 | */}
20 |
24 | {/*
25 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
26 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
27 | */}
28 |
29 |
30 | {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
31 |
32 | {/* Add any additional elements that you want globally available on web... */}
33 |
34 | {/* added */}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
60 |
66 |
72 |
73 |
74 | {children}
75 |
76 | );
77 | }
78 |
79 | const responsiveBackground = `
80 | body {
81 | background-color: #fff;
82 | }
83 | @media (prefers-color-scheme: dark) {
84 | body {
85 | background-color: #000;
86 | }
87 | }`;
88 |
--------------------------------------------------------------------------------
/components/exercise/items/exercise-item-event.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from "@/components/icons";
2 | import { Text, View } from "@/components/themed";
3 | import { Button } from "@/components/ui/button";
4 | import { colors } from "@/constants/colors";
5 | import { layouts } from "@/constants/layouts";
6 | import { useTheme } from "@/context/theme";
7 |
8 | interface Props {
9 | correctAnswer: string;
10 | isSuccess: boolean | null;
11 | checkButtonDisabled: boolean;
12 | onPressCheck: () => void;
13 | onPressContinue: () => void;
14 | }
15 |
16 | export function ExerciseItemEvent({
17 | isSuccess,
18 | correctAnswer,
19 | onPressCheck,
20 | onPressContinue,
21 | checkButtonDisabled,
22 | }: Props) {
23 | const { destructive, destructiveForeground, sucess, sucessForeground } =
24 | useTheme();
25 |
26 | return (
27 |
46 | {isSuccess !== null ? (
47 |
53 |
59 |
67 |
71 |
78 | {isSuccess ? "Excellect" : "Incorrect"}
79 |
80 |
81 | {isSuccess === false && (
82 |
89 |
96 | Correct Answer:
97 |
98 |
104 | {correctAnswer}
105 |
106 |
107 | )}
108 |
109 |
122 |
123 | ) : (
124 |
127 | )}
128 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/components/select-course.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Image } from "expo-image";
3 | import { Pressable } from "react-native";
4 | import Popover from "react-native-popover-view/dist/Popover";
5 | import { Placement } from "react-native-popover-view/dist/Types";
6 |
7 | import { Text, View } from "@/components/themed";
8 | import { getLanguage, languages } from "@/config/language";
9 | import { colors } from "@/constants/colors";
10 | import { layouts } from "@/constants/layouts";
11 | import { useCourse } from "@/context/course";
12 | import { useTheme } from "@/context/theme";
13 | import { SupportedLanguageCode } from "@/types";
14 |
15 | interface Props {
16 | excludes?: SupportedLanguageCode[];
17 | }
18 |
19 | export function SelectCourse({ excludes }: Props) {
20 | const { border, accent, background, mutedForeground } = useTheme();
21 | const [isVisiable, setIsVisiable] = useState(false);
22 | const { courseId, setCourseId } = useCourse();
23 |
24 | if (!courseId) return null;
25 |
26 | return (
27 | setIsVisiable(false)}
31 | popoverStyle={{
32 | borderRadius: layouts.padding,
33 | backgroundColor: border,
34 | }}
35 | backgroundStyle={{
36 | backgroundColor: background,
37 | opacity: 0.5,
38 | }}
39 | from={
40 | setIsVisiable(!isVisiable)}
47 | >
48 |
55 |
60 |
61 |
62 | }
63 | >
64 |
73 |
80 |
88 | Select Course
89 |
90 |
91 | {Object.keys(languages).map((key, index) => {
92 | const code = key as SupportedLanguageCode;
93 | const language = languages[code];
94 |
95 | if (excludes?.includes(code)) {
96 | return null;
97 | }
98 |
99 | return (
100 | {
103 | setIsVisiable(false);
104 | setCourseId(code);
105 | }}
106 | >
107 | {({ hovered, pressed }) => (
108 |
117 |
124 |
129 |
130 | {language.name}
131 |
132 | )}
133 |
134 | );
135 | })}
136 |
137 |
138 | );
139 | }
140 |
--------------------------------------------------------------------------------
/components/exercise/screens/exercise-outro.tsx:
--------------------------------------------------------------------------------
1 | import { router } from "expo-router";
2 | import { useWindowDimensions } from "react-native";
3 |
4 | import { Container } from "@/components/container";
5 | import { Icon } from "@/components/icons";
6 | import { Shell } from "@/components/shell";
7 | import { Text, View } from "@/components/themed";
8 | import { Button } from "@/components/ui/button";
9 | import { layouts } from "@/constants/layouts";
10 | import { nextProgress } from "@/content/courses/data";
11 | import { useBreakpoint } from "@/context/breakpoints";
12 | import { useCourse } from "@/context/course";
13 | import { useTheme } from "@/context/theme";
14 | import { IconName } from "@/types";
15 |
16 | interface Props {
17 | xp: number;
18 | duration: string;
19 | target: string;
20 | increaseProgress: boolean;
21 | }
22 |
23 | const exerciseResults: {
24 | icon: IconName;
25 | type: keyof Pick;
26 | title: string;
27 | }[] = [
28 | {
29 | icon: "bolt",
30 | type: "xp",
31 | title: "Total xp",
32 | },
33 | {
34 | icon: "clockCircle",
35 | type: "duration",
36 | title: "Speedy",
37 | },
38 | {
39 | icon: "targetCircle",
40 | type: "target",
41 | title: "Good",
42 | },
43 | ];
44 |
45 | export default function LessonOutrolayout(props: Props) {
46 | const { foreground, background } = useTheme();
47 | const breakpoint = useBreakpoint();
48 | const layout = useWindowDimensions();
49 | const { courseProgress, setCourseProgress } = useCourse();
50 |
51 | const onContinue = () => {
52 | if (props.increaseProgress) {
53 | const nextCourseProgress = nextProgress(courseProgress);
54 | if (nextCourseProgress) {
55 | setCourseProgress(nextCourseProgress);
56 | }
57 | }
58 | router.push("/learn");
59 | };
60 |
61 | return (
62 |
63 |
64 |
72 |
73 | Practice complete!
74 |
75 |
82 | {exerciseResults.map((result, index) => (
83 |
99 |
109 | {result.title}
110 |
111 |
119 |
126 |
127 |
134 | {props[result.type]}
135 |
136 |
137 |
138 |
139 | ))}
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 | );
148 | }
149 |
--------------------------------------------------------------------------------
/app/(guest)/register.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Image } from "expo-image";
3 | import { router } from "expo-router";
4 | import { Pressable, ScrollView } from "react-native";
5 |
6 | import { Container } from "@/components/container";
7 | import { Metadata } from "@/components/metadata";
8 | import { Text, View } from "@/components/themed";
9 | import { languages } from "@/config/language";
10 | import { colors } from "@/constants/colors";
11 | import { layouts } from "@/constants/layouts";
12 | import { getCommonTranslation } from "@/content/translations";
13 | import { useBreakpoint } from "@/context/breakpoints";
14 | import { useCourse } from "@/context/course";
15 | import { useLanguageCode } from "@/context/language";
16 | import { useTheme } from "@/context/theme";
17 | import { SupportedLanguageCode } from "@/types";
18 |
19 | export default function Register() {
20 | const { border, accent, background, mutedForeground } = useTheme();
21 | const breakpoint = useBreakpoint();
22 | const { languageCode } = useLanguageCode();
23 | const { setCourseId } = useCourse();
24 | const [containerWidth, setContainerWidth] = useState(0);
25 |
26 | return (
27 | <>
28 |
29 |
30 |
31 |
38 |
39 |
42 | {getCommonTranslation("iWantToLearn", languageCode)}
43 |
44 | setContainerWidth(e.nativeEvent.layout.width)}
46 | style={{
47 | flexDirection: "row",
48 | gap: layouts.padding,
49 | flexWrap: "wrap",
50 | justifyContent: "center",
51 | paddingBottom:
52 | breakpoint === "sm" ? layouts.padding : layouts.padding * 2,
53 | }}
54 | >
55 | {Object.keys(languages).map((key, index) => {
56 | const code = key as SupportedLanguageCode;
57 | if (languageCode === code) return null;
58 | const language = languages[code];
59 |
60 | const cols = breakpoint === "sm" ? 2 : 4;
61 | const size =
62 | (containerWidth - layouts.padding * (cols - 1)) / cols;
63 |
64 | return (
65 | {
71 | setCourseId(code);
72 | router.push("/learn");
73 | }}
74 | >
75 | {({ pressed, hovered }) => (
76 |
88 |
97 |
101 |
102 |
109 | {language.name}
110 |
111 |
112 | )}
113 |
114 | );
115 | })}
116 |
117 |
118 |
119 |
120 |
121 | >
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/app/(guest)/index.tsx:
--------------------------------------------------------------------------------
1 | import { Image } from "expo-image";
2 | import { router } from "expo-router";
3 | import { ScrollView, useWindowDimensions } from "react-native";
4 |
5 | import { Container } from "@/components/container";
6 | import { MAIN_HEADER_HEIGHT } from "@/components/layouts/main-header";
7 | import { Metadata } from "@/components/metadata";
8 | import { Text, View } from "@/components/themed";
9 | import { Button } from "@/components/ui/button";
10 | import { layouts } from "@/constants/layouts";
11 | import { getCommonTranslation } from "@/content/translations";
12 | import { useBreakpoint } from "@/context/breakpoints";
13 | import { useLanguageCode } from "@/context/language";
14 |
15 | export default function Home() {
16 | const breakpoint = useBreakpoint();
17 | const { languageCode: language } = useLanguageCode();
18 | const screen = useWindowDimensions();
19 |
20 | return (
21 | <>
22 |
23 |
24 |
25 |
33 | {breakpoint === "sm" ? (
34 |
35 |
41 |
42 |
48 |
49 |
56 | {getCommonTranslation("landingPageContent", language)}
57 |
58 |
59 |
64 |
67 |
70 |
71 |
72 | ) : (
73 |
80 |
86 |
87 |
93 |
94 |
95 |
96 |
97 |
104 | {getCommonTranslation("landingPageContent", language)}
105 |
106 |
113 |
116 |
122 |
123 |
124 |
125 |
126 | )}
127 |
128 |
129 |
130 | >
131 | );
132 | }
133 |
--------------------------------------------------------------------------------
/components/exercise/screens/exercise.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState } from "react";
2 | import { router } from "expo-router";
3 |
4 | import { sound } from "@/assets/audios/sound";
5 | import { Container } from "@/components/container";
6 | import ExerciseItems from "@/components/exercise/items/exercise-items";
7 | import LessonOutroScreen from "@/components/exercise/screens/exercise-outro";
8 | import { Icon } from "@/components/icons";
9 | import { SelectLanguage } from "@/components/select-language";
10 | import { Shell } from "@/components/shell";
11 | import { Text, View } from "@/components/themed";
12 | import { Button } from "@/components/ui/button";
13 | import { Dialog } from "@/components/ui/dialog";
14 | import { layouts } from "@/constants/layouts";
15 | import { useBreakpoint } from "@/context/breakpoints";
16 | import { useCourse } from "@/context/course";
17 | import { useTheme } from "@/context/theme";
18 | import { useAudio } from "@/hooks/audio";
19 | import { calculatePrecentage, shuffleArray } from "@/lib/utils";
20 | import { ExerciseSet } from "@/types/course";
21 |
22 | interface Props {
23 | exercise: ExerciseSet;
24 | increaseProgress: boolean;
25 | }
26 |
27 | export default function ExerciseScreen({ exercise, increaseProgress }: Props) {
28 | const shuffledExerciseItems = useMemo(
29 | () => shuffleArray(exercise.items),
30 | [exercise.items]
31 | );
32 | const totalExerciseItems = shuffledExerciseItems.length;
33 |
34 | const { courseId } = useCourse();
35 | const { accent, foreground, mutedForeground } = useTheme();
36 | const breakpoint = useBreakpoint();
37 |
38 | const { playSound: playCorrectSound } = useAudio({ source: sound.correct });
39 | const { playSound: playWrongSound } = useAudio({ source: sound.wrong });
40 |
41 | const [currentIndex, setCurrentIndex] = useState(0);
42 | const [finishedCount, setFinishedCount] = useState(0);
43 | const [isFinished, setIsFinished] = useState(false);
44 |
45 | const onResult = (success: boolean) => {
46 | if (finishedCount < totalExerciseItems) {
47 | setFinishedCount(finishedCount + 1);
48 | if (success) {
49 | playCorrectSound();
50 | } else {
51 | playWrongSound();
52 | }
53 | }
54 | };
55 |
56 | const onContinue = () => {
57 | if (currentIndex < totalExerciseItems - 1) {
58 | setCurrentIndex(currentIndex + 1);
59 | } else {
60 | setIsFinished(true);
61 | }
62 | };
63 |
64 | if (!courseId) return null;
65 |
66 | if (isFinished) {
67 | return (
68 |
74 | );
75 | }
76 |
77 | return (
78 |
79 |
80 |
89 | }
91 | title="Settings"
92 | contentContainerStyle={{ gap: layouts.padding }}
93 | >
94 |
97 |
105 | Language:
106 |
107 |
108 |
109 |
112 |
113 |
114 |
122 |
134 |
135 |
136 |
143 |
144 | 5
145 |
146 |
147 |
152 |
153 |
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/context/course.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | Dispatch,
4 | SetStateAction,
5 | useContext,
6 | useEffect,
7 | useState,
8 | } from "react";
9 |
10 | import { validLanguages } from "@/config/language";
11 | import { DEFAULT_COURSE_PROGRESS } from "@/constants/default";
12 | import {
13 | COURSE_PROGRESS_STORAGE_KEY,
14 | CURRENT_COURSE_ID_STORAGE_KEY,
15 | } from "@/constants/storage-key";
16 | import { getExercise } from "@/content/courses/data";
17 | import { getLocalData, setLocalData } from "@/lib/local-storage";
18 | import { SupportedLanguageCode } from "@/types";
19 | import { CourseProgression } from "@/types/course";
20 |
21 | type CourseContextType = {
22 | courseId: SupportedLanguageCode | null;
23 | setCourseId: Dispatch>;
24 | courseProgress: CourseProgression;
25 | setCourseProgress: Dispatch>;
26 | };
27 |
28 | const CourseContext = createContext(undefined);
29 |
30 | export const useCourse = () => {
31 | const context = useContext(CourseContext);
32 | if (!context) {
33 | throw new Error("useCourse must be used within a CourseProvider");
34 | }
35 | return context;
36 | };
37 |
38 | interface Props {
39 | children: React.ReactNode;
40 | }
41 |
42 | export function CourseProvider({ children }: Props) {
43 | const [courseId, setCourseId] = useState(null);
44 | const [courseProgress, setCourseProgress] = useState(
45 | DEFAULT_COURSE_PROGRESS
46 | );
47 | const [isInitialized, setIsInitialized] = useState(false);
48 |
49 | const handleCourseProgress = async (courseId: SupportedLanguageCode) => {
50 | const courseProgressKey = COURSE_PROGRESS_STORAGE_KEY(courseId);
51 | const storedCourseProgress = await getLocalData(courseProgressKey);
52 |
53 | try {
54 | if (storedCourseProgress) {
55 | const parsedCourseProgress = JSON.parse(
56 | storedCourseProgress
57 | ) as CourseProgression;
58 | if (
59 | isValidCourseProgress(parsedCourseProgress) &&
60 | isValidCourseProgressIds(parsedCourseProgress)
61 | ) {
62 | setCourseProgress(parsedCourseProgress);
63 | } else {
64 | handleInvalidCourseProgress();
65 | }
66 | } else {
67 | setCourseProgress(DEFAULT_COURSE_PROGRESS);
68 | await setLocalData(
69 | courseProgressKey,
70 | JSON.stringify(DEFAULT_COURSE_PROGRESS)
71 | );
72 | }
73 | } catch (error) {
74 | console.error("Error parsing stored course progress data:", error);
75 | handleInvalidCourseProgress();
76 | }
77 | };
78 |
79 | const isValidCourseProgress = (
80 | parsedCourseProgress: any
81 | ): parsedCourseProgress is CourseProgression => {
82 | if (
83 | parsedCourseProgress &&
84 | typeof parsedCourseProgress === "object" &&
85 | "sectionId" in parsedCourseProgress &&
86 | "chapterId" in parsedCourseProgress &&
87 | "lessonId" in parsedCourseProgress &&
88 | "exerciseId" in parsedCourseProgress &&
89 | typeof parsedCourseProgress.sectionId === "number" &&
90 | typeof parsedCourseProgress.chapterId === "number" &&
91 | typeof parsedCourseProgress.lessonId === "number" &&
92 | typeof parsedCourseProgress.exerciseId === "number"
93 | ) {
94 | return true;
95 | }
96 | return false;
97 | };
98 |
99 | const isValidCourseProgressIds = (courseProgress: CourseProgression) => {
100 | return !!getExercise(courseProgress);
101 | };
102 |
103 | const handleInvalidCourseProgress = () => {
104 | setCourseProgress(DEFAULT_COURSE_PROGRESS);
105 | };
106 |
107 | useEffect(() => {
108 | const initializeCourse = async () => {
109 | try {
110 | let storedCourseId = await getLocalData(CURRENT_COURSE_ID_STORAGE_KEY);
111 |
112 | if (
113 | storedCourseId &&
114 | validLanguages.includes(storedCourseId as SupportedLanguageCode)
115 | ) {
116 | const COURSE_ID = storedCourseId as SupportedLanguageCode;
117 | handleCourseProgress(COURSE_ID);
118 | setCourseId(COURSE_ID);
119 | }
120 | } catch (error) {
121 | console.error("Error fetching course ID:", error);
122 | // Handle the error gracefully, for example, set courseId to a default value
123 | setCourseId(null);
124 | } finally {
125 | setIsInitialized(true);
126 | }
127 | };
128 |
129 | initializeCourse();
130 | }, []); // Empty dependency array ensures that this effect runs once after the initial render
131 |
132 | useEffect(() => {
133 | if (isInitialized && courseId !== null) {
134 | setLocalData(CURRENT_COURSE_ID_STORAGE_KEY, courseId);
135 | handleCourseProgress(courseId);
136 | }
137 | }, [courseId, isInitialized]);
138 |
139 | useEffect(() => {
140 | if (isInitialized && courseId !== null) {
141 | const courseProgressKey = COURSE_PROGRESS_STORAGE_KEY(courseId);
142 | setLocalData(courseProgressKey, JSON.stringify(courseProgress));
143 | }
144 | }, [courseProgress, isInitialized]);
145 |
146 | const courseContextValue: CourseContextType = {
147 | courseId,
148 | setCourseId,
149 | courseProgress,
150 | setCourseProgress,
151 | };
152 |
153 | return (
154 |
155 | {isInitialized && children}
156 |
157 | );
158 | }
159 |
--------------------------------------------------------------------------------
/components/lesson-item.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { router } from "expo-router";
3 | import { Pressable, PressableProps } from "react-native";
4 | import Popover from "react-native-popover-view/dist/Popover";
5 |
6 | import { Icon } from "@/components/icons";
7 | import { Text, View } from "@/components/themed";
8 | import { Button } from "@/components/ui/button";
9 | import { layouts } from "@/constants/layouts";
10 | import { useTheme } from "@/context/theme";
11 | import { CourseProgression, ExerciseSet } from "@/types/course";
12 |
13 | interface Props extends PressableProps {
14 | circleRadius: number;
15 | isCurrentLesson: boolean;
16 | isFinishedLesson: boolean;
17 | index: number;
18 | lessonDescription: string;
19 | totalExercise: number;
20 | currentExercise: ExerciseSet;
21 | courseProgression: CourseProgression;
22 | }
23 |
24 | export function LessonItem({
25 | isCurrentLesson,
26 | isFinishedLesson,
27 | circleRadius,
28 | index,
29 | lessonDescription,
30 | totalExercise,
31 | currentExercise,
32 | courseProgression,
33 | ...props
34 | }: Props) {
35 | const {
36 | border,
37 | background,
38 | primary,
39 | primaryForeground,
40 | foreground,
41 | mutedForeground,
42 | muted,
43 | } = useTheme();
44 | const isNotFinishedLesson = !isFinishedLesson && !isCurrentLesson;
45 | const [isVisiable, setIsVisiable] = useState(false);
46 | const openPopover = () => setIsVisiable(true);
47 | const closePopover = () => setIsVisiable(false);
48 |
49 | const {
50 | sectionId: sectionId,
51 | chapterId: chapterId,
52 | lessonId: lessonId,
53 | exerciseId: exerciseId,
54 | } = courseProgression;
55 |
56 | return (
57 |
70 |
77 |
90 | {isCurrentLesson ? (
91 |
92 | ) : isFinishedLesson ? (
93 |
94 | ) : index === 0 ? (
95 |
96 | ) : (
97 |
98 | )}
99 |
100 |
101 |
102 | }
103 | >
104 |
114 |
123 |
130 | {lessonDescription}
131 |
132 | {isCurrentLesson && (
133 |
141 |
148 | {currentExercise.difficulty}
149 |
150 |
151 | )}
152 |
153 |
154 | {isFinishedLesson
155 | ? "Prove your proficiency with Legendary"
156 | : isNotFinishedLesson
157 | ? "Complete all levels above to unlock this!"
158 | : `Exercise ${currentExercise.id} of ${totalExercise}`}
159 |
160 |
179 |
180 |
181 | );
182 | }
183 |
--------------------------------------------------------------------------------
/components/exercise/items/flash-card-item.tsx:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
2 | import { Image } from "expo-image";
3 | import {
4 | Pressable,
5 | PressableProps,
6 | ScrollView,
7 | useWindowDimensions,
8 | } from "react-native";
9 |
10 | import { Text, View } from "@/components/themed";
11 | import { colors } from "@/constants/colors";
12 | import { DEFAULT_COURSE_ID } from "@/constants/default";
13 | import { layouts } from "@/constants/layouts";
14 | import { useBreakpoint } from "@/context/breakpoints";
15 | import { useCourse } from "@/context/course";
16 | import { useLanguageCode } from "@/context/language";
17 | import { useTheme } from "@/context/theme";
18 | import { useAudio } from "@/hooks/audio";
19 | import { shuffleArray } from "@/lib/utils";
20 | import {
21 | ExerciseItemProps,
22 | FlashCardExercise,
23 | FlashCardExerciseWord,
24 | } from "@/types/course";
25 |
26 | import { ExerciseItemEvent } from "./exercise-item-event";
27 |
28 | interface Props extends ExerciseItemProps {
29 | exercise: FlashCardExercise;
30 | }
31 |
32 | export function FlashCardItem({ exercise, onResult, onContinue }: Props) {
33 | const shuffled = useMemo(() => shuffleArray(exercise.words), [exercise]);
34 |
35 | const { languageCode } = useLanguageCode();
36 |
37 | const [selectedId, setSelectedId] = useState(null);
38 | const [isSuccess, setIsSuccess] = useState(null);
39 |
40 | useEffect(() => {
41 | if (isSuccess !== null) {
42 | onResult(isSuccess);
43 | }
44 | }, [isSuccess]);
45 |
46 | const reset = () => {
47 | setSelectedId(null);
48 | setIsSuccess(null);
49 | };
50 |
51 | const onCheck = () => {
52 | if (selectedId !== null) {
53 | setIsSuccess(exercise.correctWordId === selectedId);
54 | }
55 | };
56 |
57 | return (
58 |
61 |
69 |
75 |
76 | {exercise.question[languageCode]}
77 |
78 |
79 |
87 | {shuffled.map((word, index) => (
88 |
95 | ))}
96 |
97 |
98 |
99 |
100 | id === exercise.correctWordId)
104 | ?.content[languageCode] || ""
105 | }
106 | isSuccess={isSuccess}
107 | onPressCheck={onCheck}
108 | onPressContinue={() => {
109 | reset();
110 | onContinue();
111 | }}
112 | />
113 |
114 | );
115 | }
116 |
117 | interface FlashCardWordProps extends PressableProps {
118 | word: FlashCardExerciseWord;
119 | isSuccess: boolean | null;
120 | selectedId: number | null;
121 | setSelectedId: Dispatch>;
122 | }
123 |
124 | function FlashCardWord({
125 | word,
126 | isSuccess,
127 | selectedId,
128 | setSelectedId,
129 | }: FlashCardWordProps) {
130 | const {
131 | foreground,
132 | muted,
133 | mutedForeground,
134 | sucess,
135 | sucessForeground,
136 | destructive,
137 | destructiveForeground,
138 | } = useTheme();
139 | const { courseId } = useCourse();
140 | const layout = useWindowDimensions();
141 | const breakpoint = useBreakpoint();
142 | const { playSound } = useAudio({
143 | source: word.audio ? word.audio[courseId!] : undefined,
144 | });
145 |
146 | return (
147 | {
174 | if (isSuccess === null) {
175 | setSelectedId(word.id);
176 | }
177 | playSound();
178 | }}
179 | >
180 |
188 |
195 |
196 |
213 | {word.content[courseId || DEFAULT_COURSE_ID]}
214 |
215 |
216 | );
217 | }
218 |
--------------------------------------------------------------------------------
/app/(course)/learn.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { ScrollView } from "react-native";
3 |
4 | import { CourseDetailsBar } from "@/components/course-details-bar";
5 | import { Icon } from "@/components/icons";
6 | import { LessonItem } from "@/components/lesson-item";
7 | import { Metadata } from "@/components/metadata";
8 | import { Text, View } from "@/components/themed";
9 | import { Button } from "@/components/ui/button";
10 | import { layouts } from "@/constants/layouts";
11 | import { courseContent } from "@/content/courses/data";
12 | import { useBreakpoint } from "@/context/breakpoints";
13 | import { useCourse } from "@/context/course";
14 | import { useLanguageCode } from "@/context/language";
15 | import { useTheme } from "@/context/theme";
16 | import { Chapter } from "@/types/course";
17 |
18 | const CAMP = 16;
19 | const CIRCLE_RADUIS = 48;
20 |
21 | export default function Learn() {
22 | const { languageCode } = useLanguageCode();
23 | const { courseId, courseProgress } = useCourse();
24 | const { mutedForeground, border, accent } = useTheme();
25 | const breakpoint = useBreakpoint();
26 |
27 | const [headerHeight, setHeaderHeight] = useState(0);
28 |
29 | let isOdd = true;
30 | let translateX = 0;
31 |
32 | const currentSection = courseContent.sections[courseProgress.sectionId];
33 | if (!currentSection) return null;
34 |
35 | const renderCourseChapter = (chapter: Chapter, chapterIndex: number) => (
36 |
43 |
58 |
65 |
66 | {chapter.title[languageCode]}
67 |
68 |
69 | {chapter.description[languageCode]}
70 |
71 |
72 |
80 |
81 |
82 |
88 | {chapter.lessons.map((lession, lessonIndex) => {
89 | if (translateX > CAMP || translateX < -CAMP) {
90 | isOdd = !isOdd;
91 | }
92 |
93 | if (lessonIndex !== 0) {
94 | isOdd
95 | ? (translateX += CIRCLE_RADUIS)
96 | : (translateX -= CIRCLE_RADUIS);
97 | }
98 |
99 | const isCurrentChapter = courseProgress.chapterId === chapterIndex;
100 | const isCurrentLesson =
101 | isCurrentChapter && courseProgress.lessonId === lessonIndex;
102 | const isFinishedLesson =
103 | (isCurrentChapter && lessonIndex < courseProgress.lessonId) ||
104 | chapterIndex < courseProgress.chapterId;
105 | const currentExercise = lession.exercises[courseProgress.exerciseId];
106 |
107 | if (!currentExercise) return null;
108 |
109 | return (
110 |
127 | );
128 | })}
129 |
130 |
131 | );
132 |
133 | return (
134 | <>
135 |
139 |
140 | setHeaderHeight(e.nativeEvent.layout.height)}
152 | >
153 | {(breakpoint === "sm" || breakpoint === "md") && courseId && (
154 |
163 | )}
164 |
175 |
183 | {currentSection.title[languageCode]}
184 |
185 |
186 |
187 |
198 | {currentSection.chapters.map((chapter, index) =>
199 | renderCourseChapter(chapter, index)
200 | )}
201 |
202 |
203 | >
204 | );
205 | }
206 |
--------------------------------------------------------------------------------
/components/exercise/items/translate-item.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Image } from "expo-image";
3 | import { Pressable, ScrollView } from "react-native";
4 |
5 | import { Text, View } from "@/components/themed";
6 | import { colors } from "@/constants/colors";
7 | import { DEFAULT_COURSE_ID } from "@/constants/default";
8 | import { layouts } from "@/constants/layouts";
9 | import { useCourse } from "@/context/course";
10 | import { useLanguageCode } from "@/context/language";
11 | import { useTheme } from "@/context/theme";
12 | import {
13 | ExerciseItemProps,
14 | TranslateExercise,
15 | TranslateExerciseOption,
16 | } from "@/types/course";
17 |
18 | import { ExerciseItemEvent } from "./exercise-item-event";
19 |
20 | interface Props extends ExerciseItemProps {
21 | exercise: TranslateExercise;
22 | }
23 |
24 | export function TranslateItem({ exercise, onContinue, onResult }: Props) {
25 | const { languageCode } = useLanguageCode();
26 | const { courseId } = useCourse();
27 | const { border } = useTheme();
28 |
29 | const [isSuccess, setIsSuccess] = useState(null);
30 | const [selectedOptions, setSelectOptions] = useState<
31 | TranslateExerciseOption[]
32 | >([]);
33 |
34 | useEffect(() => {
35 | if (isSuccess !== null) {
36 | onResult(isSuccess);
37 | }
38 | }, [isSuccess]);
39 |
40 | const onPressCheck = () => {
41 | let flag = true;
42 | for (let i = 0; i < selectedOptions.length; i++) {
43 | if (selectedOptions[i].id !== exercise.correctOrderIds[languageCode][i]) {
44 | flag = false;
45 | break;
46 | }
47 | }
48 | if (flag) {
49 | setIsSuccess(true);
50 | } else {
51 | setIsSuccess(false);
52 | }
53 | };
54 |
55 | const onPressContinue = () => {
56 | setSelectOptions([]);
57 | setIsSuccess(null);
58 | onContinue();
59 | };
60 |
61 | const correctAnswer = exercise.correctOrderIds[languageCode]
62 | .map(
63 | (id) =>
64 | exercise.options.find((option) => option.id === id)?.word.content[
65 | languageCode
66 | ]
67 | )
68 | .join(" ");
69 |
70 | return (
71 |
79 |
86 |
87 | {exercise.question[languageCode]}
88 |
89 |
90 |
91 |
97 |
98 |
107 |
108 | {exercise.sentence.content[courseId!]}
109 |
110 |
126 |
127 |
128 |
129 |
140 |
148 | {selectedOptions.map((option, index) => (
149 |
152 | setSelectOptions(
153 | selectedOptions.filter(({ id }) => id !== option.id)
154 | )
155 | }
156 | disabled={isSuccess !== null}
157 | >
158 |
167 |
168 | {option.word.content[languageCode]}
169 |
170 |
171 |
172 | ))}
173 |
174 |
175 |
176 |
182 |
190 | {exercise.options.map((option, index) => {
191 | const isSelected = !!selectedOptions.find(
192 | ({ id }) => id === option.id
193 | );
194 | return (
195 | {
198 | if (!isSelected) {
199 | setSelectOptions([...selectedOptions, option]);
200 | }
201 | }}
202 | disabled={isSuccess !== null}
203 | >
204 |
215 |
221 | {option.word.content[languageCode]}
222 |
223 |
224 |
225 | );
226 | })}
227 |
228 |
229 |
230 |
237 |
238 | );
239 | }
240 |
--------------------------------------------------------------------------------
/content/courses/data/characters/index.ts:
--------------------------------------------------------------------------------
1 | import { LanguageCharacters } from "@/types/course";
2 |
3 | export const characters: LanguageCharacters = {
4 | en: [
5 | {
6 | role: "Alphabet",
7 | dialogueItems: [
8 | "A",
9 | "B",
10 | "C",
11 | "D",
12 | "E",
13 | "F",
14 | "G",
15 | "H",
16 | "I",
17 | "J",
18 | "K",
19 | "L",
20 | "M",
21 | "N",
22 | "O",
23 | "P",
24 | "Q",
25 | "R",
26 | "S",
27 | "T",
28 | "U",
29 | "V",
30 | "W",
31 | "X",
32 | "Y",
33 | "Z",
34 | ],
35 | },
36 | ],
37 | ja: [
38 | {
39 | role: "Hiragana",
40 | dialogueItems: [
41 | "あ",
42 | "い",
43 | "う",
44 | "え",
45 | "お",
46 | "か",
47 | "き",
48 | "く",
49 | "け",
50 | "こ",
51 | "さ",
52 | "し",
53 | "す",
54 | "せ",
55 | "そ",
56 | "た",
57 | "ち",
58 | "つ",
59 | "て",
60 | "と",
61 | "な",
62 | "に",
63 | "ぬ",
64 | "ね",
65 | "の",
66 | "は",
67 | "ひ",
68 | "ふ",
69 | "へ",
70 | "ほ",
71 | "ま",
72 | "み",
73 | "む",
74 | "め",
75 | "も",
76 | "や",
77 | "ゆ",
78 | "よ",
79 | "ら",
80 | "り",
81 | "る",
82 | "れ",
83 | "ろ",
84 | "わ",
85 | "を",
86 | "ん",
87 | ],
88 | },
89 | {
90 | role: "Katakana",
91 | dialogueItems: [
92 | "ア",
93 | "イ",
94 | "ウ",
95 | "エ",
96 | "オ",
97 | "カ",
98 | "キ",
99 | "ク",
100 | "ケ",
101 | "コ",
102 | "サ",
103 | "シ",
104 | "ス",
105 | "セ",
106 | "ソ",
107 | "タ",
108 | "チ",
109 | "ツ",
110 | "テ",
111 | "ト",
112 | "ナ",
113 | "ニ",
114 | "ヌ",
115 | "ネ",
116 | "ノ",
117 | "ハ",
118 | "ヒ",
119 | "フ",
120 | "ヘ",
121 | "ホ",
122 | "マ",
123 | "ミ",
124 | "ム",
125 | "メ",
126 | "モ",
127 | "ヤ",
128 | "ユ",
129 | "ヨ",
130 | "ラ",
131 | "リ",
132 | "ル",
133 | "レ",
134 | "ロ",
135 | "ワ",
136 | "ヲ",
137 | "ン",
138 | "ガ",
139 | "ギ",
140 | "グ",
141 | "ゲ",
142 | "ゴ",
143 | "ザ",
144 | "ジ",
145 | "ズ",
146 | "ゼ",
147 | "ゾ",
148 | "ダ",
149 | "ヂ",
150 | "ヅ",
151 | "デ",
152 | "ド",
153 | "バ",
154 | "ビ",
155 | "ブ",
156 | "ベ",
157 | "ボ",
158 | "パ",
159 | "ピ",
160 | "プ",
161 | "ペ",
162 | "ポ",
163 | ],
164 | },
165 | {
166 | role: "Kanji",
167 | dialogueItems: [
168 | "一",
169 | "二",
170 | "三",
171 | "四",
172 | "五",
173 | "六",
174 | "七",
175 | "八",
176 | "九",
177 | "十",
178 | "百",
179 | "千",
180 | "万",
181 | "円",
182 | "日",
183 | "月",
184 | "火",
185 | "水",
186 | "木",
187 | "金",
188 | "土",
189 | "本",
190 | "人",
191 | "大",
192 | "小",
193 | "中",
194 | "学",
195 | "生",
196 | "先",
197 | "生",
198 | "山",
199 | "川",
200 | "田",
201 | "女",
202 | "男",
203 | "子",
204 | "口",
205 | "目",
206 | "耳",
207 | "手",
208 | ],
209 | },
210 | ],
211 | my: [
212 | {
213 | role: "Consonants",
214 | dialogueItems: [
215 | "က",
216 | "ခ",
217 | "ဂ",
218 | "ဃ",
219 | "င",
220 | "စ",
221 | "ဆ",
222 | "ဇ",
223 | "ဈ",
224 | "ည",
225 | "ဋ",
226 | "ဌ",
227 | "ဍ",
228 | "ဎ",
229 | "ဏ",
230 | "တ",
231 | "ထ",
232 | "ဒ",
233 | "ဓ",
234 | "န",
235 | "ပ",
236 | "ဖ",
237 | "ဗ",
238 | "ဘ",
239 | "မ",
240 | "ယ",
241 | "ရ",
242 | "လ",
243 | "ဝ",
244 | "သ",
245 | "ဟ",
246 | "ဠ",
247 | "အ",
248 | ],
249 | },
250 | {
251 | role: "Numerals",
252 | dialogueItems: ["၁", "၂", "၃", "၄", "၅", "၆", "၇", "၈", "၉", "၁၀"],
253 | },
254 | ],
255 | th: [
256 | {
257 | role: "Consonants",
258 | dialogueItems: [
259 | "ก",
260 | "ข",
261 | "ฃ",
262 | "ค",
263 | "ฅ",
264 | "ฆ",
265 | "ง",
266 | "จ",
267 | "ฉ",
268 | "ช",
269 | "ซ",
270 | "ฌ",
271 | "ญ",
272 | "ฎ",
273 | "ฏ",
274 | "ฐ",
275 | "ฑ",
276 | "ฒ",
277 | "ณ",
278 | "ด",
279 | "ต",
280 | "ถ",
281 | "ท",
282 | "ธ",
283 | "น",
284 | "บ",
285 | "ป",
286 | "ผ",
287 | "ฝ",
288 | "พ",
289 | "ฟ",
290 | "ภ",
291 | "ม",
292 | "ย",
293 | "ร",
294 | "ล",
295 | "ว",
296 | "ศ",
297 | "ษ",
298 | "ส",
299 | "ห",
300 | "ฬ",
301 | "อ",
302 | "ฮ",
303 | ],
304 | },
305 | {
306 | role: "Numerals",
307 | dialogueItems: ["๑", "๒", "๓", "๔", "๕", "๖", "๗", "๘", "๙", "๑๐"],
308 | },
309 | ],
310 | cn: [
311 | {
312 | role: "Simplified",
313 | dialogueItems: [
314 | "阿",
315 | "哦",
316 | "屙",
317 | "饿",
318 | "诶",
319 | "额",
320 | "诶",
321 | "俄",
322 | "诶",
323 | "鹅",
324 | "诶",
325 | "恶",
326 | "艾",
327 | "爱",
328 | "艾",
329 | "哀",
330 | "优",
331 | "油",
332 | "由",
333 | "邮",
334 | "丨",
335 | "丨",
336 | "丨",
337 | "丨",
338 | "宁",
339 | "宁",
340 | "宁",
341 | "马",
342 | "马",
343 | "马",
344 | "马",
345 | "亚",
346 | "亚",
347 | "亚",
348 | "亚",
349 | "哇",
350 | "哇",
351 | "哇",
352 | "哇",
353 | ],
354 | },
355 | {
356 | role: "Traditional",
357 | dialogueItems: [
358 | "一",
359 | "二",
360 | "三",
361 | "四",
362 | "五",
363 | "六",
364 | "七",
365 | "八",
366 | "九",
367 | "十",
368 | "百",
369 | "千",
370 | "萬",
371 | "零",
372 | "壹",
373 | "貳",
374 | "參",
375 | "肆",
376 | "伍",
377 | "陸",
378 | "柒",
379 | "捌",
380 | "玖",
381 | "拾",
382 | "佰",
383 | "仟",
384 | "萬",
385 | "億",
386 | "兆",
387 | "吉",
388 | "日",
389 | "月",
390 | "火",
391 | "水",
392 | "木",
393 | "金",
394 | "土",
395 | "天",
396 | "地",
397 | "人",
398 | "中",
399 | "大",
400 | "小",
401 | "上",
402 | "下",
403 | "左",
404 | "右",
405 | "前",
406 | "後",
407 | "東",
408 | "西",
409 | "南",
410 | "北",
411 | "山",
412 | "水",
413 | "花",
414 | "草",
415 | "樹",
416 | "石",
417 | "金",
418 | "銀",
419 | "风",
420 | "雨",
421 | "雪",
422 | "雷",
423 | "電",
424 | "雲",
425 | "霧",
426 | "露",
427 | "星",
428 | "太陽",
429 | "月亮",
430 | "星期",
431 | "年",
432 | "月",
433 | "日",
434 | "時",
435 | "分",
436 | "秒",
437 | "時辰",
438 | "天使",
439 | "魔鬼",
440 | "人類",
441 | "動物",
442 | "植物",
443 | "車",
444 | "船",
445 | "飛機",
446 | "自行車",
447 | "馬",
448 | "魚",
449 | "鳥",
450 | "狗",
451 | "貓",
452 | "老虎",
453 | "獅子",
454 | "猴子",
455 | "熊",
456 | "蛇",
457 | "龍",
458 | ],
459 | },
460 | ],
461 | es: [
462 | {
463 | role: "Latin",
464 | dialogueItems: [
465 | "A",
466 | "B",
467 | "C",
468 | "D",
469 | "E",
470 | "F",
471 | "G",
472 | "H",
473 | "I",
474 | "J",
475 | "K",
476 | "L",
477 | "M",
478 | "N",
479 | "O",
480 | "P",
481 | "Q",
482 | "R",
483 | "S",
484 | "T",
485 | "U",
486 | "V",
487 | "W",
488 | "X",
489 | "Y",
490 | "Z",
491 | ],
492 | },
493 | ],
494 | fr: [
495 | {
496 | role: "Latin",
497 | dialogueItems: [
498 | "A",
499 | "B",
500 | "C",
501 | "D",
502 | "E",
503 | "F",
504 | "G",
505 | "H",
506 | "I",
507 | "J",
508 | "K",
509 | "L",
510 | "M",
511 | "N",
512 | "O",
513 | "P",
514 | "Q",
515 | "R",
516 | "S",
517 | "T",
518 | "U",
519 | "V",
520 | "W",
521 | "X",
522 | "Y",
523 | "Z",
524 | ],
525 | },
526 | ],
527 | hi: [
528 | {
529 | role: "Consonants",
530 | dialogueItems: [
531 | "क",
532 | "ख",
533 | "ग",
534 | "घ",
535 | "ङ",
536 | "च",
537 | "छ",
538 | "ज",
539 | "झ",
540 | "ञ",
541 | "ट",
542 | "ठ",
543 | "ड",
544 | "ढ",
545 | "ण",
546 | "त",
547 | "थ",
548 | "द",
549 | "ध",
550 | "न",
551 | "प",
552 | "फ",
553 | "ब",
554 | "भ",
555 | "म",
556 | "य",
557 | "र",
558 | "ल",
559 | "व",
560 | "श",
561 | "ष",
562 | "स",
563 | "ह",
564 | "क्ष",
565 | "त्र",
566 | "ज्ञ",
567 | ],
568 | },
569 | {
570 | role: "Vowels",
571 | dialogueItems: [
572 | "अ",
573 | "आ",
574 | "इ",
575 | "ई",
576 | "उ",
577 | "ऊ",
578 | "ऋ",
579 | "ॠ",
580 | "ए",
581 | "ऐ",
582 | "ओ",
583 | "औ",
584 | ],
585 | },
586 | {
587 | role: "Numerals",
588 | dialogueItems: ["०", "१", "२", "३", "४", "५", "६", "७", "८", "९"],
589 | },
590 | ],
591 | ru: [
592 | {
593 | role: "Russian Cyrillic",
594 | dialogueItems: [
595 | "А",
596 | "Б",
597 | "В",
598 | "Г",
599 | "Д",
600 | "Е",
601 | "Ё",
602 | "Ж",
603 | "З",
604 | "И",
605 | "Й",
606 | "К",
607 | "Л",
608 | "М",
609 | "Н",
610 | "О",
611 | "П",
612 | "Р",
613 | "С",
614 | "Т",
615 | "У",
616 | "Ф",
617 | "Х",
618 | "Ц",
619 | "Ч",
620 | "Ш",
621 | "Щ",
622 | "Ъ",
623 | "Ы",
624 | "Ь",
625 | "Э",
626 | "Ю",
627 | "Я",
628 | ],
629 | },
630 | ],
631 | };
632 |
--------------------------------------------------------------------------------
/components/icons.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Defs,
3 | G,
4 | LinearGradient,
5 | Path,
6 | Stop,
7 | Svg,
8 | SvgProps,
9 | } from "react-native-svg";
10 |
11 | import { useTheme } from "@/context/theme";
12 |
13 | type IconProps = {
14 | size?: number;
15 | color?: string;
16 | name: keyof typeof Icons;
17 | strokeWidth?: number;
18 | };
19 |
20 | export const Icon = ({
21 | name,
22 | color,
23 | size = 32,
24 | strokeWidth = 2,
25 | }: IconProps) => {
26 | const Icon = Icons[name];
27 | const { mutedForeground } = useTheme();
28 | return (
29 |
35 | );
36 | };
37 |
38 | export const Icons = {
39 | euolingo: ({ ...props }: SvgProps) => (
40 |
62 | ),
63 | targetCircle: ({ ...props }: SvgProps) => (
64 |
91 | ),
92 | clockCircle: ({ ...props }: SvgProps) => (
93 |
106 | ),
107 | closeCircle: ({ ...props }: SvgProps) => (
108 |
119 | ),
120 | checkCircle: ({ ...props }: SvgProps) => (
121 |
132 | ),
133 | setting: ({ ...props }: SvgProps) => (
134 |
147 | ),
148 | skip: ({ ...props }: SvgProps) => (
149 |
157 | ),
158 | check: ({ ...props }: SvgProps) => (
159 |
168 | ),
169 | lock: ({ ...props }: SvgProps) => (
170 |
178 | ),
179 | star: ({ ...props }: SvgProps) => (
180 |
186 | ),
187 | notebook: ({ ...props }: SvgProps) => (
188 |
219 | ),
220 | chevronDown: ({ ...props }: SvgProps) => (
221 |
235 | ),
236 | chevronUp: ({ ...props }: SvgProps) => (
237 |
250 | ),
251 | home: ({ ...props }: SvgProps) => (
252 |
284 | ),
285 | languageSquare: ({ ...props }: SvgProps) => (
286 |
297 | ),
298 | shieldStar: ({ ...props }: SvgProps) => (
299 |
310 | ),
311 | box: ({ ...props }: SvgProps) => (
312 |
328 | ),
329 | shop: ({ ...props }: SvgProps) => (
330 |
357 | ),
358 | profile: ({ ...props }: SvgProps) => (
359 |
370 | ),
371 | menuDots: ({ ...props }: SvgProps) => (
372 |
391 | ),
392 | heart: ({ ...props }: SvgProps) => (
393 |
404 | ),
405 | fire: ({ ...props }: SvgProps) => (
406 |
417 | ),
418 | donut: ({ ...props }: SvgProps) => (
419 |
470 | ),
471 | bolt: ({ ...props }: SvgProps) => (
472 |
485 | ),
486 | };
487 |
--------------------------------------------------------------------------------