wordtrail source

App.tsx

import { useEffect, useRef, useState } from 'react';
import { Animated, StatusBar } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import Svg, { Circle, Ellipse, Path } from 'react-native-svg';
import { MotionBlock, ScrollArea, SafeArea, Block, Icon, PlocksProvider, Text } from '@plocks/ui';

const C = {
  green: '#58CC02',
  greenDark: '#46A302',
  greenPale: '#E9F9DC',
  blue: '#1CB0F6',
  bluePale: '#E8F7FF',
  red: '#FF4B4B',
  redPale: '#FFE8E7',
  ink: '#303735',
  muted: '#79817D',
  border: '#E5E9E4',
  white: '#FFFFFF',
  disabled: '#E7E9E7',
  disabledText: '#A7ADA9',
};

const QUESTIONS = [
  {
    language: 'ENGLISH',
    word: 'the apple',
    prompt: 'Select the Spanish translation',
    options: ['manzana', 'perro', 'casa'],
    answer: 0,
    note: 'Manzana means apple.',
  },
  {
    language: 'SPANISH',
    word: 'hola',
    prompt: 'Select the English translation',
    options: ['goodbye', 'hello', 'please'],
    answer: 1,
    note: 'Hola is a friendly hello.',
  },
  {
    language: 'ENGLISH',
    word: 'water',
    prompt: 'Select the Spanish translation',
    options: ['libro', 'sol', 'agua'],
    answer: 2,
    note: 'Agua means water.',
  },
  {
    language: 'SPANISH',
    word: 'gracias',
    prompt: 'Select the English translation',
    options: ['thank you', 'morning', 'friend'],
    answer: 0,
    note: 'Gracias means thank you.',
  },
];

function Bird() {
  return (
    <Svg width={112} height={120} viewBox="0 0 112 120" accessibilityLabel="Friendly green bird">
      <Ellipse cx="56" cy="112" rx="42" ry="6" fill="#D9EFC9" />
      <Path d="M20 69C20 36 34 15 57 15c24 0 39 23 39 54 0 27-16 43-40 43S20 96 20 69Z" fill={C.green} />
      <Path d="M25 65c-11 5-15 15-10 24 3 6 9 8 15 6l4-25Z" fill={C.greenDark} />
      <Path d="M87 65c11 5 15 15 10 24-3 6-9 8-15 6l-4-25Z" fill={C.greenDark} />
      <Ellipse cx="55" cy="76" rx="28" ry="30" fill="#B9EE8B" />
      <Ellipse cx="42" cy="52" rx="16" ry="20" fill={C.white} />
      <Ellipse cx="70" cy="52" rx="16" ry="20" fill={C.white} />
      <Circle cx="46" cy="57" r="5" fill={C.ink} />
      <Circle cx="66" cy="57" r="5" fill={C.ink} />
      <Path d="M48 69q8-4 16 0l-8 9Z" fill="#FFB84D" />
      <Path d="M48 89q8 7 16 0" stroke={C.greenDark} strokeWidth="3" strokeLinecap="round" fill="none" />
      <Ellipse cx="41" cy="107" rx="9" ry="4" fill="#F6AD36" />
      <Ellipse cx="71" cy="107" rx="9" ry="4" fill="#F6AD36" />
    </Svg>
  );
}

function AnswerCard({
  label,
  number,
  selected,
  correct,
  revealed,
  onPress,
}: {
  label: string;
  number: number;
  selected: boolean;
  correct: boolean;
  revealed: boolean;
  onPress: () => void;
}) {
  const [pressed, setPressed] = useState(false);
  const scale = useRef(new Animated.Value(1)).current;
  useEffect(() => {
    if (selected)
      Animated.sequence([
        Animated.spring(scale, { toValue: 1.025, friction: 5, useNativeDriver: true }),
        Animated.spring(scale, { toValue: 1, friction: 6, useNativeDriver: true }),
      ]).start();
  }, [selected, scale]);

  const isCorrect = revealed && correct;
  const isWrong = revealed && selected && !correct;
  const color = isCorrect ? C.greenDark : isWrong ? C.red : selected ? C.blue : C.border;
  const background = isCorrect ? C.greenPale : isWrong ? C.redPale : selected ? C.bluePale : C.white;
  return (
    <MotionBlock scale={scale}>
      <Block
        onPress={onPress}
        onPressIn={() => setPressed(true)}
        onPressOut={() => setPressed(false)}
        disabled={revealed}
        accessibilityRole="button"
        accessibilityState={{ selected, disabled: revealed }}
        mih={68}
        borderWidth={2}
        borderBottomWidth={pressed && !revealed ? 2 : 4}
        borderColor={color}
        radius={17}
        bg={background}
        px={18}
        py={13}
        direction="row"
        align="center"
        gap={16}
        translateY={pressed && !revealed ? 2 : 0}
      >
        <Block
          gap={0}
          w={29}
          h={29}
          radius={8}
          borderWidth={1.5}
          borderColor={color}
          bg={isCorrect || isWrong || selected ? C.white : '#F8FAF7'}
          align="center"
          justify="center"
        >
          <Text c={isCorrect || isWrong || selected ? color : C.muted} size={14} fw="bold">
            {number}
          </Text>
        </Block>
        <Text c={C.ink} size={19} fw="semibold" flex={1}>
          {label}
        </Text>
        {(isCorrect || isWrong) && <Icon name={isCorrect ? 'check' : 'x'} color={color} size={22} />}
      </Block>
    </MotionBlock>
  );
}

function PrimaryButton({
  title,
  onPress,
  disabled = false,
  color = C.green,
  shadow = C.greenDark,
}: {
  title: string;
  onPress: () => void;
  disabled?: boolean;
  color?: string;
  shadow?: string;
}) {
  const [pressed, setPressed] = useState(false);
  return (
    <Block
      onPress={onPress}
      onPressIn={() => setPressed(true)}
      onPressOut={() => setPressed(false)}
      disabled={disabled}
      accessibilityRole="button"
      accessibilityState={{ disabled }}
      h={58}
      radius={16}
      bg={disabled ? C.disabled : color}
      borderBottomWidth={disabled || pressed ? 0 : 4}
      borderBottomColor={shadow}
      align="center"
      justify="center"
      gap={0}
      translateY={pressed && !disabled ? 3 : 0}
    >
      <Text c={disabled ? C.disabledText : C.white} size={16} fw="bold" lts={0.7}>
        {title}
      </Text>
    </Block>
  );
}

export default function App() {
  const [index, setIndex] = useState(0);
  const [selected, setSelected] = useState<number | null>(null);
  const [phase, setPhase] = useState<'selecting' | 'feedback'>('selecting');
  const [score, setScore] = useState(0);
  const [hearts, setHearts] = useState(5);
  const progress = useRef(new Animated.Value(0)).current;
  const entrance = useRef(new Animated.Value(0)).current;
  const rise = useRef(new Animated.Value(20)).current;
  const birdScale = useRef(new Animated.Value(0.84)).current;
  const feedbackRise = useRef(new Animated.Value(24)).current;
  const feedbackOpacity = useRef(new Animated.Value(0)).current;
  const done = index >= QUESTIONS.length;
  const question = QUESTIONS[index];
  const correct = !done && selected === question.answer;

  useEffect(() => {
    Animated.timing(progress, {
      toValue: Math.min((index + (phase === 'feedback' ? 1 : 0)) / QUESTIONS.length, 1),
      duration: 430,
      useNativeDriver: false,
    }).start();
  }, [index, phase, progress]);

  useEffect(() => {
    entrance.setValue(0);
    rise.setValue(20);
    birdScale.setValue(0.84);
    Animated.parallel([
      Animated.timing(entrance, { toValue: 1, duration: 300, useNativeDriver: true }),
      Animated.spring(rise, { toValue: 0, friction: 8, useNativeDriver: true }),
      Animated.spring(birdScale, { toValue: 1, friction: 5, useNativeDriver: true }),
    ]).start();
  }, [index, entrance, rise, birdScale]);

  useEffect(() => {
    if (phase !== 'feedback') return;
    feedbackRise.setValue(24);
    feedbackOpacity.setValue(0);
    Animated.parallel([
      Animated.spring(feedbackRise, { toValue: 0, friction: 8, useNativeDriver: true }),
      Animated.timing(feedbackOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
    ]).start();
  }, [phase, index, feedbackRise, feedbackOpacity]);

  const reset = () => {
    setIndex(0);
    setSelected(null);
    setPhase('selecting');
    setScore(0);
    setHearts(5);
  };
  const check = () => {
    if (selected === null || done) return;
    if (selected === question.answer) setScore((value) => value + 1);
    else setHearts((value) => Math.max(0, value - 1));
    setPhase('feedback');
  };
  const advance = () => {
    setIndex((value) => value + 1);
    setSelected(null);
    setPhase('selecting');
  };

  return (
    <SafeAreaProvider>
      <PlocksProvider theme={{ colorScheme: 'light' }}>
        <SafeArea gap={0} flex={1} bg={C.white}>
          <StatusBar barStyle="dark-content" />
          <Block w="100%" maw={700} alignSelf="center" px={24} pt={18} pb={14} direction="row" align="center" gap={16}>
            <Block
              gap={0}
              onPress={reset}
              accessibilityRole="button"
              accessibilityLabel="Restart lesson"
              w={36}
              h={36}
              align="center"
              justify="center"
            >
              <Icon name="x" size={24} color={C.muted} />
            </Block>
            <Block gap={0} flex={1} h={15} radius={20} bg={C.border} overflow="hidden">
              <MotionBlock
                h="full"
                radius={20}
                bg={C.green}
                motionWidth={progress.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] })}
              />
            </Block>
            <Block direction="row" align="center" gap={5}>
              <Icon name="heart" variant="filled" size={24} color={C.red} />
              <Text c={C.red} size={19} fw="bold">
                {hearts}
              </Text>
            </Block>
          </Block>

          <ScrollArea
            contentProps={{
              grow: 1,
              w: '100%',
              maw: 640,
              alignSelf: 'center',
              justify: 'center',
              px: 24,
              pt: 26,
              pb: 30,
            }}
          >
            <MotionBlock motionOpacity={entrance} translateY={rise}>
              {done ? (
                <Block gap={0} align="center" py={32}>
                  <MotionBlock scale={birdScale}>
                    <Bird />
                  </MotionBlock>
                  <Block gap={0} mt={25} px={16} py={7} radius={99} bg={C.greenPale}>
                    <Text c={C.greenDark} size={12} fw="bold" lts={1.2}>
                      LESSON COMPLETE
                    </Text>
                  </Block>
                  <Text c={C.ink} size={34} fw="bold" ta="center" mt={16}>
                    Beautiful work!
                  </Text>
                  <Text c={C.muted} size={17} ta="center" mt={9}>
                    You made it through your first Spanish lesson.
                  </Text>
                  <Block direction="row" gap={12} mt={35} w="100%">
                    <Block gap={0} flex={1} radius={18} bg="#FFF7E5" p={20} align="center">
                      <Text c="#B8821D" size={12} fw="bold" lts={1}>
                        CORRECT
                      </Text>
                      <Text c="#B8821D" size={30} fw="bold">
                        {score}/{QUESTIONS.length}
                      </Text>
                    </Block>
                    <Block gap={0} flex={1} radius={18} bg={C.redPale} p={20} align="center">
                      <Text c={C.red} size={12} fw="bold" lts={1}>
                        HEARTS LEFT
                      </Text>
                      <Text c={C.red} size={30} fw="bold">
                        {hearts}
                      </Text>
                    </Block>
                  </Block>
                </Block>
              ) : (
                <>
                  <Text c={C.greenDark} size={12} fw="bold" lts={1.5}>
                    UNIT 1 ยท FIRST WORDS
                  </Text>
                  <Text c={C.ink} size={29} fw="bold" mt={12} lh={36}>
                    {question.prompt}
                  </Text>
                  <Block direction="row" align="center" gap={18} mt={35} mb={36}>
                    <MotionBlock scale={birdScale}>
                      <Bird />
                    </MotionBlock>
                    <Block
                      gap={0}
                      flex={1}
                      borderWidth={2}
                      borderColor={C.border}
                      radius={20}
                      px={20}
                      py={17}
                      bg={C.white}
                    >
                      <Text c={C.muted} size={11} fw="bold" lts={1.2}>
                        {question.language}
                      </Text>
                      <Text c={C.ink} size={25} fw="bold" mt={4}>
                        {question.word}
                      </Text>
                    </Block>
                  </Block>
                  <Block gap={12}>
                    {question.options.map((option, optionIndex) => (
                      <AnswerCard
                        key={option}
                        label={option}
                        number={optionIndex + 1}
                        selected={selected === optionIndex}
                        correct={question.answer === optionIndex}
                        revealed={phase === 'feedback'}
                        onPress={() => setSelected(optionIndex)}
                      />
                    ))}
                  </Block>
                </>
              )}
            </MotionBlock>
          </ScrollArea>

          <Block
            gap={0}
            borderTopWidth={1}
            borderTopColor={phase === 'feedback' && !done ? 'transparent' : C.border}
            bg={phase === 'feedback' && !done ? (correct ? C.greenPale : C.redPale) : C.white}
          >
            <Block gap={0} w="100%" maw={640} alignSelf="center" px={24} pt={18} pb={20}>
              {phase === 'feedback' && !done && (
                <MotionBlock
                  motionOpacity={feedbackOpacity}
                  translateY={feedbackRise}
                  mb={18}
                  direction="row"
                  align="center"
                  gap={14}
                >
                  <Block
                    gap={0}
                    w={42}
                    h={42}
                    radius={21}
                    bg={correct ? C.green : C.red}
                    align="center"
                    justify="center"
                  >
                    <Icon name={correct ? 'check' : 'x'} color={C.white} size={26} />
                  </Block>
                  <Block gap={0} flex={1}>
                    <Text c={correct ? C.greenDark : C.red} size={21} fw="bold">
                      {correct ? 'Amazing!' : 'Not quite'}
                    </Text>
                    <Text c={C.ink} size={14}>
                      {question.note}
                    </Text>
                  </Block>
                </MotionBlock>
              )}
              <PrimaryButton
                title={done ? 'PRACTICE AGAIN' : phase === 'feedback' ? 'CONTINUE' : 'CHECK'}
                onPress={done ? reset : phase === 'feedback' ? advance : check}
                disabled={!done && phase === 'selecting' && selected === null}
                color={phase === 'feedback' && !correct ? C.red : C.green}
                shadow={phase === 'feedback' && !correct ? '#D93636' : C.greenDark}
              />
            </Block>
          </Block>
        </SafeArea>
      </PlocksProvider>
    </SafeAreaProvider>
  );
}

index.ts

import { registerRootComponent } from 'expo';

import App from './App';

registerRootComponent(App);