Building Language Learning Apps with Flutter and spaced repetition

In today’s interconnected world, language learning has become more accessible than ever, thanks to innovative mobile applications. Flutter, Google’s UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase, offers a compelling platform for creating engaging and effective language learning apps. One particularly powerful technique for optimizing learning is spaced repetition. This blog post explores how to build language learning apps with Flutter using spaced repetition.

What is Spaced Repetition?

Spaced repetition is a learning technique that involves reviewing information at increasing intervals. The goal is to improve long-term retention by reinforcing memory just before it’s likely to be forgotten. This approach contrasts with cramming, where material is reviewed intensively over a short period.

Why Use Flutter for Language Learning Apps?

  • Cross-Platform Development: Flutter allows you to write code once and deploy it on both iOS and Android platforms, reducing development time and costs.
  • Rich UI and Animations: Flutter’s expressive UI and animation capabilities make it possible to create engaging and visually appealing learning experiences.
  • Performance: Flutter apps are compiled to native code, ensuring optimal performance and smooth interactions.
  • Large Community and Ecosystem: Flutter has a vibrant and supportive community, with a wealth of packages and resources available.

Building Blocks of a Flutter Language Learning App

Before diving into code, let’s outline the key components of a language learning app built with spaced repetition:

  • Word/Phrase Database: A structured database to store words, phrases, translations, example sentences, and difficulty levels.
  • Spaced Repetition Algorithm: The core logic to schedule reviews based on performance.
  • User Interface: Screens for learning, reviewing, and tracking progress.
  • Persistence: Mechanisms to store user data, progress, and learning preferences.

Implementing Spaced Repetition in Flutter

Step 1: Setting up the Project

First, create a new Flutter project:

flutter create language_learning_app
cd language_learning_app

Step 2: Adding Dependencies

Add necessary dependencies to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  sqflite: ^2.2.8+2 # For local database
  path_provider: ^2.0.15 # For accessing device storage
  intl: ^0.17.0 # For date formatting
  percent_indicator: ^4.2.3 # For visual progress indicators

dev_dependencies:
  flutter_test:
    sdk: flutter

After updating the pubspec.yaml file, run:

flutter pub get

Step 3: Defining Data Models

Create a Word class to represent a learning item:

class Word {
  int? id;
  String text;
  String translation;
  String exampleSentence;
  int difficulty; // Difficulty level (e.g., 1-5)
  DateTime nextReview;
  int correctStreak;

  Word({
    this.id,
    required this.text,
    required this.translation,
    required this.exampleSentence,
    this.difficulty = 1,
    required this.nextReview,
    this.correctStreak = 0,
  });

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'text': text,
      'translation': translation,
      'exampleSentence': exampleSentence,
      'difficulty': difficulty,
      'nextReview': nextReview.toIso8601String(),
      'correctStreak': correctStreak,
    };
  }

  factory Word.fromMap(Map<String, dynamic> map) {
    return Word(
      id: map['id'],
      text: map['text'],
      translation: map['translation'],
      exampleSentence: map['exampleSentence'],
      difficulty: map['difficulty'],
      nextReview: DateTime.parse(map['nextReview']),
      correctStreak: map['correctStreak'],
    );
  }
}

Step 4: Implementing Database Logic

Set up the SQLite database to store words and review schedules. Create a DatabaseHelper class:

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'word.dart'; // Ensure this import matches your project structure

class DatabaseHelper {
  static const _dbName = 'language_learning.db';
  static const _dbVersion = 1;
  static const _tableName = 'words';

  DatabaseHelper._privateConstructor();
  static final DatabaseHelper instance = DatabaseHelper._privateConstructor();

  static Database? _database;

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initiateDatabase();
    return _database!;
  }

  Future<Database> _initiateDatabase() async {
    final documentsDirectory = await getApplicationDocumentsDirectory();
    final path = join(documentsDirectory.path, _dbName);
    return await openDatabase(
      path,
      version: _dbVersion,
      onCreate: _onCreate,
    );
  }

  Future<void> _onCreate(Database db, int version) async {
    await db.execute('''
      CREATE TABLE $_tableName (
        id INTEGER PRIMARY KEY,
        text TEXT NOT NULL,
        translation TEXT NOT NULL,
        exampleSentence TEXT NOT NULL,
        difficulty INTEGER NOT NULL,
        nextReview TEXT NOT NULL,
        correctStreak INTEGER NOT NULL
      )
    ''');
  }

  Future<int> insertWord(Word word) async {
    final db = await database;
    return await db.insert(_tableName, word.toMap());
  }

  Future<List<Word>> getWordsForReview() async {
    final db = await database;
    final now = DateTime.now().toIso8601String();
    final List<Map<String, dynamic>> maps = await db.query(
      _tableName,
      where: 'nextReview <= ?',
      whereArgs: [now],
    );
    return List.generate(maps.length, (i) {
      return Word.fromMap(maps[i]);
    });
  }

  Future<int> updateWord(Word word) async {
    final db = await database;
    return await db.update(
      _tableName,
      word.toMap(),
      where: 'id = ?',
      whereArgs: [word.id],
    );
  }

  Future<List<Word>> getAllWords() async {
    final db = await database;
    final List<Map<String, dynamic>> maps = await db.query(_tableName);
    return List.generate(maps.length, (i) {
      return Word.fromMap(maps[i]);
    });
  }

  Future<void> deleteWord(int id) async {
      final db = await database;
      await db.delete(
          _tableName,
          where: "id = ?",
          whereArgs: [id],
      );
  }

}

Step 5: Implementing the Spaced Repetition Algorithm

Implement a function to adjust the review interval based on the user’s performance:

class SpacedRepetition {
  static DateTime getNextReview(Word word, bool correct) {
    int interval;
    if (correct) {
      // Increase interval based on difficulty and correct streak
      word.correctStreak = (word.correctStreak + 1).clamp(0, 5); // Clamp to prevent runaway intervals
      interval = _calculateInterval(word.difficulty, word.correctStreak);
    } else {
      // Reset correct streak, reduce interval slightly to reinforce learning
      word.correctStreak = 0;
      interval = _calculateInterval(word.difficulty, word.correctStreak);
    }
    
    return DateTime.now().add(Duration(days: interval));
  }

  static int _calculateInterval(int difficulty, int correctStreak) {
    // Adjust the base intervals and scaling factors as needed
    // This is where you'd tune your SR algorithm.
    double baseInterval;
    double streakFactor;

    switch (difficulty) {
      case 1: // Easiest
        baseInterval = 1.0;
        break;
      case 2:
        baseInterval = 1.5;
        break;
      case 3:
        baseInterval = 2.0;
        break;
      case 4:
        baseInterval = 2.5;
        break;
      case 5: // Hardest
        baseInterval = 3.0;
        break;
      default:
        baseInterval = 1.0; // Default to easiest if difficulty is unexpected
    }

    streakFactor = 1 + (correctStreak * 0.3); // Increased factor for higher streaks

    return (baseInterval * streakFactor).round();
  }

  static double calculateProgressPercentage(Word word) {
    //Higher difficulty, slower increase to 1.
    final maxCorrectStreak = 5;
    final progressPerCorrect = 1.0 / (maxCorrectStreak * word.difficulty); // Adjust difficulty factor as needed

    return (word.correctStreak * progressPerCorrect).clamp(0.0, 1.0); // Ensure between 0 and 1
  }

}

Step 6: Building the UI

Create the necessary screens for your app, including:

  • Learning Screen: Displays new words and phrases.
  • Review Screen: Presents words due for review based on the spaced repetition algorithm.
  • Progress Screen: Shows the user’s learning progress.

Here is a snippet of the review screen:

import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // Import DateFormat
import 'database_helper.dart'; // Import your DatabaseHelper
import 'word.dart'; // Import your Word model
import 'spaced_repetition.dart'; // Spaced Repetition Algorithm
import 'package:percent_indicator/percent_indicator.dart';

class ReviewScreen extends StatefulWidget {
  const ReviewScreen({Key? key}) : super(key: key);

  @override
  ReviewScreenState createState() => ReviewScreenState();
}

class ReviewScreenState extends State<ReviewScreen> {
  List<Word> wordsForReview = [];
  int currentIndex = 0;
  bool isShowingTranslation = false;

  @override
  void initState() {
    super.initState();
    _loadWordsForReview();
  }

  Future<void> _loadWordsForReview() async {
    List<Word> reviewList = await DatabaseHelper.instance.getWordsForReview();
    setState(() {
      wordsForReview = reviewList;
      currentIndex = 0; // Reset current index when loading new words
      isShowingTranslation = false; // Reset translation visibility
    });
  }

  void _showNextWord() {
    setState(() {
      if (currentIndex < wordsForReview.length - 1) {
        currentIndex++;
        isShowingTranslation = false; // Hide translation for the new word
      } else {
        // Optionally reload or show a message when there are no more words.
        _loadWordsForReview(); //Load review again or pop
      }
    });
  }

  void _markCorrect() async {
    if (wordsForReview.isEmpty) return; // Exit early if list is empty

    Word currentWord = wordsForReview[currentIndex];
    final nextReviewDate = SpacedRepetition.getNextReview(currentWord, true);

    Word updatedWord = Word(
      id: currentWord.id,
      text: currentWord.text,
      translation: currentWord.translation,
      exampleSentence: currentWord.exampleSentence,
      difficulty: currentWord.difficulty,
      nextReview: nextReviewDate,
      correctStreak: currentWord.correctStreak,
    );

    await DatabaseHelper.instance.updateWord(updatedWord);
    setState(() {
          wordsForReview[currentIndex] = updatedWord; // Update locally
    });
    _showNextWord();
  }

  void _markIncorrect() async {
        if (wordsForReview.isEmpty) return; // Exit early if list is empty

    Word currentWord = wordsForReview[currentIndex];
    final nextReviewDate = SpacedRepetition.getNextReview(currentWord, false);

    Word updatedWord = Word(
      id: currentWord.id,
      text: currentWord.text,
      translation: currentWord.translation,
      exampleSentence: currentWord.exampleSentence,
      difficulty: currentWord.difficulty,
      nextReview: nextReviewDate,
      correctStreak: currentWord.correctStreak,
    );

    await DatabaseHelper.instance.updateWord(updatedWord);
    setState(() {
           wordsForReview[currentIndex] = updatedWord; // Update locally
    });
    _showNextWord();
  }

  @override
  Widget build(BuildContext context) {
    if (wordsForReview.isEmpty) {
      return Scaffold(
        appBar: AppBar(title: const Text('Review Time')),
        body: const Center(child: Text('No words to review right now.  Come back later!')),
      );
    }

    Word currentWord = wordsForReview[currentIndex];
      final progressPercentage = SpacedRepetition.calculateProgressPercentage(currentWord);

    return Scaffold(
      appBar: AppBar(title: const Text('Review Time')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
             LinearPercentIndicator(
                width: MediaQuery.of(context).size.width - 50,
                animation: true,
                lineHeight: 20.0,
                animationDuration: 2500,
                percent: progressPercentage, //progress indicator logic needed
                center: Text(
                  "${(progressPercentage * 100).toStringAsFixed(1)}%",
                  style: const TextStyle(fontSize: 12.0),
                ),
                barRadius: const Radius.circular(5.0),
                progressColor: Colors.green,
              ),
              const SizedBox(height: 20),
            Text(
              currentWord.text,
              style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  isShowingTranslation = !isShowingTranslation;
                });
              },
              child: Text(isShowingTranslation ? 'Hide Translation' : 'Show Translation'),
            ),
            if (isShowingTranslation)
              Padding(
                padding: const EdgeInsets.only(top: 20.0),
                child: Text(
                  currentWord.translation,
                  style: const TextStyle(fontSize: 18, color: Colors.grey),
                ),
              ),
               if (isShowingTranslation)
              Padding(
                padding: const EdgeInsets.only(top: 20.0),
                child: Text(
                  "Example:  ${currentWord.exampleSentence}",
                  style: const TextStyle(fontSize: 14, color: Colors.black),
                ),
              ),
            const SizedBox(height: 40),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                ElevatedButton(
                  onPressed: _markIncorrect,
                  style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
                  child: const Text('Incorrect'),
                ),
                ElevatedButton(
                  onPressed: _markCorrect,
                  style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
                  child: const Text('Correct'),
                ),
              ],
            ),
            const SizedBox(height: 20),
            Text('Next review: ${DateFormat('yyyy-MM-dd HH:mm').format(currentWord.nextReview)}'),
            Text('Streak : ${currentWord.correctStreak}')
          ],
        ),
      ),
    );
  }
}

Step 7: Persistence

Implement persistence using a local database (e.g., SQLite with sqflite) to store user data, progress, and word information. See database helper for database operations.

Challenges and Considerations

  • Algorithm Tuning: The effectiveness of spaced repetition relies heavily on the algorithm. Experiment with different intervals and parameters to optimize the learning experience.
  • Content Quality: High-quality translations, example sentences, and audio pronunciations are crucial for effective language learning.
  • User Engagement: Incorporate gamification elements (e.g., points, badges, leaderboards) to keep users motivated.
  • Offline Access: Consider providing offline access to content and features to accommodate users with limited internet connectivity.

Conclusion

Building language learning apps with Flutter and spaced repetition offers a powerful way to create engaging and effective learning experiences. By combining Flutter’s rich UI capabilities with the principles of spaced repetition, you can develop apps that help users achieve their language learning goals more efficiently. While challenges exist in tuning the algorithm and ensuring content quality, the potential for creating impactful and personalized learning tools is immense.