DEV Community

Cover image for How to build a Meditation App with Vue.js [ Series - Portfolio Apps  ]
Sith Norvang
Sith Norvang

Posted on • Updated on

How to build a Meditation App with Vue.js [ Series - Portfolio Apps ]

This is the first episode of "Apps For Your Portfolio" series. In this post, we are going to build a Meditation App with my favorite front-end framework Vue.js.

1.0 / Setup

2.0 / Assets

3.0 / Components

[ 1.1 ] Vue 3 Config

# Install latest stable of Vue

yarn global add @vue/cli
Enter fullscreen mode Exit fullscreen mode

[ 1.2 ] Creating a new project

# Create a New Vue Application name 'meditation-app'

vue create meditation-app
cd meditation-app
yarn
Enter fullscreen mode Exit fullscreen mode

[ 1.3 ] Install Vuex & Sass Loader

We also need to install sass-loader pre-processors to animate easily our future timer. If you don't know Sass is an extension of CSS that enables you to use things like variables, nested rules, inline imports and more.

You can find more information on the official documentation :

https://sass-lang.com/documentation

yarn add -D sass-loader sass
Enter fullscreen mode Exit fullscreen mode

To help my code organized in this application. I decide to use the state management pattern Vuex.

yarn add vuex@next --save
Enter fullscreen mode Exit fullscreen mode

To use Vuex, we need also to change main.js and create a new folder name "store" with four files :

  • index.js
  • actions.js
  • mutations.js
  • getters.js
# ../src/main.js

import { createApp } from 'vue';

import App from './App.vue';
import store from './store/index.js'

const app = createApp(App);

app.use(store);

app.mount('#app');

Enter fullscreen mode Exit fullscreen mode
src
|
|-- store |-- actions.js
          |-- getters.js 
          |-- index.js
          |-- mutations.js
Enter fullscreen mode Exit fullscreen mode

To build our meditation application, let's initialize all states, actions, mutations and getters as following :

'isPlaying'
'timeSelected'
'vibeSelected'
'step'
'choices'

# ../store/index.js

import { createStore } from 'vuex'

import rootMutations from './mutations.js'
import rootActions from './actions.js'
import rootGetters from './getters.js'

const store = createStore({
  state() {
    return {
      isPlaying: false,
      timeSelected: 0,
      vibeSelected: { value: 'bird'},
      step: 0,
      choices: [
        { 
          id: 1,
          name: '10 minutes',
          imgSrc: require("@/assets/images/Bouton_10_minutes.png"),
          category: 'timer',
          value: 600,
        },
        { 
          id: 2,
          name: '20 minutes',
          imgSrc: require("@/assets/images/Bouton_20_minutes.png"),
          category: 'timer',
          value: 1200,
        },
        { 
          id: 3,
          name: '30 minutes',
          imgSrc: require("@/assets/images/Bouton_30_minutes.png"),
          category: 'timer',
          value: 1800,
        },
        {
          id: 4,
          name: 'In The Space',
          imgSrc: require("@/assets/images/Bouton_Space.png"),
          category: 'vibe',
          value: 'space',
        },
        {
          id: 5,
          name: 'On The Beach',
          imgSrc: require("@/assets/images/Bouton_Beach.png"),
          category: 'vibe',
          value: 'beach',
        },
        {
          id: 6,
          name: 'Under The Rain',
          imgSrc: require("@/assets/images/Bouton_Rain.png"),
          category: 'vibe',
          value: 'rain',
        },
      ]
    }
  },
  mutations: rootMutations,
  actions: rootActions,
  getters: rootGetters,
});

export default store;

Enter fullscreen mode Exit fullscreen mode
# ../src/store/actions.js

export default {
  changeTimer(context, time) {
    context.commit('changeTimer', time)
  },
  changeVibe(context, vibe) {
    context.commit('changeVibe', vibe)
  },
  changeStep(context) {
    context.commit('changeStep')
  },
  activeIsPlaying(context) {
    context.commit('activeIsPlaying')
  },
  togglePlayPause(context) {
    context.commit('togglePlayPause')
  },
}
Enter fullscreen mode Exit fullscreen mode
# ../src/store/mutations.js

export default {
  changeTimer(state, time) {
    state.timeSelected = time
  },
  changeVibe(state, vibe) {
    state.vibeSelected = vibe 
  },
  changeStep(state) {
    state.step++
  },
  activeIsPlaying(state) {
    state.isPlaying = true
  },
  togglePlayPause(state) {
    state.isPlaying = !state.isPlaying
  },
}
Enter fullscreen mode Exit fullscreen mode
# ../src/store/getters.js

export default {
  timeSelected(state) {
    return state.timeSelected
  },
  vibeSelected(state) {
    return state.vibeSelected
  },
  step(state) {
    return state.step
  },
  choices(state) {
    return state.choices
  },
  isPlaying(state) {
    return state.isPlaying
  }
}
Enter fullscreen mode Exit fullscreen mode

Great ! The next step is importing all assets.

Let's create four new folders in assets :

  • images,
  • sounds,
  • svg,
  • videos,
src
|
|-- assets --|-- images
             |-- sounds
             |-- svg
             |-- videos

Enter fullscreen mode Exit fullscreen mode

[ 2.1 ] Assets Images

Maybe you didn't notice but the state 'choices' is an array of objects. Each object has an attribute 'imgSrc' as this example.

{
  id: 6,
  name: 'Under The Rain',
  imgSrc: require("@/assets/images/Bouton_Rain.png"),
  category: 'vibe',
  value: 'rain',
},
Enter fullscreen mode Exit fullscreen mode

As you can see it 'require("@/assets/images/Bouton_Rain.png")' is the path to import the file 'Bouton_Rain.png'

You need to create six illustrations relative to timer and to background video as following :

Button Timer

Alt Text

I took some image from https://unsplash.com/ and resizing it to 375px width | 250px height.

src
|
|-- assets --|-- images -- Bouton_10_minutes.png
                       |-- Bouton_20_minutes.png
                       |-- Bouton_30_minutes.png
                       |-- Bouton_Beach.png
                       |-- Bouton_Rain.png
                       |-- Bouton_Space.png

Enter fullscreen mode Exit fullscreen mode

[ 2.2 ] Assets Sounds

Alt Text

Download four sound effects from https://www.videvo.net/ and import them in sounds folder.

src
|
|-- assets -- sounds -- beach.mp3
                    |-- calmSpace.mp3
                    |-- rain.mp3
                    |-- songOfBirds.mp3

Enter fullscreen mode Exit fullscreen mode

[ 2.3 ] Assets Svg

Create eight components in svg folder :

src
|
|-- assets -- svg -- BeachSvg.vue
                 |-- GithubSvg.vue
                 |-- LinkedInSvg.vue
                 |-- PauseSvg.vue
                 |-- PlaySvg.vue
                 |-- PraySvg.vue
                 |-- RainSvg.vue
                 |-- SpaceSvg.vue

Enter fullscreen mode Exit fullscreen mode
# ../src/assets/svg/BeachSvg.vue

<template>
  <svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
    <path d="M115.4 200.8L217.5 238.15C252.63 156.53 303.75 93.75 356.5 64.45C260.62 59.575 167.7 101.41 108 176.15C101.2 184.6 105.2 197.2 115.4 200.8ZM247.6 249L486.1 335.87C521.85 214.47 504.72 104.27 443.47 81.97C436.095 79.345 428.35 77.908 420.35 77.908C362.4 77.88 292.1 147.13 247.6 249ZM521.5 124.51C527.75 140.76 532.25 159.13 534.63 179.76C540.38 229.63 533.254 287.86 515.75 346.66L618.35 384.03C628.48 387.78 639.6 380.655 639.85 369.91C642.3 274.1 598 182.4 521.5 124.51ZM528 512H321L386 333.5L325.87 311.63L252.99 512.03H48C21.49 512 0 533.5 0 560C0 568.8 7.163 576 16 576H560C568.837 576 576 568.837 576 560.9C576 533.5 554.5 512 528 512Z" :fill="defaultColor"/>
  </svg>
</template>

<script>
export default {
  props: {
    size: {
      type: String,
      required: false,
    },
    color: {
      type: String,
      required: false,
    }
  },
  computed: {
    defaultWidth() {
      return this.size
    },
    defaultHeight() {
      return this.size
    },
    defaultColor() {
      return this.color
    }
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode
# ../src/assets/svg/GithubSvg.vue

<template>
  <svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path fill-rule="evenodd" clip-rule="evenodd" d="M320 140C220.4 140 140 220.4 140 320C140 399.2 191.6 466.4 263.6 490.4C273.2 491.6 275.6 486.8 275.6 482V450.8C225.2 461.6 214.4 426.8 214.4 426.8C206 406.4 194 400.4 194 400.4C177.2 389.6 195.2 389.6 195.2 389.6C213.2 390.8 222.8 407.6 222.8 407.6C238.4 435.2 264.8 426.8 275.6 422C276.8 410 281.6 402.8 287.6 398C248 393.2 206 377.6 206 309.2C206 290 213.2 273.2 224 261.2C221.6 256.4 215.6 238.4 225.2 213.2C225.2 213.2 240.8 208.4 274.4 231.2C288.8 227.6 304.4 225.2 320 225.2C335.6 225.2 351.2 227.6 365.6 231.2C400.4 208.4 414.8 213.2 414.8 213.2C424.4 238.4 418.4 256.4 416 261.2C428 273.2 434 290 434 309.2C434 378.8 392 393.2 352.4 398C358.4 404 364.4 414.8 364.4 431.6V480.8C364.4 485.6 368 491.6 376.4 489.2C448.4 465.2 498.8 398 498.8 318.8C500 220.4 419.6 140 320 140Z" :fill="defaultColor"/>
  </svg>
</template>

<script>
export default {
  props: {
    size: {
      type: String,
      required: false,
    },
    color: {
      type: String,
      required: false,
    }
  },
  computed: {
    defaultWidth() {
      return this.size
    },
    defaultHeight() {
      return this.size
    },
    defaultColor() {
      return this.color
    }
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode
# ../src/assets/svg/LinkedInSvg.vue

<template>
  <svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path d="M512 96H127.9C110.3 96 96 110.5 96 128.3V511.7C96 529.5 110.3 544 127.9 544H512C529.6 544 544 529.5 544 511.7V128.3C544 110.5 529.6 96 512 96ZM231.4 480H165V266.2H231.5V480H231.4ZM198.2 237C176.9 237 159.7 219.7 159.7 198.5C159.7 177.3 176.9 160 198.2 160C219.4 160 236.7 177.3 236.7 198.5C236.7 219.8 219.5 237 198.2 237V237ZM480.3 480H413.9V376C413.9 351.2 413.4 319.3 379.4 319.3C344.8 319.3 339.5 346.3 339.5 374.2V480H273.1V266.2H336.8V295.4H337.7C346.6 278.6 368.3 260.9 400.6 260.9C467.8 260.9 480.3 305.2 480.3 362.8V480V480Z" :fill="defaultColor"/>
  </svg>
</template>

<script>
export default {
  props: {
    size: {
      type: String,
      required: false,
    },
    color: {
      type: String,
      required: false,
    }
  },
  computed: {
    defaultWidth() {
      return this.size
    },
    defaultHeight() {
      return this.size
    },
    defaultColor() {
      return this.color
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode
# ../src/assets/svg/PauseSvg.vue

<template>
  <svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
    <path d="M432 127.1H400C373.49 127.1 352 148.59 352 174.2V462.2C352 488.71 373.49 510.2 400 510.2L432 512C458.51 512 480 490.51 480 464V176C480 149.49 458.5 127.1 432 127.1ZM240 127.1H208C181.49 127.1 160 148.59 160 175.1V463.1C160 490.5 181.49 512 208 512H240C266.51 512 288 490.51 288 464V176C288 149.49 266.5 127.1 240 127.1Z" :fill="defaultColor"/>
  </svg>

</template>

<script>
export default {
  props: {
    size: {
      type: String,
      required: false,
    },
    color: {
      type: String,
      required: false,
    }
  },
  computed: {
    defaultWidth() {
      return this.size
    },
    defaultHeight() {
      return this.size
    },
    defaultColor() {
      return this.color
    }
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode
# ../src/assets/svg/PlaySvg.vue

<template>
  <svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
    <path d="M176 543.98C148.6 543.98 128 521.58 128 495.98V143.98C128 118.6 148.4 96 176.01 96C184.696 96 193.36 98.352 201.03 103.031L489.03 279.031C503.3 287.78 512 303.28 512 319.98C512 336.68 503.297 352.21 489.03 360.93L201.03 536.93C193.4 541.58 184.7 543.98 176 543.98Z" :fill="defaultColor"/>
  </svg>
</template>

<script>
export default {
  props: {
    size: {
      type: String,
      required: false,
    },
    color: {
      type: String,
      required: false,
    }
  },
  computed: {
    defaultWidth() {
      return this.size
    },
    defaultHeight() {
      return this.size
    },
    defaultColor() {
      return this.color
    }
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode
# ../src/assets/svg/PraySvg.vue

<template>
  <svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
    <g clip-path="url(#clip0)">
    <path d="M272 255.9C254.38 255.9 240 270.25 240 287.87V367.9C240 376.775 232.875 383.9 224 383.9C215.125 383.9 208 376.775 208 367.9V291.4C208 274.03 212.75 256.9 221.75 242.03L299.5 112.41C308.5 97.29 303.625 77.65 288.5 68.53C273.1 59.775 255.8 64.1289 246.1 77.63C245.1 77.88 245.5 77.88 245.4 78.13L128.1 254C117.5 269.9 112 288.3 112 307.3V387.54L21.87 416.64C8.75 421.9 0 434.1 0 447.9V543.89C0 554.77 8.5 574.99 32 574.99C34.75 574.99 37.375 574.74 40 573.99L219.3 527.37C269.1 514 304 467.8 304 415.9V287.9C304 270.3 289.6 255.9 272 255.9ZM618.1 417.6L528 387.6V307.4C528 288.4 522.5 270.03 511.88 254.15L394.58 78.25C394.455 78 393.955 78.0013 393.83 77.7513C384.205 64.2513 365.95 59.9013 351.45 68.5223C336.33 77.6473 331.45 97.2823 340.45 112.532L418.2 242.032C427.3 257 432 274 432 291.5V367.99C432 376.865 424.875 383.99 416 383.99C407.125 383.99 400 376.865 400 367.99V287.1C400 269.48 385.62 255.13 368 255.13C350.38 255.13 336 269.51 336 286.23V413.33C336 465.2 370.88 511.45 420.75 525.73L600 575C602.6 575.6 605.4 576 608 576C631.5 576 640 554.75 640 544.9V448.91C640 434.3 631.3 422 618.1 417.6Z" :fill="defaultColor"/>
    </g>
    <defs>
    <clipPath id="clip0">
    <rect width="640" height="512" fill="white" transform="translate(0 64)"/>
    </clipPath>
    </defs>
  </svg>
</template>

<script>
export default {
  props: {
    size: {
      type: String,
      required: false,
    },
    color: {
      type: String,
      required: false,
    }
  },
  computed: {
    defaultWidth() {
      return this.size
    },
    defaultHeight() {
      return this.size
    },
    defaultColor() {
      return this.color
    }
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode
# ../src/assets/svg/RainSvg.vue

<template>
  <svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
    <path d="M146.56 295.9C127.79 327.4 96 384.6 96 411.4C96 449.3 124.65 480 160 480C195.35 480 224 449.27 224 411.36C224 384.64 192.21 327.4 173.44 295.96C167.14 285.4 152.86 285.4 146.56 295.9ZM320 219.4C320 192.68 288.21 135.44 269.44 104C263.133 93.43 248.86 93.43 242.55 104C223.8 135.4 192 192.6 192 219.4C192 257.3 220.7 288 256 288C291.3 288 320 257.3 320 219.4ZM430.6 199.5C423.684 189.49 408.33 189.49 401.41 199.5C367.8 248.1 288 369.2 288 422.3C288 489.5 345.3 544 416 544C486.7 544 544 489.5 544 422.3C544 369.2 464.2 248.1 430.6 199.5Z" :fill="defaultColor"/>
  </svg>
</template>

<script>
export default {
  props: {
    size: {
      type: String,
      required: false,
    },
    color: {
      type: String,
      required: false,
    }
  },
  computed: {
    defaultWidth() {
      return this.size
    },
    defaultHeight() {
      return this.size
    },
    defaultColor() {
      return this.color
    }
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode
# ../src/assets/svg/SpaceSvg.vue

<template>
  <svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
    <g clip-path="url(#clip0)">
    <path d="M323.7 150L373.36 170.63L393.98 220.3C395.105 222.675 397.478 224.007 399.978 224.007C402.478 224.007 404.854 222.675 405.979 220.3L426.599 170.63L476.219 150C478.594 148.875 479.967 146.499 479.967 143.998C479.967 141.497 478.594 139.124 476.219 137.999L426.6 117.38L405.98 67.75C404.9 65.375 402.5 64 400 64C397.5 64 395.128 65.375 394.003 67.75L373.383 117.38L323.723 138.01C321.348 139.135 320.011 141.509 320.011 144.009C320.011 146.509 321.3 148.88 323.7 150ZM428.2 331.2L323.4 315.92L276.6 220.7C268.225 203.7 243.88 203.57 235.38 220.7L188.5 315.1L83.71 331.2C64.8322 333.1 57.258 357.2 71.007 370.5L146.877 444.5L129.777 549.1C126.652 567.98 146.477 582.15 163.097 573.28L256.887 523.9L350.627 573.28C358.377 577.405 367.757 576.666 374.877 571.541C380.977 566.416 385.497 557.631 383.996 549.131L365.2 444.5L441.12 370.5C454.7 357.3 447.1 333.1 428.2 331.2ZM573 283.3L533.38 266.67L516.76 227.04C515.885 225.165 514.009 224.037 512.009 224.037C510.009 224.037 508.135 225.165 507.26 227.04L490.64 266.67L451 283.3C449.125 284.175 447.998 286.046 447.998 288.046C447.998 290.046 449.125 291.925 451 292.8L490.62 309.43L507.24 349.06C508.115 350.935 509.989 352.055 511.989 352.055C513.989 352.055 515.865 350.935 516.74 349.06L533.36 309.43L572.98 292.8C574.9 291.9 576 290 576 288C576 286 574.9 284.1 573 283.3Z" :fill="defaultColor"/>
    </g>
    <defs>
    <clipPath id="clip0">
    <rect width="512" height="512" fill="white" transform="translate(64 64)"/>
    </clipPath>
    </defs>
  </svg>
</template>

<script>
export default {
  props: {
    size: {
      type: String,
      required: false,
    },
    color: {
      type: String,
      required: false,
    }
  },
  computed: {
    defaultWidth() {
      return this.size
    },
    defaultHeight() {
      return this.size
    },
    defaultColor() {
      return this.color
    }
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode

[ 2.4 ] Assets Videos

Alt Text

From https://www.videvo.net/ download and import 4 background videos in assets.

src
|
|-- assets -- videos -- bird.mp4
                    |-- rain.mp4
                    |-- space-galaxy.mp4
                    |-- sunny-beach.mp4
Enter fullscreen mode Exit fullscreen mode

All assets are imported now ! Let's create all components 🙂

[ 3.1 ] ContainerBackground.vue

Add a new folder "container" in "../src/components" and create a new component "ContainerBackground.vue"

# ../src/components/container/ContainerBackground.vue

<template>
  <div class="video-container">
    <video
      v-if="vibeSelected === 'space'"
      class="video-wrapper"
      autoplay
      muted
      loop
    >
      <source
        :src="updateBackground"
        type="video/mp4"
        rel='preload'
      />
    </video>
    <video
      v-if="vibeSelected === 'beach'"
      class="video-wrapper"
      autoplay
      muted
      loop
    >
      <source
        :src="updateBackground"
        type="video/mp4"
        rel='preload'
      />
    </video>
    <video
      v-if="vibeSelected === 'rain'"
      class="video-wrapper"
      autoplay
      muted
      loop
    >
      <source
        :src="updateBackground"
        type="video/mp4"
        rel='preload'
      />
    </video>
    <video
      v-if="vibeSelected === 'bird'"
      class="video-wrapper"
      autoplay
      muted
      loop
    >
      <source
        :src="updateBackground"
        type="video/mp4"
        rel='preload'
      />
    </video>
  </div>  
</template>

<script>
export default {
  computed: {
    updateBackground() {
      if (
          this.vibeSelected === 'space' || 
          this.vibeSelected === null
        ) 
      {
        return require("@/assets/videos/space-galaxy.mp4")
      } else if (this.vibeSelected === 'beach') {
        return require("@/assets/videos/sunny-beach.mp4")
      } else if (this.vibeSelected === 'rain') {
        return require("@/assets/videos/rain.mp4")
      } else {
        return require("@/assets/videos/bird.mp4")
      }
    },
    vibeSelected() {
      return this.$store.getters['vibeSelected'].value
    }
  },
}
</script>

<style>
.video-container {
  position: absolute;
  z-index: -5;
  display: flex;
  width: 100vw;
  height: 100vh;
}

.video-wrapper {
  position: absolute;
  width: 100%;
  height: auto;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: -10;
  overflow: none;
}

@media (min-aspect-ratio: 16/9) {
  .video-wrapper {
    width:100%;
    height: auto;
  }
}

@media (max-aspect-ratio: 16/9) {
  .video-wrapper { 
    width:auto;
    height: 100%;
  }
}
</style>

Enter fullscreen mode Exit fullscreen mode

This component change background video depending of user choice.

[ 3.2 ] ContainerPopUp.vue

Unlike the 'ContainerBackground.vue' 'ContainerPopUp.vue' is a nested component. It allow us to encapsulate functionality and easily reuse them in multiple places in this application.

Alt Text

ContainerPopUp.vue
   |-- ContainerSelector.vue
                     |-- Selector.vue
Enter fullscreen mode Exit fullscreen mode

Firstly, Let's create 'ContainerPopUp.vue'

# ../src/components/container/ContainerPopUp.vue

<template>
  <div v-if="step < 2" class="overlay">
    <div class="popup">
      <PraySvg
        size="80"
        color="white"
      />
      <h1>Welcome to<br> Vibe Meditation App</h1>
      <ContainerSelector
        :question="questionDisplaying"
      >
        <Selector
          v-if="step === 0"
          :size="'normal'"
          :mode="'timer'"
        />
        <Selector 
           v-if="step > 0"
          :size="'normal'"
          :mode="'vibe'"
        />
      </ContainerSelector>
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

import PraySvg from '@/assets/svg/PraySvg'
import ContainerSelector from '@/components/container/ContainerSelector'
import Selector from '@/components/Selector'

export default {
  components: {
    PraySvg,
    ContainerSelector,
    Selector
  },
  computed: {
    questionDisplaying() {
      if (this.step === 0) {
        return 'How long do you want to meditate ?' 
      } else {
        return 'Where do you want to meditate ?'
      }
    },
    ...mapGetters([
      'timeSelected',
      'vibeSelected',
      'step'
    ])
  }
}
</script>

<style scoped>
.overlay {
  position: absolute;
  width: 100vw;
  height: 100vh;
}

.popup {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  position: absolute;
  width: 600px;
  height: 600px;
  top: 50%; 
  right: 50%;
  transform: translate(50%,-50%);
  background-color: rgba(5, 5, 5, 0.900);
  border-radius: 15px;
  box-shadow: 1px 1px rgba(255, 255, 255, 0.200);
  color: white;
}

.bouton-primary {
  width: 200px;
  line-height: 30px;
  border-radius: 5px;
  margin: 30px;
  background-color: rgba(193, 99, 89, 0.800);
  border: rgba(193, 99, 89, 0.800);
  color: aliceblue;
}

.bouton-off {
  width: 200px;
  line-height: 30px;
  border-radius: 5px;
  margin: 30px;
  background-color: rgba(35, 35, 35, 0.500);
  border: white;
  color: aliceblue;
}
</style>

Enter fullscreen mode Exit fullscreen mode

Second step is creating 'ContainerSelector.vue'

# ../src/components/container/ContainerSelector.vue

<template>
  <section class="selector-container">
    <h3>{{ question }}</h3>
    <div class="selector-wrapper">
      <slot></slot>
    </div>
  </section>
</template>

<script>
export default {
  props: ['question']
}
</script>

<style>
.selector-container {
  position: relative;
  display: flex;
  flex-direction: column;
  width: 500px;
  justify-content: center;
}

h3, .selector-container {
  margin-bottom: 50px;
}

.selector-wrapper {
  display: flex;
  flex-wrap: wrap;
  flex-direction: row; 
  justify-content: space-around;
  width: 100%;
}
</style>

Enter fullscreen mode Exit fullscreen mode

Third step is creating 'Selector.vue'

# ../src/components/Selector.vue

<template>
  <div
    v-for="choice in filteredChoices"
    :key="choice.id"
    @click="selectChoice(choice.id)"
    class="choice-card"
    :class="hideUnselectedImg(choice.id)"
  >
    <img
      :src="choice.imgSrc"
      :alt="choice.name"
    >
    <label 
      :for="choice.name"
      :style="fontSizeDynamic(choice.id)"
    >
      {{ choice.name }}
    </label>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  props: ['size', 'mode'],
  methods: {
    hideUnselectedImg(id) {
       if (this.vibeSelected.id === id) {
        return 'img-selected'
       }
       else {
         return 'img-unselected'
       }
    },
    selectChoice(id) {
      let index = this.choices.findIndex(x => x.id === id);
      if (this.step === 0) {
        let timeSelecting = {
          id: this.choices[index].id,
          value: this.choices[index].value
        }
        this.$store.dispatch('changeTimer', timeSelecting)
        this.$store.dispatch('changeStep')
      } else {
        let vibeSelecting = {
          id: this.choices[index].id,
          value: this.choices[index].value
        }
        this.$store.dispatch('changeVibe', vibeSelecting)
        this.$store.dispatch('changeStep')
        this.$store.dispatch('activeIsPlaying')
      }
    },
    fontSizeDynamic(id) {
       if (this.timeSelected.id === id || this.vibeSelected.id === id) {
        return 'font-weight: bold;'
       }
       else {
         return 'font-weight: normal;'
       }
    },
  },
  computed: {
    filteredChoices() {
      return this.choices.filter(choice => 
        {
          if 
            ( 
              this.mode === 'timer' &&
              choice.category.includes('timer')
            )
          {
            return true
          }
          if 
            (
              this.mode === 'vibe' &&
              choice.category.includes('vibe')
            ) 
          {
            return true;
          }
        }
      )
    },
    ...mapGetters([
      'timeSelected',
      'vibeSelected',
      'step',
      'choices'
    ])
  }
}
</script>

<style scoped>
.choice-card {
  cursor: pointer;
  display: flex;
  flex-direction: column;
}

.img-selected {
  display: none;
}

img:hover {
  opacity: 1;
}

img {
  opacity: 0.5;
  height: 100px; 
  width: 150;
}

.img-unselected {
 border-style: none;
}

label {
  margin-top: 10px;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Perfect ! With this first nested component, the user can select time of meditation and the virtual area where he wants to meditate.

Alt Text

[ 3.3 ] Sound.vue

Previously, we created files which allow us to change background video. For a better User Experience, we need to implement sound effects !

Let's create a new simple component 'Sound.vue'

# ../src/components/Sound.vue

<template>
  <audio :src="updateSound" preload="auto" autoplay loop ref="audioPlayer" />
</template>

<script>
export default {
  computed: {
    updateSound() {
      if (this.vibeSelected === 'space') {
        return require("@/assets/sounds/calmSpace.mp3")
      } else if (this.vibeSelected === 'beach') {
        return require("@/assets/sounds/beach.mp3")
      } else if (this.vibeSelected === 'rain') {
        return require("@/assets/sounds/rain.mp3")
      } else {
        return require("@/assets/sounds/songOfBirds.mp3")
      }
    },
    vibeSelected() {
      return this.$store.getters['vibeSelected'].value
    }
  },
}
</script>

Enter fullscreen mode Exit fullscreen mode

[ 3.4 ] ContainerTimer.vue

Background Video, sound effects so what's next ? Timer Animation of course.

Let's create a new nested component 'ContainerTimer.vue'

ContainerTimer.vue
   |-- TimerHeader.vue
   |-- Timer.vue
   |-- TimerRemaining.vue
   |-- VibeSwitcher.vue

Enter fullscreen mode Exit fullscreen mode
# ../src/components/container/ContainerTimer.vue

<template>
  <section
    v-if="step === 2"
    class="container"
  >
    <TimerHeader />
    <Timer
      :timeLeft="timeLeft"
      :timeSelected="timeSelected.value"
    />
    <TimerRemaining
      :timeLeft="timeLeft"
    />
    <VibeSwitcher />
  </section>
</template>

<script>
import { mapGetters } from 'vuex'
import Timer from '@/components/Timer.vue'
import TimerRemaining from '@/components/TimerRemaining'
import TimerHeader from '@/components/TimerHeader'
import VibeSwitcher from '@/components/VibeSwitcher'

export default {
  components: {
    Timer,
    TimerRemaining,
    TimerHeader,
    VibeSwitcher
  },
  data() {
    return {
      timeLimit: 0,
      timePassed: 0,
      timerInterval : 0,
    };
  },
  computed: {
    ...mapGetters([
      'timeSelected',
      'vibeSelected',
      'step',
      'isPlaying'
    ]),
    timeLeft() { 
      if (this.timerInterval === null) {
        return 0
      } else if (this.timePassed === this.timeSelected.value) {
        clearInterval(this.timerInterval)
        this.$store.dispatch('changeVibe', { value: 'bird' })
        this.$store.dispatch('changeStep')
        console.log('step : ', this.step)
        return 0
      } else {
        return this.timeSelected.value - this.timePassed
      }
    },
  },
  watch: {
    isPlaying(isInProgress) {
      if (isInProgress) {
        this.timerInterval = setInterval(() => (this.timePassed += 1), 1000);
      } else {
        clearInterval(this.timerInterval)
      }
    }
  },
}
</script>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  height: 80%;
  width: 400px;
  background-color: rgba(5, 5, 5, 0.700);
  border-radius: 15px;
  box-shadow: 1px 1px rgba(255, 255, 255, 0.500);
}

h1, .container {
  color: white;
  font-size: 25px;
}
</style>
Enter fullscreen mode Exit fullscreen mode
# ../src/components/TimerHeader.vue

<template>
  <div 
    v-show="vibeSelected === 'space'"
    class="icon-header"
  >
    <SpaceSvg
      size="40"
      color="white"
    />
    <h1>Meditation <br> In The Space</h1>
  </div>
  <div 
    v-show="vibeSelected === 'beach'"
    class="icon-header"
  >
    <BeachSvg
      size="40"
      color="white"
    />
    <h1>Meditation <br> In On Beach</h1>
  </div>
  <div 
    v-show="vibeSelected === 'rain'"
    class="icon-header"
  >
    <RainSvg
      size="40"
      color="white"
    />
    <h1>Meditation <br> Under The Rain</h1>
  </div>
  <div 
    v-show="vibeSelected === 'alarm'"
    class="icon-header"
  >
    <RainSvg
      size="40"
      color="white"
    />
    <h1>Meditation <br> Finished</h1>
  </div>
</template>

<script>
import SpaceSvg from '@/assets/svg/SpaceSvg'
import BeachSvg from '@/assets/svg/BeachSvg'
import RainSvg from '@/assets/svg/RainSvg'

export default {
  name: 'HeaderTimer',
  components: {
    SpaceSvg,
    BeachSvg,
    RainSvg,
  },
  computed: {
    vibeSelected() {
      return this.$store.getters['vibeSelected'].value
    }
  }
}
</script>

<style scoped>
.icon-header {
  min-height: 40px;
}

h1 {
  margin-bottom: 30px;
  margin-top: 0px;
}
</style>

Enter fullscreen mode Exit fullscreen mode
# ../src/components/Timer.vue

<template>
  <div class="base-timer">
    <svg
      class="base-timer__svg"
      viewBox="0 0 100 100"
      xmlns="http://www.w3.org/2000/svg"
    >
      <g class="base-timer__circle">
        <circle
          class="base-timer__path-elapsed"
          cx="50"
          cy="50"
          r="45"
        />
        <path
          :stroke-dasharray="circleDasharray"
          :class="remainingPathColor"
          class="base-timer__path-remaining"
          d="
            M 50, 50
            m -45, 0
            a 45,45 0 1,0 90,0
            a 45,45 0 1,0 -90,0
          ">
        </path>
      </g>
    </svg>
    <span class="base-timer__label">
      <PlaySvg
        @click="togglePlayPause"
        v-show="!isPlaying"
        size="80"
        color="white"
      />
      <PauseSvg
        @click="togglePlayPause"
        v-show="isPlaying"
        size="80"
        color="white"
      />
    </span>
  </div>
</template>

<script>
import PlaySvg from '@/assets/svg/PlaySvg'
import PauseSvg from '@/assets/svg/PauseSvg'
import { mapGetters } from 'vuex'

export default {
  components: {
    PlaySvg,
    PauseSvg
  },
  props: {
    timeLeft: {
      type: Number,
      required: true
    },
    timeSelected: {
      type: Number,
      required: true
    },
    alertThreshold: {
      type: Number,
      default: 5
     },
    warningThreshold: {
      type: Number,
      default: 10
    },
  },
  methods: {
    togglePlayPause() {
      this.$store.dispatch('togglePlayPause')
    }
  },
  computed: {
    ...mapGetters([
      'isPlaying',
    ]),
    circleDasharray() {
      return `${(this.timeFraction * 283).toFixed(0)} 283`;
    },
    timeFraction() {
      const rawTimeFraction = this.timeLeft / this.timeSelected
      return rawTimeFraction -
        (1 / this.timeSelected) * (1 - rawTimeFraction)
    },
    formattedTimeLeft() {
      const timeLeft = this.timeLeft
      let minutes = Math.floor(timeLeft / 60)
      const hours = Math.floor(minutes / 60)
      let seconds = timeLeft % 60
      if (seconds < 10) {
        seconds = `0${seconds}`
      }
      if (minutes === 60) {
        minutes = `00`
      }
      return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
    },
    colorCodes() {
      return {
        info: {
          color: "green"
        },
        warning: {
          color: "orange",
          threshold: this.warningThreshold
        },
        alert: {
          color: "red",
          threshold: this.alertThreshold
        }
      }
    },
    remainingPathColor() {
       const { alert, warning, info } = this.colorCodes;
       if (this.timeLeft <= alert.threshold) {
         return alert.color;
       } else if (this.timeLeft <= warning.threshold) {
         return warning.color;
       } else {
         return info.color;
       }
    }
  }
}
</script>

<style scoped lang="scss">
.base-timer {
  position: relative;
  width: 250px;
  height: 250px;

  &__path-remaining {
    stroke-width: 7px;
    stroke-linecap: round;
    transform: rotate(90deg);
    transform-origin: center;
    transition: 1s linear all;
    fill-rule: nonzero;
    stroke: currentColor;
    &.green {
      color: rgb(65, 184, 131);
    }
    &.orange {
      color: orange;
    }
    &.red {
      color: rgba(193, 99, 89, 1.000);
    }
  }
  &__svg {
    /* Flips the svg and makes the animation to move left-to-right
    transform: scaleX(-1); */
  }
  &__circle {
    fill: rgba(35, 35, 35, 0.500);
    stroke: rgba(35, 35, 35, 0.500);
  }
  &__path-elapsed {
    stroke-width: 7px;
    stroke: none;
  }

    &__label {
    position: absolute;    
    width: 250px;
    height: 250px;
    top: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 48px;
    color: white;
    cursor: pointer;
  }
}
</style>

Enter fullscreen mode Exit fullscreen mode
# ../src/componnents/TimerRemaining.vue

<template>
  <span class="time-remaining">Remaining Time</span>
  <span 
    class="timer-flow"
    :style="isPlaying ? 'opacity: 1.0;' : 'opacity: 0.5'"
  >
    {{ formattedTimeLeft }}
  </span>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  props: {
    timeLeft: {
      type: Number,
      required: true
    },
  },
  computed: {
    formattedTimeLeft() {
      const timeLeft = this.timeLeft
      let minutes = Math.floor(timeLeft / 60)
      const hours = Math.floor(minutes / 60)
      let seconds = timeLeft % 60
      if (seconds < 10) {
        seconds = `0${seconds}`
      }
      if (minutes === 60) {
        minutes = `00`
      }
      return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
    },
    ...mapGetters([
      'timeSelected',
      'vibeSelected',
      'step',
      'isPlaying'
    ]),
  },
}
</script>

<style>
.time-remaining {
  margin-top: 10px;
  font-weight: bold;
}

.timer-flow {
  font-size: 40px
}
</style>

Enter fullscreen mode Exit fullscreen mode
# ../src/components/VibeSwitcher.vue

<template>
  <p>Would You Like Mediate Elsewhere ?</p>
  <div class="switcher">
    <span @click="selectPreviousVibe">{{ previousVibe }}</span>
    <span>  |  </span>
    <span @click="selectNextVibe">{{ nextVibe }}</span>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  methods: {
    selectPreviousVibe() {
      if (this.vibeSelected.value === 'space') {
        this.$store.dispatch('changeVibe', { value: 'rain'})
      } else if (this.vibeSelected.value === 'beach') {
        this.$store.dispatch('changeVibe', { value: 'space'})
      } else {
        this.$store.dispatch('changeVibe', { value: 'beach'})
      }
    },
    selectNextVibe() {
      if (this.vibeSelected.value === 'space') {
        this.$store.dispatch('changeVibe', { value: 'beach'})
      } else if (this.vibeSelected.value === 'beach') {
        this.$store.dispatch('changeVibe', { value: 'rain'})
      } else {
        this.$store.dispatch('changeVibe', { value: 'space'})
      }
    }
  },
  computed: {
    ...mapGetters([
      'vibeSelected'
    ]),
    previousVibe() {
      if (this.vibeSelected.value === 'space') {
        return 'Under The Rain'
      } else if (this.vibeSelected.value === 'beach') {
        return 'In The Space'
      } else {
        return 'On The Beach'
      }
    },
    nextVibe() {
      if (this.vibeSelected.value === 'space') {
        return  'On The Beach'
      } else if (this.vibeSelected.value === 'beach') {
        return 'Under The Rain'
      } else {
        return 'In The Space'
      }
    }
  },
}
</script>

<style scoped>
.switcher {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
}

span {
  display: flex;
  cursor: pointer;
  font-size: 18px;
  margin: 10px;
}

p {
  font-size: 18px;
}
</style>

Enter fullscreen mode Exit fullscreen mode

Alt Text

What do you think ? Nice view right ?

[ 3.5 ] ContainerLinks.vue

When Timer is over, I prepared a last view with my linkedIn, Github and my Portfolio.

You just need to replace links and Svg in this last component.

# ../src/components/container/ContainerLinks.vue

<template>
  <div 
    v-if="step === 3"
    class="container-end"
  >
    <PraySvg :size="'60'" :color="'white'"/>
    <h2>Your Meditation Is Over !</h2>
    <span>Made With Love<br> ❤️ By Sith Norvang ❤️</span>
    <div class="container-network">
      <a
        class="icon-network"
        href="https://github.com/slaoprp"
        target="_blank"
      >
        <GithubSvg :size="'80'" :color="'white'" />
        <span>Github</span>
      </a>
      <a
        class="icon-network"
        href="https://www.linkedin.com/in/sith-norvang-3a72501a5"
        target="_blank"
      >
        <LinkedInSvg  :size="'80'" :color="'white'"  />
        <span>LinkedIn</span>
      </a>
      <a 
        class="icon-network"
        href="https://www.eliyote.com"
        target="_blank"
      >
        <span>Portfolio</span>
      </a>
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
import PraySvg from '@/assets/svg/PraySvg'
import GithubSvg from '@/assets/svg/GithubSvg'
import LinkedInSvg from '@/assets/svg/LinkedInSvg'

export default {
  components: {
    PraySvg,
    GithubSvg, 
    LinkedInSvg
  },
  methods: {
    linkTo() {
      this.$router.push('www.eliyote.com')
    }
  },
  computed: {
    ...mapGetters([
      'step',
    ]),
  }
}
</script>

<style scoped>
.container-end {
  color: white;
  width: auto;
  height: auto;
  padding: 50px;
  background-color: rgba(5, 5, 5, 0.900);
  border-radius: 15px;
  box-shadow: 1px 1px rgba(255, 255, 255, 0.200);
}

.container-network {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  margin: 20px;
}

.icon-network {
  display: flex;
  flex-direction: column;
  cursor: pointer;
}

a {
  text-decoration: none;
  color: white;
}
</style>

Enter fullscreen mode Exit fullscreen mode

Everything is already now ! We need to import all components in App.vue

# ../src/App.vue

<template>
  <main class="app">
    <ContainerPopUp />
    <ContainerBackground />
    <Sound />
    <ContainerTimer />
    <ContainerLinks />
  </main>
</template>

<script>
import ContainerPopUp from '@/components/container/ContainerPopUp'
import ContainerBackground from '@/components/container/ContainerBackground'
import ContainerTimer from '@/components/container/ContainerTimer'
import ContainerLinks from '@/components/container/ContainerLinks'
import Sound from '@/components/Sound'

export default {
  name: "App",
  components: {
    ContainerPopUp,
    ContainerBackground,
    ContainerTimer,
    ContainerLinks,
    Sound
  },
};
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

body {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  overflow: hidden;
}

.app {
  height: 100vh;
  width: 100vw;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
</style>

Enter fullscreen mode Exit fullscreen mode

Alt Text

There are so many other way to build this meditation application.

I hope this will help a beginner in Vue. See you in the next post of this series "Portfolio Apps".

You can try this meditation-app here :

http://vibe-meditation.me/

Latest comments (2)

Collapse
 
karfagen profile image
Sofiia Shevchuk

Thanks, this might be useful to me. I'm just thinking about build a meditation app

Collapse
 
chill_whisper profile image
Chill Whisper

I love it, and i will used maybe in future i wanna make some link exchange with me app.