Все русские фанаты Толкиена знают, какая беда творится с переводами его великого романа (далее – ВК) на язык Пушкина и Достоевского. Поясню вкратце. Через советскую цензуру в 1982 году удалось протащить только первый том, дальше дело заглохло. Чтобы узнать, дошел ли Фродо до Роковой горы, десяток дилетантов независимо друг от друга перевели все остальное, кто как понял. Когда культура толкиенизма в России была уже сформирована, вышел, наконец, и полный профессиональный перевод. Но запал переводчика к тому времени уже иссяк, так что и этот труд вышел довольно неоднозначным: тюремный жаргон, ругательства, ежестраничные отсылки к советским реалиям…

Оригинал же производит на нашего читателя двоякое впечатление. С одной стороны, великолепная стилистическая работа гениального филолога, в руках которого жесткий английский язык гнется, как пластилин ("On he led them, into the mouth of darkness, and still on under the deep clouded night"). С другой - обилие идиом, фразовых глаголов, игры слов и вообще таких выражений, какие ни в одном словаре не сыщешь.

Поэтому я решила написать небольшое приложение, которое поможет освоить все это великолепие при помощи кривой Эббингауза.

Дисклеймер: не уверена, что такую статью не сочтут рекламной, поэтому на всякий случай отправлю ее в "Я пиарюсь". Если модераторы сочтут возможным, то буду рада перемещению в тематические хабы.

Составление базы термов

В первую очередь для этой цели потребовался словарь тех слов и выражений, которые используются в ВК. Частотные методы плохо применимы ввиду довольно сложной грамматики оригинала, полусвободного порядка слов и избытка малоупотребительной лексики. Да и перевод каждого слова требует ручной работы, чтобы выдержать стилистику и учесть контекст употребления.

Но когда-то в наивные студенческие годы я работала над собственным переводом ВК, и с тех пор у меня осталось несколько его экземпляров, размеченных карандашом по словарю Мюллера и ABBYY Lingvo.

Самый красивый из моих экземпляров Lord of the Rings, испорченный пометками
Самый красивый из моих экземпляров Lord of the Rings, испорченный пометками

Открыв Excel, я начала заполнять таблицу, выписывая терм, свой перевод и номер главы, в которой оно встречается. Убив на это дело пару десятков вечеров, я остановилась, когда закончила первую книгу, т.е. первую половину первого тома. Мой словарь составил 6745 слов и словосочетаний.

Разумеется, многие из них дублировали друг друга. Для наглядности я отсортировала первый столбец таблицы по алфавиту.

Первоначальная база. Дубликаты видны невооруженным глазом
Первоначальная база. Дубликаты видны невооруженным глазом

Как видно из скриншота, каждый перевод подбирался к конкретному вхождению слова, поэтому разнится от случая к случаю.

Теперь надо сгруппировать термы по словарным гнездам. Для этого обратимся к мощи pandas. Сгенерируем датафрейм по таблице.

import pandas as pd
import pathlib
excel_data = pd.read_excel(pathlib.Path('c:/', 'LotR', 'словарь Толкиена.xlsx'), dtype = str)
df = pd.DataFrame(excel_data, columns=['Word', 'Russian', 'Chapters']) # эти же заголовки должны фигурировать в первой строке листа Excel

Еще раз убедимся, что данные получены в виде строк:

df = df.astype({'Word': str, 'Russian': str, 'Chapters': str})

И создаем сводную таблицу с помощью функции pivot_table(). Агрегирующая функция set позволит объединить для каждого терма отдельно варианты перевода и отдельно номера глав, устранив дубликаты.

pt = pd.pivot_table(df,
                   values=['Russian', 'Chapters'],
                   index='Word',
                   aggfunc={'Russian': set,
                            'Chapters': set})

Экспортируем обратно в Excel:

pt.to_excel('C:/Властелин Колец/словарь Толкиена сведенный.xlsx')

Теперь база термов выглядит так:

Сведенная база термов
Сведенная база термов

И в ней всего 3851 запись, то есть объем удалось уменьшить вдвое.

Лишние кавычки и фигурные скобки можно убрать обычной автозаменой. Кроме того, правая колонка нуждается в ручном редактировании, так как в словарях принято группировать варианты перевода по частям речи.

Однако меня беспокоило, что в моей базе собраны не все случаи употребления термов, а лишь те, которые когда-то заинтересовали меня как переводчика. Некоторые из них по мере работы над текстом накрепко откладывались в моей памяти, поэтому не были помечены в следующих главах. Поэтому я решила вчерне дополнить недостающие вхождения хотя бы простых слов, без учета грамматики.

Простейший способ это сделать – использовать регулярное выражение:

Chapter\s1.*?(\bterm).*?Chapter\s2

банально проверив, есть ли вхождение слова term между строками Chapter 1 и Chapter 2. Оператор (он же «специальная командная последовательность») \b указывает границу слова, то есть мы ищем слова, которые начинаются с подстроки term, но могут иметь какое-либо окончание (например, terms).

Теперь, имея исходный текст романа text и массив vec из двух элементов (vec[0] - список уже найденных глав, хранящийся в строке через запятую, и vec[1] - собственно терм), можно найти все вхождения терма vec[1] по главам:

import re
def check_chapters(vec):
    global text
    ch = list(map(int, vec[0].split(',')))

    for i in range(1, 13):
        match = re.search(r'Chapter\s{}.*?(\b{}).*?Chapter\s{}'.format(i, vec[1], i + 1), text, flags=re.IGNORECASE) if i < 12 else re.search(r'Chapter\s{}.*?(\b{})'.format(i, vec[1]), text, flags=re.IGNORECASE)
        if match and not i in ch:
            ch.append(i)

    ch.sort()
    return ch

Условие в цикле понадобилось по причине отсутствия в романе 13-й главы, а список ch – для удобства добавления номеров найденных глав к строке vec[0]. И не забудем отсортировать итоговый список глав по возрастанию, потому что раньше забыли это сделать.

Закончив эту подготовительную работу, снова создадим датафрейм из Excel-таблицы. Затем откроем оригинал и удалим из него символы перевода строки и возврата каретки.

f = open('C:/Fellowship Part 1.txt')
text = f.read().replace('\n', '').replace('\r', '')

Теперь применяем нашу функцию к двум столбцам датафрейма и сохраняем результат в Excel:

df['New chapters'] = df[['Chapters', 'Word']].apply(check_chapters, axis=1)
df.to_excel('C:/Властелин Колец/словарь Толкиена сведенный.xlsx')

Хотя поиск по регулярному выражению - довольно трудоемкая операция, на ноутбуке с 4-ядерным Intel i5-8265U и 8 Гб ОЗУ мой список был проверен всего за 874 секунды (время засекалось с помощью функции timeit.default_timer()).

В итоге база термов дополнилась вот этим правым столбцом.

Предпоследний столбец сформирован по моим ручным выпискам, последний дополнен по нему автоматически
Предпоследний столбец сформирован по моим ручным выпискам, последний дополнен по нему автоматически

От Excel к SQLite

Теперь надо создать базу данных SQLite, которая используется в Android.

ER-диаграмма базы данных
ER-диаграмма базы данных

Собственно кривая Эббингауза – это график, и, как известно, один из способов его задания – табличный. Эту функцию в БД выполняет таблица repeat, содержащая интервалы повторений. Я использовала один из найденных в интернете вариантов значений: первое повторение через 1 день, второе через 2, третье через неделю, четвертое через две недели. Логически удобнее считать 1-м повторением само изучение терма, поэтому я заполнила таблицу repeat следующими данными:

id

interval

2

1

3

2

4

7

5

14

В таблице words будут храниться номер и дата последнего повторения. Так как в SQLite отсутствует поле для хранения даты, придется хранить ее в виде строки.

Таблица entries содержит информацию о вхождениях термов в конкретные главы. В ней сильно не хватает первичного ключа для приведения БД к третьей нормальной форме. Сейчас я покажу, почему так получилось.

Чтобы экспортировать данные из Excel в SQLite, понадобится пакет sqlite3.

import sqlite3 as sl

Откроем файл с пустой базой данных:

con = sl.connect(pathlib.Path('c:/', 'LotR', 'lotrvoc.db'))

Добавим в таблицу слов поле для будущего первичного ключа. Фактически он соответствует номеру строки датафрейма с той поправкой, что ID следует начать с единицы.

df = df.assign(idWord = df['Chapters'].index + 1) # нумерация с 1

Сохраняем датафрейм в БД:

df[['idWord','Word','Russian']].to_sql('words', con, if_exists = 'replace', index = False)

Для заполнения таблицы entries воспользуемся функцией explode() из пакета pandas. Она позволяет размножить записи для каждого терма по числу элементов в поле chapters (которые хранятся там, разделенные запятыми, в виде строки, поэтому к ним придется применить функцию split()). Скажем, запись

3691

1, 2, 3, 5

explode() превратит в

3691

1

3691

2

3691

3

3691

5

Код:

df = df.assign(idChapter = df['Chapters'].str.split(', ')).explode('idChapter')
df = df.astype({'idChapter': int})
df[['idWord','idChapter']].to_sql('entries', con, if_exists = 'replace', index = False)

Осталось добавить первичные ключи. Для таблицы words это легко:

con.execute('CREATE UNIQUE INDEX IF NOT EXISTS "idWord" ON "words" ("idWord");')

А вот как добавить первичный ключ для entries - это вопрос, потому что трюк с копированием index у другого поля здесь не пройдет. Как показала практика, все значения индексов копируются с первоначальной таблицы и не размножаются при работе explode(). Придется пока обойтись без третьей нормальной формы.

Теперь можно посмотреть внимательнее на состав получившейся базы данных:

Общее число термов и число новых термов для каждой из двенадцати глав
Общее число термов и число новых термов для каждой из двенадцати глав

Как показывают синие столбики, лишь для освоения первых двух-трех глав действительно придется совершить рывок. Затем число изучаемых термов будет держаться в районе двухсот, а для главы 10 составит всего 103. Кроме того, надо учитывать, что при хорошем словарном запасе пользователя далеко не все термы потребуют изучения.

Android-приложение

Осталось написать приложение, которое будет помогать в изучении словаря и отслеживать прогресс. Я использую Android Studio и Java. Интерфейс будущего приложения вдохновлен замечательной программой reword.

Для начала хватит одной activity, где разместятся терм, перевод и две кнопки – «Знаю» и «Не знаю». Здесь же переключатель режима изучения/повторения термов. Поэтому код activity_mail.xml выглядит так:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#d1d1d1"
    android:orientation="vertical">

    <TextView
        android:id="@+id/tvWord"
        android:layout_width="0dp"
        android:layout_height="191dp"
        android:layout_marginStart="38dp"
        android:layout_marginTop="61dp"
        android:layout_marginEnd="38dp"
        android:layout_marginBottom="61dp"
        android:text="TextView"
        android:textColor="#09627C"
        android:textSize="40dp"
        app:layout_constraintBottom_toTopOf="@+id/tvTranslation"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.0" />

    <TextView
        android:id="@+id/tvTranslation"
        android:layout_width="0dp"
        android:layout_height="182dp"
        android:layout_marginStart="38dp"
        android:layout_marginEnd="38dp"
        android:layout_marginBottom="240dp"
        android:onClick="onSuggestionClick"
        android:text="TextView"
        android:textColor="#635C5C"
        android:textSize="36dp"
        android:textStyle="italic"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/btKnow"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="44dp"
        android:layout_marginBottom="68dp"
        android:onClick="onKnowClick"
        android:text="Уже знаю"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tvTranslation"
        app:layout_constraintVertical_bias="0.967" />

    <Button
        android:id="@+id/btWillLearn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="56dp"
        android:layout_marginBottom="72dp"
        android:onClick="onWillLearnClick"
        android:text="Учить"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <RadioGroup
        android:id="@+id/rgLearnRepeat"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <RadioButton
            android:id="@+id/rbLearn"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="24dp"
            android:layout_weight="1"
            android:checked="true"
            android:text="Учить"
            android:textColorLink="#00BCD4" />

        <RadioButton
            android:id="@+id/rbRepeat"
            android:text="Повторять"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="24dp"
            android:layout_weight="1" />
    </RadioGroup>
</androidx.constraintlayout.widget.ConstraintLayout>

Создадим классы для слова и главы, приблизительно соответствующие таблицам базы данных.

Вот как выглядит класс Word:

public class Word {
    private int id;
    private String word;
    private String translation;
    private boolean isKnow;
    private boolean isFirstShow;
    private int remember;
    private int repnum;
}

Remember – это счетчик, показывающий, сколько раз пользователь вспомнил слово при изучении. Лично мне одного раза для запоминания недостаточно, поэтому здесь не булево значение, а целое число.

Сгенерируем конструктор, геттеры и сеттеры и внесем небольшие дополнения.

При создании слова считаем его по умолчанию неизученным (isKnow), а счетчик удачных попыток remember обнулим.

public Word(int id, String word, String translation, int repnum, boolean isFirstShow) {
    this.id = id;
    this.word = word;
    this.translation = translation;
    this.isKnow = false;
    this.isFirstShow = isFirstShow;
    this.remember = 0;
    this.repnum = repnum;
}

После первого показа будем использовать метод setFirstShow(), который зафиксирует факт состоявшегося показа терма:

public void setFirstShow() {
    this.isFirstShow = false;
}

Также нам понадобится увеличивать счетчик удачных попыток на единицу:

public void incRemember() {
    this.remember ++;
}

Будем считать терм изученным, либо если он был известен ранее, либо если пользователь вспомнил его заданное число раз. Если число попыток каким-то образом превысит лимит, то тем лучше.

public boolean learned() {
    if (this.isKnow || this.remember > 3)
        return true;
    return false;
}

Брр, хардкодинг! Никогда так не делайте!)

Что касается файла Chapter.java, то одноименный класс имеет менее сложную структуру:

public class Chapter {
    private int id;
    private String name;
    private int booknumber;
    private int volumenumber;
}

Из интересных методов здесь только инкремент номера главы в случае исчерпания неизученных термов из главы текущей.

public void incId() {
    this.id ++;
}

Основная работа с базой данных производится в классе DatabaseHelper.

public class DatabaseHelper extends SQLiteOpenHelper {
    public static final String DBNAME;
    public static final String DBLOCATION;
    private Context mContext;
    private SQLiteDatabase mDatabase;
    private int maxRepnum;
}

Эти поля хранят имя и расположение БД, контекст для получения информации о приложении, объект БД SQLite и максимальное число повторений для того, чтобы признать терм изученным. Значения задаются прямо здесь или в конструкторе. Исключение составляет переменная mDatabase, значение которой присваивается в отдельном методе openDatabase(). Его реализация тривиальна, как и парного метода closeDatabase().

Собственно работа с БД уложилась в три метода.

Метод getCurrentChapter() определяет, какую главу пользователь готовится прочитать. Исходя из предположения, что чтение производится в хронологическом порядке, напишем SQL запрос для определения непрочитанной главы с наименьшим ID:

public Chapter getCurrentChapter() {
    openDatabase();
    String query = "SELECT id, name, booknumber, volumenumber FROM chapters WHERE isread = 0 ORDER BY id LIMIT 1";
    Cursor cursor = mDatabase.rawQuery(query, new String[] {});
    cursor.moveToFirst();
    Chapter ch = new Chapter(0, "", 0, 0);
    if (!cursor.isAfterLast()) {
        ch.setId(cursor.getInt(0));
        ch.setName(cursor.getString(1));
        ch.setBooknumber(cursor.getInt(2));
        ch.setVolumenumber(cursor.getInt(3));
    }
    cursor.close();
    closeDatabase();
    return ch;
}

Следующий метод предназначен для получения собственно списка изучаемых/повторяемых термов. Здесь интерес представляют два SQL-запроса.

Получить список новых термов для первичного изучения (параметры – номер текущей главы и число термов, изучаемых за один сеанс):

SELECT idWord, Word, Russian
FROM words
WHERE repnum = 0 and idWord IN (SELECT idWord FROM entries WHERE idChapter = ?)
ORDER BY RANDOM()
LIMIT ?

Получить список для повторения (параметры те же + максимальное число повторений, после которого терм считается изученным):

SELECT idWord, Word, Russian, repnum, ((strftime('%s', 'now') - strftime('%s', repdate)) / 86400.0) AS days
FROM words
WHERE idWord IN (SELECT idWord FROM entries WHERE idChapter = ?)
        AND repnum > 0 AND repnum < ? AND days > (SELECT interval FROM repeat WHERE id = repnum + 1)
ORDER BY days DESC
LIMIT ?

Так как нас интересует только число дней (а не часов или минут), прошедших с предыдущего повторения, то идеальным решением была бы функция julianday() из SQLite. Однако она не поддерживается старыми версиями Java, то есть потенциально и старыми телефонами. Поэтому придется вычитать не дни, а строковые значения дат, полученные с помощью функции strftime() с параметром («определителем преобразования») "%s" – число секунд, прошедших с начала эпохи Unix, - а затем переводить секунды в дни с помощью деления на 60*60*24.

При этом мы получаем только те термы, у которых уже было хотя бы одно повторение (repnum > 0), однако не достигнуто максимальное число повторений (repnum < maxRepnum) и, главное, истек лимит для следующего повторения (repnum + 1). В первую очередь следует выдать термы, которые ждут своей очереди дольше всех (ORDER BY days DESC). Кривая Эббингауза в действии.

Полный код метода getListWord() выглядит так:

public List<Word> getListWord(int idChapter, int wordsInDay, boolean isRepeat) {
    Word w = null;
    List<Word> wordList = new ArrayList<>();
    openDatabase();
    String query = "";
    Cursor cursor;
    if (!isRepeat) {
        query = "SELECT idWord, Word, Russian\n" +
                "FROM words\n" +
                "WHERE repnum = 0 and idWord IN (SELECT idWord FROM entries WHERE idChapter = ?)\n" +
                "ORDER BY RANDOM()\n" +
                "LIMIT ?";
        cursor = mDatabase.rawQuery(query, new String[]{String.valueOf(idChapter), String.valueOf(wordsInDay)});
    }
    else {
        query = "SELECT idWord, Word, Russian, repnum, ((strftime('%s', 'now') - strftime('%s', repdate)) / 86400.0) AS days \n" +
                "FROM words\n" +
                "WHERE idWord IN (SELECT idWord FROM entries WHERE idChapter = ?)\n" +
                "AND repnum > 0 AND repnum < ? AND days > (SELECT interval FROM repeat WHERE id = repnum + 1)\n" +
                "ORDER BY days DESC\n" +
                "LIMIT ?";
        cursor = mDatabase.rawQuery(query, new String[]{String.valueOf(idChapter),
                String.valueOf(maxRepnum), String.valueOf(wordsInDay)});
    }
    if (cursor.moveToFirst()) {
        int repnum = 0;
        boolean isFirstShow = (isRepeat == false);
        while (!cursor.isAfterLast()) {
            if (isRepeat)
                repnum = cursor.getInt(3);

            w = new Word(cursor.getInt(0), cursor.getString(1),
                    cursor.getString(2), repnum, isFirstShow);
            wordList.add(w);
            cursor.moveToNext();
        }
    }
    cursor.close();
    closeDatabase();
    return wordList;
}

Последний метод saveListWord() служит для сохранения изученного/повторенного списка термов в БД. По упомянутым выше причинам придется хранить дату в строковом формате.

        openDatabase();
        ContentValues values = new ContentValues();

        SimpleDateFormat formatter= new SimpleDateFormat("yyyy-MM-dd");
        Date date = new Date(System.currentTimeMillis());
        values.put("repdate", formatter.format(date));

Очень хотелось написать далее что-то вроде

UPDATE words SET repnum = repnum + 1 WHERE idWord IN (…)

и обойтись одним запросом. Но оказалось, что запрос с UPDATE нельзя передавать в сыром виде, то есть в методы execSQL() и rawQuery() класса SQLiteDatabase. Согласно документации, метод execSQL() не предназначен для вставки/обновления/удаления, а rawQuery() - только для операции SELECT. В стандартный же метод update() так запросто не вставишь инкремент repnum для всех полей. Поэтому пришлось выполнять по запросу для каждого терма из списка:

        for (int i = 0; i < wordList.size(); i ++) {
            Word w = wordList.get(i);
            if (w.getIsKnow())
                values.put("repnum", maxRepnum);
            else if (w.getRemember() > 3)
                values.put("repnum", w.getRepnum() + 1);
            mDatabase.update("Words", values, "idWord = ?", new String[]{String.valueOf(w.getId())});
        }

Если терм ранее был известен пользователю и не нуждается в изучении, то ему сразу присваивается максимальное значение числа повторений. В противном случае увеличиваем номер повторения, но только для тех термов, которые пользователь вспомнил не менее заданного числа раз (снова хардкодинг!). Если как-то получилось, что счетчик прокрутился больше этого числа, то тем лучше: повторение – мать учения.

Теперь надо обновить статистику изученных слов. Думаете, что для этого достаточно увеличить nlearned для текущей главы на wordsInDay? Ничего подобного. Важно обновить статистику для всех глав, т.к. слова повторяются в разных главах. Но в методе update() почему-то нельзя вставить вложенные запросы. Попытка решить задачу с помощью компилируемых выражений тоже не сработала. Поэтому пришлось произвести подсчеты средствами SQL, записать результат в хэш-таблицу, а ее затем построчно сохранить в БД.

Собственно подсчет делается путем соединения всех трех значимых таблиц – words, entries и chapters:

SELECT id, COUNT(entries.idWord) FROM chapters
JOIN entries ON chapters.id = entries.idChapter
JOIN words ON words.idWord = entries.idWord
WHERE words.Repnum >= ?
GROUP BY entries.idChapter

Записав этот запрос в строковую переменную query, продолжаем:

        Cursor cursor = mDatabase.rawQuery(query, new String[] {String.valueOf(maxRepnum)});
        if (cursor.moveToFirst()) {
            Hashtable<Integer, Integer> hashtable = new Hashtable<>();
            while (!cursor.isAfterLast()) {
                hashtable.put(cursor.getInt(0), cursor.getInt(1));
                cursor.moveToNext();
            }

            values.clear();
            Set<Integer> keySet = hashtable.keySet();
            for (Integer key : keySet) {
                values.put("nlearned", String.valueOf(hashtable.get(key)));
                mDatabase.update("chapters", values, "id = ?", new String[]{String.valueOf(key)});
            }
        }
        cursor.close();

После этого остается только обновить индикатор isread, если какая-то глава оказалась изученной. Если где-то изучено больше термов, чем содержит глава, - ничего страшного, пригодятся)

        values.clear();
        values.put("isread", "1");
        mDatabase.update("chapters", values, "nlearned >= nwords", new String[]{});

И закрываем БД с помощью метода closeDatabase().

В классе MainActivity, помимо обычных приватных полей для элементов управления, нам понадобится еще несколько:

private Chapter curChapter;
private int wordsInDay;
private int curWordPos;
private boolean isRepeat;

Здесь будем хранить, соответственно, информацию о текущей главе, число слов, изучаемых за 1 день (или, точнее, за 1 сеанс), текущую позицию в списке изучаемых слов и индикатор режима повторения.

В методе onCreate() получаем объекты View с главной activity, задаем значение wordsInDay (снова хардкодинг!) и не забываем обработать слушателя OnCheckedChangeListener для радиокнопок. При переключении режима изучения/повторения надо будет сменить значение индикатора isRepeat и загрузить из БД новый список термов.

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tvWord = (TextView)findViewById(R.id.tvWord);
        tvTranslation = (TextView)findViewById(R.id.tvTranslation);
        btKnow = (Button)findViewById(R.id.btKnow);
        btWillLearn = (Button)findViewById(R.id.btWillLearn);
        wordsInDay = 10; // сколько слов изучим за 1 сеанс

        RadioGroup rgLearnRepeat = (RadioGroup)findViewById(R.id.rgLearnRepeat);
        rgLearnRepeat.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener()
        {
            public void onCheckedChanged(RadioGroup group, int checkedId) {
                if (checkedId == R.id.rbRepeat)
                    isRepeat = true;
                else
                    isRepeat = false;
                Log.w("isRepeat", "isRepeat = " + isRepeat);
                getListWord();
            }
        });

Создаем переменную для работы с базой данных. Если БД отсутствует на устройстве, то копируем ее. Неплохой урок на эту тему здесь.

Затем получим информацию о текущей главе с помощью метода, разобранного ранее:

        curChapter = mDBHelper.getCurrentChapter();
        if (curChapter.getId() == 0) {
            Toast.makeText(this, "Не удалось найти главу для изучения", Toast.LENGTH_SHORT).show();
            return;
        }

И список слов для изучения или повторения:

        getListWord();

Метод getListWord() не просто вызывает одноименный метод из класса DatabaseHelper, но еще и контролирует движение по главам с учетом выбранного режима – изучение или повторение. Если в режиме повторения не удалось найти подходящих термов, делаем вывод, что пока ни для одного терма не истек лимит очередного повторения, и переходим в режим изучения. Если же и там закончились термы, то рапортуем об изучении очередной главы. После этого ее можно с чистой совестью начинать читать.

public void getListWord() {
    mWordList = mDBHelper.getListWord(curChapter.getId(), wordsInDay, isRepeat);
    if (mWordList.size() == 0) {
        if (isRepeat) {
            Toast.makeText(this, "Все слова повторены!", Toast.LENGTH_LONG).show();
            RadioGroup rgLearnRepeat = (RadioGroup) findViewById(R.id.rgLearnRepeat);
            rgLearnRepeat.check(R.id.rbLearn);
        } else {
            while (mWordList.size() == 0) {
                Toast.makeText(this, "Глава " + curChapter.getId() + " изучена", Toast.LENGTH_LONG).show();
                curChapter.incId();
                mWordList = mDBHelper.getListWord(curChapter.getId(), wordsInDay, isRepeat);
            }
        }
    }
    if (!isRepeat && mWordList.size() == 0)
        Toast.makeText(this, "Все главы изучены", Toast.LENGTH_LONG).show();
    else {
        curWordPos = 0;
        showNextWord();
    }
}

Независимо от режима, если удалось сформировать новый список термов, обнуляем счетчик curWordPos, чтобы он соответствовал первому элементу списка, и вызываем метод showNextWord().

Здесь мне хотелось сохранить направление движения по списку, поэтому поиск в нем неизученных термов начинается не с 0, а с curWordPos, доходит до конца и начинается с первого элемента.

        boolean isFound = false;
        for (int i = curWordPos + 1; i < mWordList.size(); i ++)
            if (mWordList.get(i).learned() == false) {
                curWordPos = i;
                isFound = true;
                break;
            }
        if (!isFound) {
            for (int i = 0; i <= curWordPos; i ++)
                if (mWordList.get(i).learned() == false) {
                    curWordPos = i;
                    isFound = true;
                    break;
                }
        }

Найдя неизученный терм, выводим его в activity и задаем надписи на кнопках: для первого показа – «Уже знаю» и «Буду учить», для последующих – «Вспомнил» и «Забыл». Что касается перевода, то он отображается лишь при первом показе в режиме изучения, а в остальных случаях заменяется вопросом: «Подсказать?».

        if (isFound) {
                // если это первый показ, то сначала спросим, знает ли пользователь это слово
                tvWord.setText(mWordList.get(curWordPos).getWord());
                tvTranslation.setText(mWordList.get(curWordPos).getTranslation());
                if (!isRepeat && mWordList.get(curWordPos).isFirstShow()) {
                    btKnow.setText("Уже знаю");
                    btWillLearn.setText("Буду учить");
                }
                else {
                    tvTranslation.setText("Подсказать?");
                    btKnow.setText("Вспомнил");
                    btWillLearn.setText("Забыл");
                }
            }
        else {
            Toast.makeText(this, "Готово! Слова из текущего списка выучены!", Toast.LENGTH_LONG).show();
            mDBHelper.saveListWord(mWordList);
            getListWord();
        }
    }

Если ни одного неизученного терма в списке mWordList не нашлось, то показываем короткое сообщение, сохраняем текущий список и загружаем новый.
Чтобы реализовать отображение подсказки по клику на надпись «Подсказать?», мы ранее добавили в файле activity_main.xml к параметрам tvTranslation строку

android:onClick="onSuggestionClick"

Код этого метода простейший:

public void onSuggestionClick(View view) {
    tvTranslation.setText(mWordList.get(curWordPos).getTranslation());
}

Осталось описать действие двух кнопок – btKnow и btWillLearn.

Если на первой кнопке написано «Уже знаю», то по щелчку на ней надо присвоить полям isKnow и isFirstShow текущего терма соответственно значения true и false. Если же на кнопке написано «Вспомнил», то увеличиваем счетчик удачных попыток вспомнить слово (remember). В любом случае переходим к следующему терму из списка.

public void onKnowClick(View view) {
    if (mWordList.get(curWordPos).isFirstShow()) {
        mWordList.get(curWordPos).makeKnow();
        mWordList.get(curWordPos).setFirstShow();
    }
    else
        mWordList.get(curWordPos).incRemember();
    showNextWord();
}

По клику на второй кнопке мы тоже переходим к следующему терму, предварительно констатируя факт показа (isFirstShow = false) в том случае, если на кнопке написано «Буду учить».

public void onWillLearnClick(View view) {
    if (mWordList.get(curWordPos).isFirstShow())
        mWordList.get(curWordPos).setFirstShow();
    showNextWord();
}

Вот и весь код приложения, за исключением тривиальных операций по работе с БД, не относящихся к логике самого приложения.

Запускаем приложение (я запускаю на реальном устройстве, так как виртуальное намертво вешает мой ноутбук) и видим вот такие картинки.

Скриншоты приложения в режиме 1) изучения и 2) повторения
Скриншоты приложения в режиме 1) изучения и 2) повторения

Вручную выставляя изученным термам дату повторения на 1, 2, 7 и 14 дней назад, я проверила, что они выходят для повторения своевременно.

А теперь посмотрим на статистику.

Прогресс изучения термов по первым 12-ти главам ВК
Прогресс изучения термов по первым 12-ти главам ВК

Как показывает столбец nlearned, после изучения первых же термов из главы 1 уже не осталось ни одной другой главы с нулевым количеством изученного материала. Благодаря повторам слов и выражений пользователь, изучая одну главу, сразу приобретает задел на все последующие.

Остается добавить activity для задания настроек и вывода статистики, фанфары при завершении изучения очередной главы и… еще несколько тысяч термов из второй половины «Братства Кольца».

Комментарии (2)


  1. Wesha
    20.12.2022 04:04

    jaws — это вообще-то не "пасть", а "челюсти". "пасть" — это maw.


    1. Ioanna Автор
      20.12.2022 10:04

      Да, спасибо. Видимо, где-то в контексте лучше звучал перевод "пасть", поэтому я его и записала.