Making a simple Japanese character learning app w/ Monaca, Capacitor, ReactJS, and Ionic

Alvaro Saldanha
The Web Tub
Published in
16 min readFeb 21, 2024
Photo by Ryoma Onita on Unsplash

こんにちは! ! !

The first step in everyone’s Japanese language learning journey tends to be Hiragana and Katakana. When starting, it can be overwhelming to find the right resources to learn from. Not only that, but learning requires consistent practice of reading and memorization, which are key to further improve comprehension.

In the beginning, it is normal to use multiple resources for memorizing characters and, while they can all be very useful, going back and forth between websites, apps, and textbooks is always a chore. As such, let’s try our hand at creating a prototype app for learning Japanese characters (including N5 to N4 Kanji), using Capacitor, ReactJS, and Ionic, for Android, IOS, and browser. This way, we can sample an app that brings together the learning experiences for all alphabets.

In this blogpost, I will share the process of creating such an app, along with some lessons learned from choosing the mentioned technologies.

Photo by @felipepelaquim on Unsplash

Index:

  1. App Setup
    1.1. create-react-app
    1.2. monaca-cli and yarn
  2. SQLite Offline Storage
  3. Native Audio
  4. Dark Mode
  5. Quiz Section

Technologies Used:

  • ReactJS: Basis for app development, with JSX.
  • Ionic CSS components: for styling and animations.
  • Capacitor: for building the app.
  • react-swipeable: for allowing swiping between screens on mobile platforms.
  • @capacitor-community/sqlite: for offline storage of user quiz progress and character unlocks.
  • @capacitor-community/native-audio: for playing the pronunciation files of each characters.
  • Monaca (optional): as an environment setup alternative.

App Structure

The application consists mainly of three screens: a hiragana, a katakana, and a kanji one. Each screen presents a grid of all characters, and allows the user to select a more detailed view of the already-unlocked characters by clicking on them. A user unlocks more characters by progressing through quizzes and getting correct answers. Inside a character’s “detailed view” component, its pronunciation can be heard and its progress tracked. In quizzes, there are two types of questions: multiple choice and linking questions. Finally, there is also a settings screen, allowing the user to turn on and off dark mode, and reset progression.

Hiragana Screen

The rest of this blogpost will go over the main challenges of building the application, focusing on the technologies utilized. It will also give a more detailed overview of the main logic of the application as it goes along. Let’s start!

  1. App Setup

1.1. App Setup with create-react-app

There are several ways of starting new React projects, depending on your preferred technology stack. A quick search will make it very simple for anyone looking to learn how. For example, using the official ReactJS documentation: https://react.dev/learn/start-a-new-react-project. Other pre-requisites might be installing Node (if you have not already), Android Studio and Xcode, where the apps will then be tested.

Once the app is setup, it’s time to add Capacitor to it. For example:

npm install -D @capacitor/cli
npx cap init
npm install @capacitor/core @capacitor/ios @capacitor/android
npx cap add ios
npx cap add android

Then, once it’s time to run the app on your desired target platform (Android Studio or Xcode), simply run:

npx cap open PLATFORM

where PLATFORM is either android or iOS. To run in the browser, run:

npm run build
npm start

1.2. App Setup with monaca-cli and yarn

If the goal is to use Monaca to create the application, first create an account (https://ja.monaca.io/), then follow these steps:

# Installing Monaca
npm i -g monaca
# Login to Monaca
monaca login
# Install yarn if needed
npm install yarn -g
# Create Monaca App, choose Capacitor template
monaca create SampleApp
# Capacitor setup
yarn add @capacitor/core @capacitor/ios @capacitor/android
yarn cap add ios
yarn cap add android
yarn cap sync

And to open your desired target platform (Android Studio or Xcode):

yarn cap open PLATFORM

where PLATFORM is either android or iOS. Alternatively, build your application on Monaca cloud.

To run in the browser, run:

yarn dev

or

monaca preview

2. SQLite Offline Storage

Since the application stores the user progress for each character, along with its pronunciation, the first step when opening the app is to load the most recent data from offline storage. To implement storing user progress, I chose the “@capacitor-community/sqlite” community plugin. There are other options, and I recommend reading the official documentation page on this topic: https://capacitorjs.com/docs/guides/storage. Additionally, refer to this page for more information on Capacitor plugins: https://capacitorjs.com/docs/plugins

It took quite a chunk of time to get this plugin to work. As such, I will share the implementation strategy that worked for me, along with the problems I found. First, install the plugin:

npm install --save @capacitor-community/sqlite
npx cap sync

or

yarn add @capacitor-community/sqlite
npx cap sync

Then, add this to your capacitor.config.json file:

plugins: {
CapacitorSQLite: {
iosDatabaseLocation: 'Library/CapacitorDatabase',
iosIsEncryption: true,
iosKeychainPrefix: 'angular-sqlite-app-starter',
iosBiometric: {
biometricAuth: false,
biometricTitle : "Biometric login for capacitor sqlite"
},
androidIsEncryption: true,
androidBiometric: {
biometricAuth : false,
biometricTitle : "Biometric login for capacitor sqlite",
biometricSubTitle : "Log in using your biometric"
},
electronIsEncryption: true,
electronWindowsLocation: "C:\\ProgramData\\CapacitorDatabases",
electronMacLocation: "/Volumes/Development_Lacie/Development/Databases",
electronLinuxLocation: "Databases"
}
}

Additionally, don’t forget to copy the file sql-wasm.wasm from nodes_modules/sql.js/dist/sql-wasm.wasm to the public/assets folder, manually.

Now, I recommend using the following Github repository as a reference for your implementation: https://github.com/aaronksaunders/ionic7-react-sqlite/tree/main. Essentially, database setup, initialization, and access is wrapped in a React component. Attempting to create a separate “service” or set of independent methods to access the database consistently resulted in issues when initializing the databases. Namely, even when using the exact same methods but outside a React component, the tables could not be opened, as the database was undefined. As such, if you run into the same problems, the given repository finds a way around these issues.

@capacitor-community/sqlite” is very useful and has good support, specially considering it’s a community plugin. However, navigating through the documentation can be overwhelming at first. So, trying to find implementations of apps that use the same technologies as yours is highly recommended.

For a deeper look at my solution, this is index.jsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Main from './Main';
import { CapacitorSQLite, SQLiteConnection} from "@capacitor-community/sqlite";
import { JeepSqlite } from "jeep-sqlite/dist/components/jeep-sqlite";
import { setupIonicReact } from '@ionic/react';

setupIonicReact();

window.addEventListener("DOMContentLoaded", async () => {
try {
const platform = Capacitor.getPlatform();
if (platform === "web") {
const sqlite = new SQLiteConnection(CapacitorSQLite);
customElements.define("jeep-sqlite", JeepSqlite);
const jeepSqliteEl = document.createElement("jeep-sqlite");
document.body.appendChild(jeepSqliteEl);
await customElements.whenDefined("jeep-sqlite");
await sqlite.initWebStore();
}
const root = ReactDOM.createRoot(document.getElementById('root'));
console.log("Rendering Main from Index")
root.render(
<Main/>
);
} catch (e) {
console.log(e);
}
});

The “setupIonicReact()” will enable the use of Ionic styling components ahead. The developer can also opt to use Ionic for app routing. The rest of the code is ensuring the offline storage works in the browser, and comes from the very useful given repository above.

Afterwards, Main.jsx will look like this:

import React, { useState, useEffect } from 'react';
import '@ionic/react/css/core.css';
import Loading from './Loading';
import App from './App';
import useSQLiteDB from './useSQLiteDB';
import { DarkModeProvider } from './containers/DarkModeContext';

function Main() {

const [isLoading, setIsLoading] = useState(true);
const { performSQLAction, initialized , restartDB } = useSQLiteDB();
const [katakanaData, setKatakanaData] = useState({});
const [hiraganaData, setHiraganaData] = useState({});
const [kanjiData, setKanjiData] = useState({});
const [currentPage, setCurrentPage] = useState('hiragana');

useEffect(() => {
const loadDataAsync = async () => {
await loadData();
setIsLoading(false);
};
if (initialized) {
loadDataAsync();
}
}, [initialized]);

const loadData = async () => {
try {
performSQLAction(async (db) => {
const tables = JSON.stringify(await db?.query(`Select name From sqlite_schema Where type ='table'`));
console.log('Tables in db: ' + tables)
const hiragana = JSON.stringify(await db?.query(`Select * from charProgressionHiragana`));
console.log('Hiragana Table: ' + hiragana)
const katakana = JSON.stringify(await db?.query(`Select * from charProgressionKatakana`));
console.log('Katakana Table: ' + katakana)
const kanji = JSON.stringify(await db?.query(`Select * from charProgressionKanji`));
console.log('Kanji Table: ' + kanji)
setKatakanaData(JSON.parse(katakana))
setHiraganaData(JSON.parse(hiragana))
setKanjiData(JSON.parse(kanji))
});
} catch (error) {
console.log((error).message);
}
};

...

const restart = async () => {
setIsLoading(true);
await restartDB();
setCurrentPage(currentPage);
setIsLoading(false)
};

return (
<DarkModeProvider>
<div className="App">
{isLoading ? (<Loading />) : (<App katakanaData={katakanaData} hiraganaData={hiraganaData} kanjiData={kanjiData} currentPageInherited={currentPage} reload={reload} restart={restart} />)}
</div>
</DarkModeProvider>
);
}

export default Main;

So, there is a very simple loading screen while the data is loaded and passed as props to the App container:

import React from 'react';
import {IonSpinner} from '@ionic/react';

const Loading = () => {
return (
<div className="loading-page">
<IonSpinner name="crescent" />
<h1>Loading...</h1>
</div>
);
}

export default Loading;

The App container will then use callbacks to access the database. Again, it’s highly recommended to check the repository given above for a full overview of the database implementation. If instead of ReactJS, the developer is using VueJS, Angular, etc…, the implementation will be different.

The structure of the App.js container is as such:

import Hiragana from './containers/Hiragana';
import Katakana from './containers/Katakana';
import Kanji from './containers/Kanji';
import './App.css';
import { useSwipeable } from 'react-swipeable';
import React, { useState } from 'react';
import '@ionic/react/css/core.css';
import { IonFooter, IonToolbar, IonButton, IonLabel, IonAlert, IonApp, IonSegment, IonSegmentButton, IonIcon } from '@ionic/react';

...

function App({ katakanaData, hiraganaData, kanjiData, reload, currentPageInherited, restart}) {

const [activeQuiz, setActiveQuiz] = useState(false);
const [quizLoading, setQuizLoading] = useState([false, 5]);
const pages = ['hiragana', 'katakana', 'kanji', 'settings'];
const [currentPage, setCurrentPage] = useState(currentPageInherited);
const { darkMode } = useDarkMode();

const handleSwipe = useSwipeable({
onSwipedLeft: () => {
if (activeQuiz) return;
const currentIndex = pages.indexOf(currentPage);
const nextPage = currentIndex < pages.length - 1 ? pages[currentIndex + 1] : pages[0];
setCurrentPage(nextPage);
},
onSwipedRight: () => {
if (activeQuiz) return;
const currentIndex = pages.indexOf(currentPage);
const nextPage = currentIndex === 0 ? pages[pages.length - 1] : pages[currentIndex - 1];
setCurrentPage(nextPage);
}
});

const startQuiz = () => {
setQuizLoading([true,5]);
}

const endQuiz = (userResponses) => {
setActiveQuiz(false)
reload(userResponses, currentPage)
}

const filterQuizData = () => {
switch (currentPage) {
case "hiragana":
return hiraganaData.values.filter(item => item.level > 0);
case "katakana":
return katakanaData.values.filter(item => item.level > 0);
case "kanji":
return kanjiData.values.filter(x => x.jlpt === quizLoading[1]).filter(item => item.level > 0);
default:
break
}
}

return (
<IonApp> <div className="App" {...handleSwipe}>
{!activeQuiz && currentPage === 'hiragana' && <Hiragana data={hiraganaData.values} />}
{!activeQuiz && currentPage === 'katakana' && <Katakana data={katakanaData.values} />}
{!activeQuiz && currentPage === 'kanji' && <Kanji data={kanjiData.values} />}
{!activeQuiz && currentPage === 'settings' && <Settings restart={restart} />}
{!activeQuiz && (
<IonFooter style={{ position: 'fixed', bottom: '0', width: 'inherit' }}>
{currentPage !== "settings" && <IonToolbar id="quizbtn">
<IonButton onClick={startQuiz} style={{ width: '100%' }} id="quizbtn" fill="clear">
<IonLabel style={{ color: 'black' }}><b>Quiz {currentPage}</b></IonLabel>
</IonButton>
</IonToolbar>}
<IonSegment color="primary" className={darkMode ? 'dark-segment' : 'light-segment'} value={currentPage} style={{ height: '100px' }}>
<IonSegmentButton value="hiragana" style={{width:'90%', height: '90%' }} onClick={() => setCurrentPage("hiragana")} >
<b> あ</b>
</IonSegmentButton>
<IonSegmentButton value="katakana" style={{ width: '90%', height: '90%' }} onClick={() => setCurrentPage("katakana")}>
<b>ア</b>
</IonSegmentButton>
<IonSegmentButton value="kanji" style={{ width: '90%', height: '90%' }} onClick={() => setCurrentPage("kanji")}>
<b>川</b>
</IonSegmentButton>
<IonSegmentButton value="settings" style={{ width: '90%', height: '90%' }} onClick={() => setCurrentPage("settings")}>
<IonIcon icon={settings}></IonIcon>
</IonSegmentButton>
</IonSegment>
</IonFooter>

)}
{activeQuiz && (
<Quiz
type={currentPage}
data={filterQuizData()}
onClose={endQuiz}
/>
)}
</div></IonApp>

);
}

export default App;

In this implementation, Ionic routing is not used. Additionally, IonTabs cannot be used without Ionic routing (https://github.com/ionic-team/ionic-framework/issues/25184) and as such, the implementation simulates tabs using IonicSegments. Additionally, “react-swipeable” is used to allow the user to swipe between screens without using the tabs menu.

Then, there’s for example the Hiragana screen, using IonContent and IonGrid to allow for a scrollable and responsive interface. To learn more about Ionic components, visit this page: https://ionicframework.com/docs/components. When an item on the grid is clicked on, the ItemScreen.js component (see 3. Native Audio section) is displayed:

import React, { useState } from 'react';
import { IonCol, IonGrid, IonRow, IonContent } from '@ionic/react';
import SectionDivider from './../components/SectionDivider'
import GridItem from '../containers/GridItem';
import ItemScreen from '../components/ItemScreen';
import { useDarkMode } from './DarkModeContext';

const itemsPerRow = 4;

const Hiragana = ({data}) => {

const [selectedCharacter, setSelectedCharacter] = useState(null);
const { darkMode } = useDarkMode();

const handleGridItemClick = (character) => {
setSelectedCharacter(character);
};

const handleCloseItem = () => {
setSelectedCharacter(null);
};

return (
<div>
<h1>Hiragana</h1>
<SectionDivider />
<IonContent>
<IonGrid>
{data &&
data.reduce((rows, item, index) => {
if (index % itemsPerRow === 0) {
rows.push([]);
}
rows[rows.length - 1].push(item);
return rows;
}, []).map((row, rowIndex) => (
<IonRow key={rowIndex}>
{row.map((item, colIndex) => (
<IonCol key={colIndex}>
<GridItem character={item.character} level={item.level} pronunciation={item.pronunciation} onClick={handleGridItemClick} />
</IonCol>
))}
</IonRow>
))
}
</IonGrid>
</IonContent>
{selectedCharacter !== null && < ItemScreen
isOpen={selectedCharacter !== null}
onClose={handleCloseItem}
character={selectedCharacter?.character}
pronunciation={selectedCharacter?.pronunciation}
level={selectedCharacter?.level}
/>}
</div>
);
};

export default Hiragana;

3. Native Audio

For native audio, the “@capacitor-community/native-audio” community plugin can be utilized: https://github.com/capacitor-community/native-audio/tree/master. First, install the plugin:

npm install @capacitor-community/native-audio
npx cap sync

or

yarn add @capacitor-community/native-audio
npx cap sync

Then, make sure the audio files are in their respective directories:

Android: android/app/src/assets/public/assets/sounds

iOS: ios/App/App/public/assets/sounds

Web: assets/sounds

The basic usage flow for the plugin is as follows:

import {NativeAudio} from '@capacitor-community/native-audio'

NativeAudio.preload({
assetId: "id",
assetPath: "id.mp3",
audioChannelNum: 1,
isUrl: false
});


NativeAudio.play({
assetId: 'id',
});

So, there is a need to preload the audio file into memory before it can be played. In the application, playing audio is used in ItemScreen.js file, which provides a more in-depth view of a given character through an IonModal component.

ItemScreen Component
import React, {useEffect} from 'react';
import { IonModal, IonProgressBar, IonIcon, IonButton, IonFooter, IonToolbar, IonLabel } from '@ionic/react';
import { volumeHighOutline } from 'ionicons/icons';
import { NativeAudio } from '@capacitor-community/native-audio'
import { useDarkMode } from '@/containers/DarkModeContext';
import { Capacitor } from "@capacitor/core";

const ItemScreen = ({ character, pronunciation, level, isOpen, onClose }) => {

const { darkMode } = useDarkMode();

useEffect(() => {
const platform = Capacitor.getPlatform();
let ap = `${pronunciation}.mp3`
if (platform !== "web") {
ap = `public/assets/sounds/${pronunciation}.mp3`
}
NativeAudio.preload({
assetId: pronunciation,
assetPath: ap,
audioChannelNum: 1,
isUrl: false
}).catch(error => {
console.error('Error playing audio:', error);
console.log(pronunciation)
});
}, []);

const playAudio = () => {
NativeAudio.play({
assetId: pronunciation,
}).catch(error => {
console.error('Error playing audio:', error);
console.log(pronunciation)
});
}

const unloadAudio = () => {
NativeAudio.unload({
assetId: pronunciation,
});
onClose();
}

return (
<IonModal isOpen={isOpen} onWillDismiss={onClose} >
<div className={darkMode ? 'itemscreen-content dark' : 'itemscreen-content light'}>
<h2>{character}</h2>
<p>Pronunciation: {pronunciation}</p>
<IonButton style={{ width: '100%', marginTop: '50px' }} color="primary" onClick={() => playAudio()}><IonIcon icon={volumeHighOutline}></IonIcon></IonButton>
<IonProgressBar style={{ marginTop: '50px' }} value={level / 20}></IonProgressBar>
<IonFooter style={{ position: 'fixed', bottom: '0', width: '100%', left:'0'}}>
<IonToolbar>
<IonButton onClick={unloadAudio} style={{ width: '100%', background:"white", height:'75px' }} color='light' fill="clear">
<IonLabel style={{ color: 'black' }}><b>Close</b></IonLabel>
</IonButton>
</IonToolbar>
</IonFooter>
</div>
</IonModal>
);
};

export default ItemScreen;

4. Dark Mode

Settings Screen

For implementing light and dark mode, I chose to use DarkModeContext.js provider, as such:

import React, { createContext, useContext, useState } from 'react';

const DarkModeContext = createContext();

export const DarkModeProvider = ({ children }) => {
const [darkMode, setDarkMode] = useState(true);

const toggleDarkMode = () => {
setDarkMode((prevMode) => !prevMode);
};

return (
<DarkModeContext.Provider value={{ darkMode, toggleDarkMode }}>
{children}
</DarkModeContext.Provider>
);
};

export const useDarkMode = () => {
const context = useContext(DarkModeContext);
if (!context) {
throw new Error('useDarkMode must be used within a DarkModeProvider');
}
return context;
};

Which then wraps the App on Main.js:

    return (
<DarkModeProvider>
<div className="App">
{isLoading ? (<Loading />) : (<App katakanaData={katakanaData} hiraganaData={hiraganaData} kanjiData={kanjiData} currentPageInherited={currentPage} reload={reload} restart={restart} />)}
</div>
</DarkModeProvider>
);

This can then be used in multiple ways. For example, manually altering classes or style attributes when the darkMode state is changed in DarkModeProvider.js, or changing the style directly in a component that can access the context. For example, ItemScreen.js:

const ItemScreen = ({ character, pronunciation, level, isOpen, onClose }) => {

const { darkMode } = useDarkMode();

...

}

return (
<IonModal isOpen={isOpen} onWillDismiss={onClose} >
<div className={darkMode ? 'itemscreen-content dark' : 'itemscreen-content light'}>
...
</div>
</IonModal>
);
};

export default ItemScreen;
Light Mode Hiragana Screen

Depending on the styling framework used, implementing dark mode might be simpler. Ionic also lists other options, such as using media queries: https://ionicframework.com/docs/theming/dark-mode.

Dark mode can be toggled in the Settings.js container, which also allows to reset user progression:

import React from 'react';
import { IonContent, IonItem, IonPage, IonToggle, IonList, IonButton, IonAlert } from '@ionic/react';
import { useDarkMode } from './DarkModeContext';
import SectionDivider from './../components/SectionDivider'

const Settings = ({restart}) => {
const { darkMode, toggleDarkMode } = useDarkMode();

return (
<div className={darkMode ? 'mainSection dark' : 'mainSection light'}>

<IonPage>
<h1>Settings</h1>
<SectionDivider />
<IonContent className="ion-padding">
<IonList inset={true}>
<IonItem className="settings-item">
<IonToggle checked={darkMode} onIonChange={toggleDarkMode} justify="space-between" color='primary'>
<h2 >Dark Mode</h2>
</IonToggle>
</IonItem>
</IonList >
<IonList inset={true}>
<IonButton id="present-alert-1" expand="block" fill="outline" color={darkMode ? 'light' : 'dark'}>
<h2>Restart Progression</h2>
</IonButton>
</IonList>
</IonContent>
</IonPage>

<IonAlert cssClass='my-custom-class'
header="Are you sure?"
trigger="present-alert-1"
buttons={[
{
text: 'Cancel',
role: 'cancel',
handler: () => {
return null;
},
},
{
text: 'OK',
role: 'confirm',
handler: () => {
restart()
},
},
]}
onDidDismiss={({ detail }) => console.log(`Dismissed with role: ${detail.role}`)}
></IonAlert>
</div>
);
};

export default Settings;

5. Quiz Section

This section presents a brief overview of the quiz section of the App. In the quiz, there are two main types of questions: Multiple Choice and “Linking” Questions.

Multiple Choice Question:

Multiple Choice Question

“Linking” Question:

Linking Question

Multiple Choice questions can then contain various variations. For example, the goal might be to select the correct pronunciation for a character and vice-versa. Additionally, these questions might also include strings as seen in the image above.

The structure of the quiz component is as follows:

import React, { useState } from 'react';
import MultipleChoiceQ from './MultipleChoiceQ';
import { IonProgressBar, IonFooter, IonHeader, IonTitle, IonToolbar, IonButtons, IonLabel, IonButton, IonAlert } from '@ionic/react';
import LinkingQ from './LinkingQ';
import * as QuizHelpers from './QuizHelpers';
import QuizOver from './QuizOver';

const Quiz = ({ type, data, onClose }) => {
const [currentQuestion, setCurrentQuestion] = useState(0);
const [userResponses, setUserResponses] = useState([]);
const [quizOver, setQuizOver] = useState(false);
const totalQuestions = 5;

const handleAnswer = (answer, isRight) => {
setUserResponses([...userResponses, [answer, isRight]]);
if (currentQuestion < totalQuestions - 1) {
setCurrentQuestion(currentQuestion + 1);
} else {
setQuizOver(true)
}
};

const onClosetest = () => {
setQuizOver(false);
onClose(userResponses);
}

const setupMultipleChoiceQ = () => {
...
}

const setupMultipleChoiceQReverse = () => {
...
}

const setupDoubleCharacterQ = () => {
...
};

const setupLinkingQ = () => {
...
}

const renderQuestion = () => {
if (quizOver) return null;
switch (Math.floor(Math.random() * 4)) {
case 0:
return <MultipleChoiceQ key={currentQuestion} type={type} onAnswer={handleAnswer} data={setupMultipleChoiceQ()} />;
case 1:
return <LinkingQ key={currentQuestion} onAnswer={handleAnswer} data={setupLinkingQ() } />;
case 2:
return <MultipleChoiceQ key={currentQuestion} type={type} onAnswer={handleAnswer} data={setupMultipleChoiceQReverse()} />;
case 3:
if (type !== "kanji") return <MultipleChoiceQ key={currentQuestion} onAnswer={handleAnswer} data={setupDoubleCharacterQ()} />;
return <MultipleChoiceQ key={currentQuestion} type={type} onAnswer={handleAnswer} data={setupMultipleChoiceQ()} />;
default:
return null;
}
};

return (
<div className="Quiz" style={{ height: '100vh' }}>
<IonHeader>
<IonToolbar color="light">
<IonButtons slot="start">
<IonButton id="present-alert" style={{ width: '100%' }} color="light" fill="clear">
<IonLabel style={{ color: 'black' }}><b>Quit</b></IonLabel>
</IonButton>
</IonButtons>
<IonButtons slot="end">
<IonButton>
<IonTitle>{type} Quiz</IonTitle>
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<br></br>
<h2>Question #{currentQuestion + 1}</h2>
{renderQuestion()}
<IonFooter style={{ position: 'fixed', bottom: '0'}}>
<IonToolbar color="light">
<IonProgressBar color="success" value={(currentQuestion + 1) / totalQuestions}></IonProgressBar>
</IonToolbar>
</IonFooter>
<IonAlert cssClass='my-custom-class'
header="Are you sure?"
trigger="present-alert"
buttons={[
{
text: 'Cancel',
role: 'cancel',
handler: () => {
return null;
},
},
{
text: 'OK',
role: 'confirm',
handler: () => {
onClose();
},
},
]}
onDidDismiss={({ detail }) => console.log(`Dismissed with role: ${detail.role}`)}
></IonAlert>
{userResponses.length === 5 && <QuizOver onClose={onClosetest} userResponses={userResponses} totalQuestions={totalQuestions}></QuizOver>}
</div>
);
};

export default Quiz;

The quiz receives the data relative to the unlocked character’s data as props and a type of question is randomly selected for each of the 5 questions of a quiz. There is a dedicated function to setup each type and variation of available question, filtering the data and formatting it to be displayed by the question components. To see these setup functions more in detail, along with the question components, check the repository link in the conclusion of this article. Additionally, there is also a function to handle the user’s answer and potential end of quiz, marking each answer as right or wrong. Finally, the user can choose to quit the quiz through a popup.

A quiz over component receives the user responses to display the overall results before returning to the main menu:

Quiz Over Screen
import React from 'react';
import { IonProgressBar , IonButton } from '@ionic/react';


const QuizOver = ({ userResponses, onClose, totalQuestions }) => {
return (
<div className="fullscreen-modal-overlay">
<div className="fullscreen-modal-content">
<h2>Quiz Over!</h2>
<p>
{`You got ${userResponses.reduce((counter, value) => {
if (value[1]) counter++;
return counter;
}, 0)} out of ${totalQuestions} correct!`}
</p>
<IonProgressBar color="success" value={userResponses.reduce((counter, value) => {
if (value[1]) counter++;
return counter;
}, 0) / totalQuestions}> </IonProgressBar>
<IonButton color='primary' onClick={() => onClose()}>Exit</IonButton>
</div>
</div>
);
};

export default QuizOver;

When the quiz is over, the reload function of Main.jsx is called. This updated the levels for each character present in the quiz, altering the database. Depending on the answers of the quiz, levels will change, and as such, new characters can be unlocked. After updating the characters, the doUnlockCharactersIfNewLevel function is called to check if there are new characters to unlock and, if so, to unlock them permanently:

    /* Checks if the sum of current levels across all characters of a specific type, divided by the number of unlocked characters, is larger than 10.
If so, it unlocks 5 additional characters */
const doUnlockCharactersIfNewLevel = async (table) => {
try {
await performSQLAction(async (db) => {
const unlockedCount = await db?.query(`SELECT count(*) AS count FROM ${table} WHERE level > 0`);
const levelCount = await db?.query(`SELECT sum(level) AS count FROM ${table} WHERE level > 0`);
if ((levelCount.values[0].count / unlockedCount.values[0].count) > 10) {
const newChars = await db?.query(`SELECT character FROM ${table} WHERE level = 0`);
const toUnlock = newChars.values.slice(0, 5)
for (let i = 0; i < toUnlock.length; i++) {
await db?.query(`UPDATE ${table} SET level = 1 WHERE character = '${toUnlock[i].character}' ;`);
}
}
});
} catch (error) {
console.log((error).message);
}
};

const reload = async (userResponses, currentPage) => {
setIsLoading(true);
try {
await performSQLAction(async (db) => {
for (let i = 0; i < userResponses.length; i++) {
if (userResponses[i][1] === undefined) continue;
let count = ["+1",0,20]
if (!userResponses[i][1]) count = ["-1",1,21]
const table = `charProgression${currentPage.charAt(0).toUpperCase() + currentPage.slice(1)}`;
const addLevel = count[0];
const character = userResponses[i][0];
const pronunciation = userResponses[i][0];
const minLevel = count[1];
const maxLevel = count[2];
await db?.query(`UPDATE ${table}
SET level = level ${addLevel}
WHERE (character = '${character}' OR pronunciation = '${pronunciation}')
AND ${minLevel} < level
AND level < ${maxLevel};`);
}
});
} catch (error) {
console.log((error).message);
}
await doUnlockCharactersIfNewLevel(`charProgression${currentPage.charAt(0).toUpperCase() + currentPage.slice(1)}`);
await loadData();
setCurrentPage(currentPage);
setIsLoading(false)
};

Conclusion

Photo by Shuken Nakamura on Unsplash

In this blogpost, some of the key challenges in developing a Japanese character learning app using ReactJS, Capacitor, and Ionic are addressed, and an overview of the main logic and structure behind a possible implementation is presented. Hopefully, apps like these can improve the Japanese learning experience! Capacitor is a very useful tool for web and mobile development, and very much worth exploring in-depth.

Thank you for reading :) ! Hope some information can be useful! To access the full implementation, visit this repository: https://github.com/AlvaroAsial/JapaneseLearning

読んでくれてありがとうございます!!!

--

--