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.
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.
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.
The Study Tab provides a focused and interactive environment to enhance language skills, blending structured learning with engaging activities for effective skill development.
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.
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),
),
],
),
),
),
],
),
);
},
),
);
},
);
}
}
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.
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),
),
);
}),
);
}
}
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.
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();
}
}
The Games Tab offers an enjoyable and interactive way to apply language skills in a fun, engaging format, combining play with learning.
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.
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;
}
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();
});
}
}
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.
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();
}
void _selectedAnswer(int index, bool isCorrect) {
setState(() {
if (_selectedAnswerIndex == index) {
_selectedAnswerIndex = null;
_showNextButton = false;
} else {
_selectedAnswerIndex = index;
_showNextButton = true;
if (isCorrect) {
_correctAnswers++;
}
}
});
}
void _nextQuestion(List<dynamic> questions) {
setState(() {
if (_currentIndex < questions.length - 1) {
_currentIndex++;
_selectedAnswerIndex = null;
_showNextButton = false;
} else {
_quizFinished = true;
}
});
}
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);
});
}
@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),
),
],
),
),
);
},
);
},
);
}
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.
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;
}
}
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();
});
}
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 = "";
});
}
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;
}
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,
),
],
),
),
),
],
),
],
),
],
),
);
}
}
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.
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.
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.