DEV Community

German Cyganov
German Cyganov

Posted on

Building a Voice and Eye-Controlled To-Do App - Part 2

In the last part we created the basic functionality with adding and removing tasks, now let's make the TODO application a bit prettier. I sketched the layout in figma using ChakraUI Figma KIT, here are two themes light and dark which we will implement.

UI Template

First set the dependencies for the icons.
npm i --save @chakra-ui/icons

Write the code

Create a new Menu component, which will have control buttons. Right now there is only a theme switch, but in the future we will add buttons for microphone, speakers, camera and possibly biometric authentication.

src/components/Menu/index.tsx

import { MoonIcon, SunIcon, } from "@chakra-ui/icons"
import { HStack, IconButton, StackProps, useColorMode } from "@chakra-ui/react"

export const Menu: React.FC<StackProps> = (props) => {
    const { colorMode, toggleColorMode } = useColorMode()

    return (
        <HStack justify='end' {...props}>
            <IconButton variant='ghost' aria-label="change theme"
                onClick={toggleColorMode}
                icon={colorMode === 'light' ? <SunIcon color='yellow.500' /> : <MoonIcon />} />
        </HStack>
    )
}
Enter fullscreen mode Exit fullscreen mode

Stylize the existing TaskList and TaskCreator components.

src/components/TaskList/index.tsx

const TaskCard: React.FC<TaskCardProps> = ({ id, name, onDelete }) => {
    const handleDelete = () => {
        onDelete(id)
    }

    return (
        <Card
            direction={{ base: 'column', sm: 'row' }}
            overflow='hidden'
        >
            <CardBody>
                <Heading size='md'>{name}</Heading>
            </CardBody>
            <IconButton
                aria-label='remove task'
                colorScheme="red"
                height='auto'
                variant='insideCard'
                borderTopLeftRadius='0'
                borderBottomLeftRadius='0'
                icon={<DeleteIcon color="white" />}
                onClick={handleDelete} />
        </Card>
    )
}

export const TaskList = () => {
...
if (error) {
        return (
            <Center>
                <Text color='red' fontSize={'xl'}>
                    {'Network Error'}
                </Text>
            </Center>
        )
    }

    if (isLoadingDelayed) {
        return (
            <Center>
                <Spinner size='xl' />
            </Center>
        )
    }

    return (
        <Stack spacing={4} pr={'8'}>
            {...tasks.map((task, idx) => (
                <TaskCard key={task.id || `${task.name}_${idx}`} {...task} onDelete={handleDeleteTask} />
            ))}
        </Stack>
    )
}
Enter fullscreen mode Exit fullscreen mode

src/components/TaskCreator/index.tsx

return (
        <form onSubmit={handleAddTask} {...props}>
            <Card
                direction={{ base: 'column', sm: 'row' }}
                overflow='hidden'
            >
                <CardBody pt='3' pb='3' pl='3' pr='8'>
                    <InputGroup>
                        <Input variant='outline' placeholder="Input a task" onChange={handleChange} />
                    </InputGroup>
                </CardBody>
                <IconButton
                    type="submit"
                    aria-label='add task'
                    colorScheme="green"
                    height='auto'
                    variant='insideCard'
                    borderTopLeftRadius='0'
                    borderBottomLeftRadius='0'
                    icon={<AddIcon color="white" />} />
            </Card>
        </form>
    )
Enter fullscreen mode Exit fullscreen mode

Basic layout.

Let's make it so that the task input field was initially a little lower than the first quarter of the screen, and when adding tasks and appearing scroll, there is an opportunity to scroll it up after which it will stick to the top edge and the tasks will go as if under it.

src/pages/index.tsx

...
      <Container pt='25%' pb='5%' style={{ background: 'inherit' }}>
        <Stack spacing='8' style={{ background: 'inherit' }}>
          <Menu style={{
            position: 'sticky', top: '0rem', zIndex: '999',
            background: 'inherit',
            padding: '0.4rem 0',
            paddingLeft: '1rem',
            marginLeft: '-1rem',
          }} />
          <TaskCreator style={{
            position: 'sticky', top: '3.2rem', zIndex: '999',
            background: colorMode === 'light' ? 'inherit' : 'transparent',
            paddingLeft: '1rem',
            marginLeft: '-1rem',
        }}/>
          <TaskList />
        </Stack>
      </Container>
...
Enter fullscreen mode Exit fullscreen mode

There appeared a little magic paddings and margins, they are necessary for that the shadow of cards did not appear when the card behind the input field and menu. Also need to propagate background: inherit through all components. A bit clumsy, but anyway.

src/pages/_app.tsx

...
      <style jsx global>
        {`
          #__next {
            background: inherit;
          }
        `}
      </style>
...
Enter fullscreen mode Exit fullscreen mode

Theme switcher

In the Menu component was colorMode variable.

const { colorMode, toggleColorMode } = useColorMode()

Now add the theme information to the Chakra provider.

src/chakra.tsx

import {
    ChakraProvider,
    cookieStorageManagerSSR,
    localStorageManager,
} from '@chakra-ui/react'
import { GetServerSideProps } from 'next'
import { PropsWithChildren } from 'react'
import theme from './theme'

interface ChakraProps {
    cookies: string
}

export const Chakra: React.FC<PropsWithChildren<ChakraProps>> = ({ cookies, children }) => {
    const colorModeManager =
        typeof cookies === 'string'
            ? cookieStorageManagerSSR(cookies)
            : localStorageManager

    return (
        <ChakraProvider colorModeManager={colorModeManager} theme={theme}>
            {children}
        </ChakraProvider>
    )
}

export const getServerSideProps = (async ({ req }) => {
    return {
        props: {
            cookies: req.headers.cookie ?? '',
        },
    }
}) satisfies GetServerSideProps<ChakraProps>
Enter fullscreen mode Exit fullscreen mode

src/theme.tsx

import { extendTheme, type ThemeConfig } from '@chakra-ui/react'

const config: ThemeConfig = {
    initialColorMode: 'dark',
    useSystemColorMode: false,
}

const theme = extendTheme({
    config,
})

export default theme
Enter fullscreen mode Exit fullscreen mode

src/pages/_document.tsx

import theme from "@/theme";
import { ColorModeScript } from "@chakra-ui/react";
import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <ColorModeScript initialColorMode={theme.config.initialColorMode} type="cookie"/>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
Enter fullscreen mode Exit fullscreen mode

src/pages/_app.tsx

import type { AppProps } from "next/app";
import { SWRConfig } from "swr";
import { Chakra } from "@/chakra";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <SWRConfig
        value={{
          refreshInterval: 3000,
        }}
      >
        <Chakra cookies={pageProps.cookies}>
          <Component {...pageProps} />
        </Chakra>
      </SWRConfig>
      <style jsx global>
        {`
          #__next {
            background: inherit;
          }
        `}
      </style>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

src/pages/index.tsx

...
export { getServerSideProps } from '@/chakra';
Enter fullscreen mode Exit fullscreen mode

In order to keep the theme from blinking on page refresh, information about theme stored in cookies, so that Next.js on the pre-render knows what color to use.

And make the color of the buttons in the task card a little bit darker.

src/theme.ts

const theme = extendTheme({
    config,
    components: {
        Button: {
            variants: {
                insideCard: (props: StyleFunctionProps) => {
                    const c = props.theme.colors[props.colorScheme]
                    return {
                        ...props.theme.components.Button.variants.solid(props),
                        background: c['500'],
                    }
                }
            }
        }
    },
})
Enter fullscreen mode Exit fullscreen mode
Result

result without animations

We are done with basic styling. Thank you for doing this with me. In the next part, we'll add animations for a task list and theme switching.

If you have any questions or suggestions please leave them in the comments.

The full code is available at GitHub

Top comments (0)