Mobile App Development Showcase
PANYAVON

This project showcases a portfolio piece for the nearly complete Khmer/English language learning app. All digital paintings, animations, typography, and user experience designs were created by me, Berta Kang. The app is being developed using Flutter, Isar for local storage, and LottieFiles for lightweight animations, combining these technologies to deliver an engaging and efficient learning experience.

Project Brief

The objective of this app is to provide a user-friendly language learning platform for English-speaking Cambodian (Khmer) and Cambodian-speaking English language learners. The app provides a simple and efficient way for users to learn and practice both languages, featuring interactive lessons, quizzes, and vocabulary-building tools. Thoroughly researched during my time living in Cambodia, it is specifically designed to address the challenges of offering language literacy to a targeted Cambodian audience with limited formal education. As a result, the app will be free to use, fully functional offline, and include a wealth of audio content to enhance the learning experience for users of all levels.

Engineering Solutions

  • Cross-Platform Front-End App Development

    Built the app using Flutter for its cross-platform capabilities, ensuring compatibility with both iOS and Android.
  • Offline Local Database Back-End Development

    Implemented Isar, a high-performance local database, for efficient and offline-friendly data storage.
  • LottieFile Integration

    CreatedLottieFiles to deliver lightweight, high-quality animations that enhance the user experience without significant performance overhead.
  • Study Materials

    Designed and implemented interactive phonics exercises, interactive vocabulary practice flashcards, and guided grammar lessons to support effective language learning.
  • Interactive Game Development

    Designed and implemented a typing game, quiz game, and flashcard speaking game with interactive functionality and real-time feedback.
  • User Scoring and Progress Tracking System

    Developed a scoring and ranking system based on user performance, incorporating competitive tiers (e.g., Native Speaker, Advanced, Beginner).
  • In-App Marketplace and Item Crafting

    Implemented a fun in-game market that allows users to purchase and craft customizable items to enhance their Cambodian cultural learning experience and personalize their journey.
  • User Configuration

    Developed flexible user configuration settings, enabling personalized experiences such as adjusting app skin preferences, app reset, and tailoring features to individual learning goals.
  • Tech Stack

  • Flutter

    Built the app using Flutter for its cross-platform capabilities, ensuring compatibility with both iOS and Android.
  • Isar

    Implemented Isar, a high-performance local database, for efficient and offline-friendly data storage.
  • LottieFiles

    Created LottieFiles to deliver lightweight, high-quality animations that enhance the user experience without significant performance overhead.
  • App Architecture

    The objective of this app is to provide a user-friendly language learning platform for English-speaking Cambodian (Khmer) and Cambodian-speaking English language learners. The app provides a simple and efficient way for users to learn and practice both languages, featuring interactive lessons, quizzes, and vocabulary-building tools. Thoroughly researched during my time living in Cambodia, it is specifically designed to address the challenges of offering language literacy to a targeted Cambodian audience with limited formal education. As a result, the app will be free to use, fully functional offline, and include a wealth of audio content to enhance the learning experience for users of all levels.

    PANYAVON
    Study Tab

    The Study Tab provides a focused and interactive environment to enhance language skills, blending structured learning with engaging activities for effective skill development.

    Phonics

    Users can master the sounds of Khmer and English through interactive exercises focused on the alphabet, consonant clusters, and other phonetic patterns (such as digraphs like "–ight" or "fix" and "sup"). This section emphasizes pronunciation by providing guided practice with video content from native Khmer and English speakers articulating phonemes, offering continuous reference and practice opportunities.

    CODE SAMPLES

    Custom Alphabet Block UI Components

    
    enum AlphabetLanguage { eng, khm }
    
    class AlphabetBlock extends StatelessWidget {
      final AlphabetLanguage language;
      final String letter;
      final String? letterFoot;
    
      const AlphabetBlock({
        super.key,
        required this.language,
        required this.letter,
        this.letterFoot,
      });
    
      @override
      Widget build(BuildContext context) {
        return LayoutBuilder(
          builder: (BuildContext context, BoxConstraints constraints) {
            double alphabetBlockWidth = constraints.maxWidth * 0.9;
            double alphabetBlockHeight = constraints.maxHeight * 0.85;
            AlignmentGeometry alignment = const AlignmentDirectional(-1.0, -1.0);
    
            return Container(
              margin: const EdgeInsets.symmetric(vertical: 8.0),
              width: double.infinity,
              child: StatefulBuilder(
                builder: (BuildContext context, StateSetter setState) {
                  void handlePress() {
                    setState(() {
                      if (alignment == const AlignmentDirectional(-1.0, -1.0)) {
                        alignment = const AlignmentDirectional(1.0, 1.0);
                      } else {
                        alignment = const AlignmentDirectional(-1.0, -1.0);
                      }
                    });
                  }
    
                  return InkWell(
                    onTap: handlePress,
                    child: Stack(
                      children: [
                        Align(
                          alignment: const AlignmentDirectional(1.0, 1.0),
                          child: Ink(
                            height: alphabetBlockHeight,
                            width: alphabetBlockWidth,
                            decoration: BoxDecoration(
                              color: const Color(0xFF899EA3),
                              borderRadius: BorderRadius.circular(16),
                            ),
                          ),
                        ),
                        Align(
                          alignment: alignment,
                          child: Ink(
                            height: alphabetBlockHeight,
                            width: alphabetBlockWidth,
                            decoration: BoxDecoration(
                              color: const Color(0xFFDBF3F9),
                              borderRadius: BorderRadius.circular(16),
                              border: Border.all(
                                  color: Color(0xFF899EA3), width: 4),
                            ),
                            child: Column(
                              mainAxisAlignment: MainAxisAlignment.center,
                              crossAxisAlignment: CrossAxisAlignment.center,
                              children: <Widget>[
                                Text(
                                  letter,
                                  style: const TextStyle(
                                    color: Color.fromARGB(255, 83, 83, 81),
                                    fontSize: 36,
                                    fontFamily: 'EB Garamond',
                                    fontWeight: FontWeight.bold,
                                  ),
                                ),
                                Icon(
                                  Icons.volume_up,
                                  size: 30,
                                  color: Color.fromARGB(255, 83, 83, 81),
                                ),
                              ],
                            ),
                          ),
                        ),
                      ],
                    ),
                  );
                },
              ),
            );
          },
        );
      }
    }
    

    Vocabulary Practice

    Enhance vocabulary with themed lessons that introduce common words and phrases. Users can practice both recognition and usage through an intuitive flashcard system, where they swipe left for challenging words and right for easier ones. Each card includes an image, synonyms, an example sentence, the relevant part of speech, and a QR code for additional information.

    CODE SAMPLES

    Flashcard Flip Animation

    
    class Flashcard extends StatefulWidget {
      final String english_term;
      final String khmerization;
      final List<dynamic> english_synonyms;
      final String english_example;
      final String khmer_term;
      final String romanization;
      final List<dynamic> khmer_synonyms;
      final String khmer_example;
      final bool isFlipped;
    
      const Flashcard({
        super.key,
        required this.english_term,
        required this.khmerization,
        required this.english_synonyms,
        required this.english_example,
        required this.khmer_term,
        required this.romanization,
        required this.khmer_synonyms,
        required this.khmer_example,
        required this.isFlipped,
      });
    
      @override
      _FlashcardState createState() => _FlashcardState();
    }
    
    class _FlashcardState extends State<Flashcard> with SingleTickerProviderStateMixin {
      late AnimationController _controller;
      late Animation<double> _animation;
      bool isFlipped = false;
    
      @override
      void initState() {
        super.initState();
        _controller = AnimationController(duration: const Duration(milliseconds: 400), vsync: this);
        _animation = Tween<double>(begin: 0.0, end: 1.0).animate(_controller);
      }
    
      void _flipCard() {
        if (isFlipped) {
          _controller.reverse();
        } else {
          _controller.forward();
        }
    
        setState(() {
          isFlipped = !isFlipped;
        });
      }
    
      @override
      void dispose() {
        _controller.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onTap: _flipCard,
          child: AnimatedBuilder(
              animation: _animation,
              builder: (context, child) {
                final angle = _animation.value * 3.1416;
                final isFrontVisible = angle <= 3.1416 / 2 || angle >= 3.1416 * 1.5;
    
                return Transform(
                  alignment: Alignment.center,
                  transform: Matrix4.rotationY(angle),
                  child: isFrontVisible
                      ? _buildCard(widget.english_term, widget.khmerization,
                          widget.english_synonyms, widget.english_example, true)
                      : Transform(
                          alignment: Alignment.center,
                          transform: Matrix4.rotationY(3.1416),
                          child: _buildCard(widget.khmer_term, widget.romanization,
                              widget.khmer_synonyms, widget.khmer_example, false),
                        ),
                );
              }),
        );
      }
    }
    

    Grammar Lessons

    Users can dive into the grammar rules of both languages, exploring sentence structure, verb conjugation, and more. Reinforce conceptual understanding through interactive games, flashcards with speech-to-text functionality, quizzes, and typing practice. Each game features vibrant, engaging visuals that enhance the learning experience.

    Code Samples

    Scroll Controller

    
    class _LessonsTemplatePageState extends State<LessonsTemplatePage> {
      int currentPageIndex = 0;
      final ScrollController _scrollController = ScrollController();
      bool isBottomNavVisible = false;
    
      late Map<String, dynamic> _lessonData = {};
    
      @override
      void initState() {
        super.initState();
        _loadLessonData();
        _scrollController.addListener(() {
          if (_scrollController.position.pixels >=
              _scrollController.position.maxScrollExtent) {
            setState(() {
              isBottomNavVisible = true;
            });
          } else {
            setState(() {
              isBottomNavVisible = false;
            });
          }
        });
      }
    
      Future<void> _loadLessonData() async {
        String lessonJSON =
            await rootBundle.loadString('data/grammar/${widget.lessonFile}');
        setState(() {
          _lessonData = json.decode(lessonJSON);
        });
      }
    
      @override
      void dispose() {
        _scrollController.dispose();
        super.dispose();
      }
    }
    
    PANYAVON
    Games Tab

    The Games Tab offers an enjoyable and interactive way to apply language skills in a fun, engaging format, combining play with learning.

    OM TOUK TYPING RACE: Typing Practice Game

    A typing challenge designed to improve spelling, speed, and accuracy. This game reinforces vocabulary and sentence structure, with visual feedback and progressively harder levels as the user advances. The game concept is inspired by Bon Om Touk, a traditional Cambodian water festival, capturing the spirit of friendly competition and skill development.

    Coming Soon

    CODE SAMPLES

    Fetching and Displaying Typed Words

    
    Future<List<String>> _fetchWords() async {
      final userRank = widget.userRank;
      final wordsData =
          await rootBundle.loadString('/path/to/your/typing/$userRank/general1.json');
      final wordsJSON = json.decode(wordsData)['words'] as List<dynamic>;
      final words = wordsJSON.map((word) => word as String).toList();
      final randomIndex = Random().nextInt(words.length);
      return words;
    }
    

    Timer Implementation

    
    void _startTimer() {
      _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
        setState(() {
          _timeLeft--;
        });
    
        if (_timeLeft == 0) {
          _timer?>cancel();
          _showGameOverDialog();
        }
      });
    }
    
    void _startBoatPositionTimer() {
      if (_boatPositionTimer == null || !_boatPositionTimer!<isActive) {
        _boatPositionTimer = Timer.periodic(const Duration(seconds: 4), (_) {
          _updateBoatPositions();
        });
      }
    }
    

    SBEK THOM SCHOLAR: Interactive Quiz Game

    This quiz game tests users' knowledge of vocabulary, grammar, and reading comprehension. The concept draws inspiration from Sbek Por, traditional Cambodian shadow puppetry, transforming the learning process into a puzzle-like challenge that engages users through storytelling and problem-solving.

    CODE SAMPLES

    Fetching Quiz Data from JSONs

    
    Future<List<Map<String, dynamic>>> _fetchQuiz() async {
      final user = (await widget.isar.users.where().findAll()).first;
      final userRank = user.rank;
    
      final quizType = '${widget.quizType.toLowerCase()}.json';
      final quizData =
          await rootBundle.loadString('data/quizzes/beginner/$quizType');
      final quizzes = json.decode(quizData)['quizzes'] as List<dynamic>;
    
      final randomIndex = Random().nextInt(quizzes.length);
      final selectedQuestions =
          quizzes[randomIndex]['questions'] as List<dynamic>;
    
      _totalQuestions = selectedQuestions.length;
    
      return selectedQuestions.map((item) {
        final questionMap = item as Map<String, dynamic>;
        final answersList = (questionMap['answers'] as List<dynamic>)
            .map((answerItem) => answerItem as Map<String, dynamic>)
            .toList();
    
        return {
          'question': questionMap['question'],
          'answers': answersList,
        };
      }).toList();
    }
    

    User Answer Selection Handling

    
    void _selectedAnswer(int index, bool isCorrect) {
      setState(() {
        if (_selectedAnswerIndex == index) {
          _selectedAnswerIndex = null;
          _showNextButton = false;
        } else {
          _selectedAnswerIndex = index;
          _showNextButton = true;
    
          if (isCorrect) {
            _correctAnswers++;
          }
        }
      });
    }
    

    Moving to the Next Question

    
    void _nextQuestion(List<dynamic> questions) {
      setState(() {
        if (_currentIndex < questions.length - 1) {
          _currentIndex++;
          _selectedAnswerIndex = null;
          _showNextButton = false;
        } else {
          _quizFinished = true;
        }
      });
    }
    

    Applying and Saving the Quiz Score

    
    Future<void> _applyScore(double grammarScore, double grammarSpeed,
        double grammarDifficulty) async {
      final quizSessions =
          await widget.isar.grammarGameSessions.where().findAll();
      final quizSession =
          quizSessions.isEmpty ? GrammarGameSession() : quizSessions.first;
    
      quizSession.grammarScore = grammarScore;
      quizSession.addScore(grammarScore);
    
      await widget.isar.writeTxn(() async {
        await widget.isar.grammarGameSessions.put(quizSession);
      });
    }
    

    UI Building

    
    @override
    Widget build(BuildContext context) {
      return Consumer<GrammarGameSession>(
        builder: (context, grammarGameSession, child) {
          return FutureBuilder<List<Map<String, dynamic>>>(
            future: _quizFuture,
            builder: (context, snapshot) {
              if (snapshot.connectionState == ConnectionState.waiting) {
                return const Center(child: CircularProgressIndicator());
              } else if (snapshot.hasError) {
                return Center(child: Text('Error: ${snapshot.error}'));
              } else if (!snapshot.hasData || snapshot.data!.isEmpty) {
                return const Center(child: Text('No quizzes available.'));
              }
    
              final questions = snapshot.data!;
    
              if (_quizFinished) {
                final double finalScore = (_totalQuestions > 0)
                    ? (_correctAnswers / _totalQuestions) * 100
                    : 0;
    
                const finalSpeed = 5.0;
                const grammarDifficulty = 5.0;
    
                _applyScore(finalScore, finalSpeed, grammarDifficulty);
                return Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      const Text(
                        'Quiz Finished!',
                        style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
                      ),
                      Text(
                        'Your score: ${finalScore.toStringAsFixed(2)}%',
                        style: const TextStyle(fontSize: 20),
                      ),
                      Text(
                        'Your speed: ${finalSpeed.toStringAsFixed(2)}%',
                        style: const TextStyle(fontSize: 20),
                      ),
                      Text(
                        'Your rank: ${grammarDifficulty.toStringAsFixed(2)}%',
                        style: const TextStyle(fontSize: 20),
                      ),
                      const SizedBox(height: 20),
                      Button(
                        buttonColor: ButtonColor.red,
                        buttonType: ButtonType.largeAction,
                        buttonText: "Back to Game Menu",
                        buttonTextSize: 20.0,
                        buttonMode: ButtonMode.light,
                        onTap: () {
                          Navigator.of(context).pop();
                        },
                      ),
                    ],
                  ),
                );
              }
    
              final question = questions[_currentIndex]['question'].toString();
              final answers = questions[_currentIndex]['answers']
                  as List<Map<String, dynamic>>;
    
              return Expanded(
                child: SingleChildScrollView(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Padding(
                        padding: const EdgeInsets.only(left: 16.0),
                        child: Text(
                          'Question 1',
                          style: TextStyle(
                              fontFamily: 'League Spartan',
                              fontSize: 18.0,
                              fontWeight: FontWeight.bold),
                        ),
                      ),
                      Container(
                        padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 16.0),
                        child: Text(
                          question,
                          style: const TextStyle(fontSize: 20.0),
                        ),
                      ),
                      Column(
                        children: answers.asMap().entries.map((entry) {
                          int index = entry.key;
                          Map<String, dynamic> answer = entry.value;
                          bool isCorrect = answer['isCorrect'] == true;
                          final answerText = answer['answer'].toString();
    
                          return Button(
                            buttonColor: ButtonColor.white,
                            buttonType: ButtonType.quizAnswer,
                            buttonText: answerText,
                            buttonMode: ButtonMode.dark,
                            buttonTextSize: 18.0,
                            onTap: () {
                              _selectedAnswer(index, isCorrect);
                            },
                          );
                        }).toList(),
                      ),
                      if (_showNextButton)
                        Button(
                          buttonColor: ButtonColor.blue,
                          buttonType: ButtonType.largeAction,
                          buttonText: "Next Question",
                          buttonTextSize: 20.0,
                          buttonMode: ButtonMode.light,
                          onTap: () => _nextQuestion(questions),
                        ),
                    ],
                  ),
                ),
              );
            },
          );
        },
      );
    }
    

    FLASHCARD FIGHT: Vocabulary Speaking Practice

    Users practice their listening and speaking skills by matching flashcards with correct pronunciation. The game uses interactive voice recognition with speech-to-text technology and provides clear visual cues to enhance learning. The concept is based on Kun Khmer, traditional Cambodian kickboxing, where precision and rhythm are key, paralleling the learning of language nuances.

    CODE SAMPLES

    State Management

    
      
    class _GamesFlashcardsPageState extends State<GamesFlashcardsPage> {
      List<dynamic> _flashcards = [];
      String _flashcardCategory = '&';
      String _currentFlashcardTermEng = "";
      String _currentFlashcardKhmerization = "";
      String _currentFlashcardTermKhm = "";
      String _currentFlashcardRomanization = "";
    
      int _currentIndex = 0;
      bool _isFlipped = false;
      int _gamePoints = 0;
      double _playerHealth = 0.0;
    
      final SpeechToText _speechToText = SpeechToText();
      bool _speechEnabled = false;
      final bool _onDevice = true;
      String lastWords = "";
      double _confidenceValue = 0.0;
    
      @override
      void initState() {
        super.initState();
        _initSpeech();
        _getFlashcardCategories().then((_) {
          _getFlashcards();
        });
      }
    
      @override
      void didChangeDependencies() {
        super.didChangeDependencies();
        _playerHealth = (MediaQuery.of(context).size.width - 32) / 2.5;
      }
    }
    
    
    

    Speech-To-Text Integration

    
    void _initSpeech() async {
      bool speechEnabled = await _speechToText.initialize();
      setState(() {
        _speechEnabled = speechEnabled;
      });
    }
    
    void _startListening() async {
      final options = SpeechListenOptions(
          onDevice: _onDevice,
          listenMode: ListenMode.dictation,
          partialResults: true,
          autoPunctuation: true,
          enableHapticFeedback: true);
    
      if (_speechEnabled) {
        await _speechToText.listen(
          onResult: resultListener,
          listenFor: const Duration(seconds: 3),
          pauseFor: const Duration(seconds: 3),
          listenOptions: options,
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Speech recognition not initialized')),
        );
      }
      setState(() {});
    }
    
    void resultListener(SpeechRecognitionResult result) {
      setState(() {
        final words = result.recognizedWords.split(' ');
        lastWords = '${words.last} - ${result.finalResult}';
        print(
            "recognizedWords: ${result.recognizedWords} finalResult: ${result.finalResult}");
        _confidenceValue = result.confidence;
        _checkAnswer();
      });
    }
    

    Game Logic

    
    void _checkAnswer() {
      if (lastWords.trim().toLowerCase() ==
          "${_currentFlashcardTermEng.trim().toLowerCase()} - false") {
        _gamePoints++;
        _playerHealth =
            (_playerHealth - _gamePoints * 10).clamp(0.0, _playerHealth);
        _nextFlashcard();
      } else {
        _startListening();
      }
    }
    
    void _nextFlashcard() {
      setState(() {
        _currentIndex = (_currentIndex + 1) % _flashcards.length;
        print(_currentIndex);
        _currentFlashcardTermEng = _flashcards[_currentIndex]['english_word'];
        _currentFlashcardTermKhm = _flashcards[_currentIndex]['khmer_word'];
        _isFlipped = false;
        lastWords = "";
      });
    }
    

    Data Management

    
    Future<String> _getFlashcardCategories() async {
      final String categoriesResponse = await rootBundle
          .loadString('data/vocabulary/vocabulary_categories.json');
      final Map<String, dynamic> categoriesData = json.decode(categoriesResponse);
      final List<dynamic> vocabCategories =
          categoriesData["vocabulary_categories"];
      final randomIndex = Random().nextInt(vocabCategories.length);
      final selectedCategory = vocabCategories[randomIndex].toString();
    
      setState(() {
        _flashcardCategory = selectedCategory;
      });
    
      return _flashcardCategory;
    }
    
    Future<List<dynamic>> _getFlashcards() async {
      final String flashcardsResponse =
          await rootBundle.loadString('data/vocabulary/$_flashcardCategory.json');
      final List<dynamic> flashcards = json.decode(flashcardsResponse);
    
      setState(() {
        _flashcards = flashcards;
        if (_flashcards.isNotEmpty) {
          _currentFlashcardTermEng = _flashcards[_currentIndex]['english_word'];
          _currentFlashcardKhmerization =
              _flashcards[_currentIndex]['khmerization'];
          _currentFlashcardTermKhm = _flashcards[_currentIndex]['khmer_word'];
          _currentFlashcardRomanization =
              _flashcards[_currentIndex]['romanization'];
        }
      });
    
      return flashcards;
    }
    

    Healthbar Implementation

    
    class HealthBar extends StatelessWidget {
      final double playerHealth;
      final double opponentHealth;
      final int gamePoints;
    
      const HealthBar({
        Key? key,
        required this.playerHealth,
        required this.opponentHealth,
        required this.gamePoints,
      }) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return SizedBox(
          width: double.infinity,
          child: Row(
            mainAxisAlignment:
                MainAxisAlignment.spaceBetween, 
            children: [
              Row(
                children: [
                  Container(
                    height: 50.0,
                    width: 36.0,
                    margin: EdgeInsets.only(right: 2.0),
                    decoration: BoxDecoration(
                      border: Border.all(
                        width: 2.0,
                        color: Colors.white,
                      ),
                    ),
                  ),
                  Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Container(
                        decoration: BoxDecoration(
                          boxShadow: [
                            BoxShadow(
                              color: Colors.black
                                  .withOpacity(0.5),
                              blurRadius: 1.0,
                              offset: Offset(2, 2),
                            ),
                          ],
                          border: Border.all(
                            width: 2.0,
                            color: Color(0xFF15e1cc),
                          ),
                        ),
                        child: Row(
                          children: List.generate(5, (index) {
                            return Container(
                              height: 14.0,
                              width: 22.0,
                              margin: EdgeInsets.symmetric(
                                  vertical: 1.0, horizontal: 1.0),
                              color: Color(0xFF15e1cc),
                            );
                          }),
                        ),
                      ),
                      SizedBox(height: 4.0),
                      RichText(
                        text: TextSpan(
                          text: 'PLAYER',
                          style: const TextStyle(
                            fontFamily: 'Leaguespartan',
                            fontSize: 20,
                            fontWeight: FontWeight.bold,
                            fontStyle: FontStyle.italic,
                            color: Colors.white,
                            shadows: [
                              Shadow(
                                offset: Offset(2.0, 2.0),
                                color: Colors.black,
                                blurRadius: 0,
                              ),
                            ],
                          ),
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ],
          ),
        );
      }
    }
    
    PANYAVON
    Progress Tab

    The Progress Page allows users to track their learning journey and monitor their continuous improvement over time. Users can explore their progress through a detailed breakdown of skill levels, ranging from Beginner to Native, across key areas such as speaking, typing, vocabulary, listening, and grammar. Each skill section provides insights into individual progress, highlighting strengths and areas for growth. Visual graphs and progress bars offer a clear view of milestones, helping users stay motivated and focused on their language-learning goals.

    PANYAVON
    Market Tab

    The Collection Market allows users to purchase virtual items that enrich their cultural learning experience, bringing the history and traditions of Cambodia to life. Users can unlock cultural artifacts using in-app points earned by completing lessons. This page is designed with vibrant, child-friendly visuals that also carry deep cultural significance, offering a meaningful way to connect with Cambodia's rich history and heritage.

    PANYAVON
    User Tab

    The User Page allows learners to manage their settings and personalize their app experience to suit their preferences. In this section, users can adjust language preferences, manage notifications, and update their account information. Additionally, users have the option to change the app's appearance, allowing them to choose from different visual themes to further personalize their experience.

    Coming Soon

    See below to get into contact.

    Talk Work With Me

    Peep My Socials

    The Berta Kang Portfolio does not collect visitor data from our site. We monitor the number of site views and other non-visitor specific data concerning the performance of the site in general. Regarding information that is provided to The Berta Kang Portfolio, we do not sell, give, or trade any information to any third parties for data mining, marketing, or any other purpose. Your privacy is of the utmost concern to The Berta Kang Portfolio. The information on our website, and any information we solicit from you, or that you provide to us in response to what you may read on our website, is not intended as an offer to sell or the solicitation of an offer to buy any goods or services. It is for informational purposes only.

    Site designed by Berta Kang