iOS app for learning a foreign language with word cards

Flashspeak

Приложение iOS для изучения иностранных слов по наборам карточек.

Содержание

Начало

Мы небольшая команда начинающих разработчиков, которых объеденяет школа IT GeekBrains. В рамках финального курса, нам предстояло создать приложение. Перед стартом написания, мы поставили перед собой следующие задачи:

  • Научиться работать в команде при раработке продукта
  • Научиться организовывать работу используя Task Manager
  • Придумать проект, который мы в силах раработать
  • Сформировать техническое описание
  • Создать дизайн приложения
  • Написать приложение
  • Оформить ReadMe в репозитории

В итоге у нас получилось приложение, обзор ниже.

Обзор

Создание списка Обучение Список слов

List.mov

LearnReview.mov

WardCardsReview.mov

Возможности

Пользователь, выбрав язык изучения, может легко создать свой список слов для изучения. Слова можно добавить через вставку или вводя по отдельности. Приложение сформирует перевод и найдет подходящие изображения для каждого слова. Пользователь на главноем экране увидит новый список. По нажатию на него, можно просмотреть получившиеся карточки, а если перевод и подобранное изображение не подойдут то можно загрузить свое изображение или изменить перевод слова. Изучение можно проходить по различным сценариям. Карточку для изучения можно сделать из частей: исходное слово, перевод и изображение. Отвечать на предложенную карточку можно через тестирование или набирая ответ на клавиатуре. Для понимания звучания, есть кнопка произнести слово вслух. Результаты прохождения каждого изучения списка сохранаются и формируются в статистику, так же пользователю будут показаны ошибки для работы над ними.

Выбор языка

При первом открытии приложения, пользователь видит экран приветствия. Приложение просит выбрать родной язык и изучаемый. По кнопке “Начать” сценарий первого старта заканчивается и открывается основной экран со списками слов1.

Списки слов

Это главный экран, который пользователь будет видеть при последующих запусках приложения. Изначально экран пустой. Чтобы показать пользователю что делать дальше, показывается стрелочка, которая указывает на кнопку создания списка “+”. После создания списка, пользователь может его увидеть на главном экране. При длительном нажатии на ячейку списка или по кнопке в верхнем правом углу, открывается меню для управления списком слов1. Если пользователь решит изучать другой язык, то он может сменить его по кнопке с изображением флага в верхнем правом углу экрана2.

Создание и редактирование списка слов

Создание списка начинается по кнопке “+” на главном экране списоков. По нажатию, открыватся модальное окно с полями для названия, выбора цвета для ориентации и переключателем изображний для слов. По нажатию на “Создать список”, пользователь попадает на экран набора слов. Экран позволяет создавать и редактировать слова, на нем есть всего три элемента: поле для ввода, кнопка создания и кнопка помощи. Слова можно вводить набирая с клавиатуры, а также вставлять уже готовый список слов, которые отделены символом запятой или переходом строки. Чтобы сориентировать пользователя, есть кнопка с вопросом, которая открывает окно с описанием возможностей. У списка есть минимальные требования колличества слов, подсказки с этой информацией отображается на кнопке в процессе набора слов. Для добавления в список слова, можно использовать кнопку Enter, запятую или кнопку +, которая появляется справа от поля в момент начала ввода слова. Чтобы удалить или исправить уже введенное слово необходимо удерживать его пару секунд, активируются поле удаления и редактирования. Пользователю необходимо перенести слово в нужное поле. Если пользователь не нажмет на кнопку создать карточки и захочет вернуться на предыдущий экран, то приложение предложит два варианта: выйти без сохранения или вернуться, чтобы не потерять созданный список12.

Просмотр карточек

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

Редактирование карточки

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

Изучение

Перед запуском изучения, пользовтелю предлагается выбрать индивидуальные настройки карточки слова и функции редактирования списока. По нажатию на кнопку настроек, открывается меню, где можно выбрать отображение вопроса и способ ответа. Экран создается по паттерну Strategy. За сохранение ответов пользователя отвечает Caretaker, который потом дает данные для экрана статистики и работы над ошибками1.

Результаты изучения

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

Темное оформление

Приложение поддерживает автоматическое переключение режима оформления.

Реализация

Task Manager

Для работы мы используем сервис Weeek. Метод организации нашей работы – Scrum, задачи ведем в канбан досках.

API

Используются API сервисы для перевода и изображений. Сервис запроса в сеть написан с использованием Combine и Generic для переиспользования при получении различных типов данных: перевода или изображений к переводу4.

func publisher<T: Decodable>(
for url: URL,
queue label: String,
responseType: T.Type = T.self,
decoder: JSONDecoder = .init()
) -> AnyPublisher<T, NetworkError> {
dataTaskPublisher(for: url)
.receive(on: DispatchQueue(label: label, qos: .background, attributes: .concurrent))
.map(\.data)
.decode(type: NetworkResponse<T>.self, decoder: decoder)
.mapError({ error -> NetworkError in
switch error {
case is URLError:
return NetworkError.network(description: error.localizedDescription)
case is DecodingError:
return NetworkError.decodingError
default:
return NetworkError.invalidResponse
}
})
.flatMap({ response -> AnyPublisher<T, NetworkError> in
guard let value = response.wrappedValue else {
return Fail<T, NetworkError>(error: NetworkError.unwrap).eraseToAnyPublisher()
}
return Just(value)
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
})
.eraseToAnyPublisher()
}
}

Так же будет легко заменить API при проблемах на стороне сервиса.

class NetworkService: NetworkServiceProtocol {
// MARK: – Public functions
func translateWordsWithGoogle(url: URL) -> AnyPublisher<TransalateResponse, NetworkError> {
URLSession.shared.publisher(for: url, queue: translateWords)
}
func getImageURL(url: URL) -> AnyPublisher<ImageUrlModel, NetworkError> {
URLSession.shared.publisher(for: url, queue: getImageUrl)
}
func imageLoader(url: URL) -> AnyPublisher<UIImage?, NetworkError> {
URLSession.shared
.dataTaskPublisher(for: url)
.mapError({ error -> NetworkError in
switch error {
default:
return NetworkError.unknownError(error: error)
}
})
.map { data, _ in UIImage(data: data) }
.eraseToAnyPublisher()
}
}

Перевод

Перевод осуществялется силами Google. Приложение отправляет список слов и получает ответ в виде переведенного списка1.

Изображения

Подбор изображений осуществляет сервис Unsplash. Приложение отправляет ключевое слово и код языка, а ответ приходит в виде ссылок на изображения. Сервис не всегда присылает подходящие фотографии, поэтому в будущем он заменится на альтернативный, это будет лего сделать, так как сетевой слой написан универсально с использованием generic функции41. Хранятся изображения в виде ссылок. При загрузке фотографий используется cache, а индивидуальные фотографии пользователя для карточек сохраняются в папку приложения1.

Архитектура

Используется ModelViewPresenter. Архитектура удобна в командной работе над проектом.

Паттерны

Delegate

Использован для обмена данными и событиями между view

Strategy

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

switch settingsManager.questionAdapter {
case .word:
self.questionViewStrategy = QuestionWordViewStrategy()
case .image:
self.questionViewStrategy = QuestionImageViewStrategy()
default:
self.questionViewStrategy = QuestionWordImageViewStrategy()
}
switch settingsManager.answer {
case .test:
self.answerViewStrategy = AnswerTestViewStrategy()
case .keyboard:
self.answerViewStrategy = AnswerKeyboardViewStrategy()
}
protocol QuestionViewStrategy {
var view: UIView { get }
func set(question: Question)
}
protocol AnswerViewStrategyProtocol {
var answer: Answer? { get set }
var collectionView: UICollectionView { get }
var collectionViewDelegate: UICollectionViewDelegate? { get set }
var collectionViewDataSource: UICollectionViewDataSource? { get set }
var delegate: AnswerViewControllerDelegate? { get set }
func set(answer: Answer)
func didAnswer(indexPath: IndexPath?)
func highlight(isRight: Bool?, index: Int)
func action(_ action: AnswerViewStrategy.Action)
}

Еще паттерн используется при формировании очереди вопросов для урока, согласно выбранным настройкам1.

/// Creator for questions queue by strategy
private var questionsStrategy: any QuestionsStrategy {
switch settingsManager.questionAdapter {
case .word:
return WordQuestionsStrategy()
case .image:
return ImageQuestionsStrategy()
case .wordImage:
return WordImageQuestionsStrategy()
}
}
/// Creator for answers queue by strategy
private var answerStrategy: any AnswerStrategy {
switch settingsManager.answer {
case .test:
return TestAnswerStrategy()
case .keyboard:
return KeyboardAnswerStrategy()
}
}
protocol AnswerStrategy: AnyObject {
func createAnswers(_ words: [Word], source: LearnLanguage.Language) -> [Answer]
}
protocol QuestionsStrategy: AnyObject {
func createQuestions(_ words: [Word], source: LearnLanguage.Language) -> [Question]
}

Caretaker

Во вермя урока, есть много данных, а именно: ответы, ошибки, время. Их надо обработать, избавив классы от перегрузки кодом. Эту задачу сбора и сохранения во время прохождения урока берет на себя сaretaker1.

func addResult(answer: Bool, for wordID: UUID, mistake: String) {
guard
let index = words.firstIndex(where: { $0.id == wordID })
else { return }
if answer {
words[index].rightAnswers += 1
} else {
words[index].wrongAnswers += 1
mistakeWords[words[index]] = mistake
if mistake.isEmpty {
mistakeWords[words[index]] = NSLocalizedString(Empty, comment: description).lowercased()
}
}
}
func finish() {
updateWordInCD()
}
func addResult(answer: Bool) {
if answer {
learn.result += 1
}
}
func finish() {
learn.finishTime = Date.now
saveLearnToCD(learn, for: listID)
}

Singleton

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

Coordinator

Нужен для координации окон приложения. Так как мы отказалист от классических storyboard, которые при одноверменной работе вызывают ошибки и конфликты, то наиболее удобное решение – это Coordinator.1

protocol Coordinator: AnyObject {
var finishDelegate: CoordinatorFinishDelegate? { get set }
// Каждому координатору назначен один навигационный контроллер
var navigationController: UINavigationController { get set }
/// Массив для всех дочерних координаторов
var childCoordinators: [Coordinator] { get set }
/// Определенный тип потока
var type: CoordinatorType { get }
/// Место, где можно поставить логику, чтобы начать поток
func start()
/// Место, где можно поставить логику, чтобы закончить поток,
/// очистить всех дочерних координаторов и уведомить родителя о том,
/// что этот координатор готов к завершению
func finish()
func reload()
init(_ navigationController: UINavigationController)
}

Router

События переходов в окнах описываются в едином файле router, который имеет closure для сообщения координатору события. Данные передаются через case в enum событиях.

protocol ListsEvent {
var didSendEventClosure: ((ListsRouter.Event) -> Void)? { get set }
}
class ListsRouter: ListsEvent {
enum Event {
case prepareLearn(list: List)
case newList
case changeLanguage(language: Language)
case editList(list: List)
case editWords(list: List)
case transfer(list: List)
case error(error: LocalizedError)
}
var didSendEventClosure: ((ListsRouter.Event) -> Void)?
}

Builder

Для создания комплекта файлов каждого окна, нужен builder. В нем можно настроить архитектуру и связи между делегатом и делегатами, передать данные.

struct ListsBuilder {
static func build(router: ListsEvent) -> UIViewController & ListsViewInput {
let coreData = CoreDataManager.instance
let presenter = ListsPresenter(
fetchedListsResultController: coreData.initListFetchedResultsController(),
router: router
)
let listsCollectionDataSource = ListsCollectionDataSource()
let listsCollectionDelegate = ListsCollectionDelegate()
let searchResultsUpdating = ListSearchResultsController()
let viewController = ListsViewController(
presenter: presenter,
listsCollectionDataSource: listsCollectionDataSource,
listsCollectionDelegate: listsCollectionDelegate,
searchResultsController: searchResultsUpdating
)
presenter.viewController = viewController
listsCollectionDelegate.viewController = viewController
listsCollectionDataSource.viewController = viewController
searchResultsUpdating.viewController = viewController
return viewController
}
}

Библиотеки

UIKit

Для написания интерфейса приложения выбрана классическая библиотека.

SwiftUI

Используется для отрисовки графиков результатов, потому что имеет встроенную библиотеку Chart. Код встаивания через UIHostController

let viewController = UIHostingController(
rootView: ChartLearnView(viewModels: viewModels, color: Color(color))
)
let chartView = viewController.view ?? UIView()
resultView.updateChartView(chartView)
addChild(viewController)
viewController.didMove(toParent: self)
resultView.chartStackView.isHidden = false

Код графика

var body: some View {
Chart(viewModels.sorted(by: { $0.date < $1.date }), id: \.date) { model in
LineMark(
x: .value(Date, model.date),
y: .value(Result, model.result)
)
.foregroundStyle(color)
// .foregroundStyle(by: .value(“Result”, model.stat))
PointMark(
x: .value(Date, model.date),
y: .value(Result, model.result)
)
.foregroundStyle(color)
// .foregroundStyle(by: .value(“Result”, model.stat))
AreaMark(
x: .value(Date, model.date),
y: .value(Result, model.result)
)
.foregroundStyle(Gradient(colors: [color, .clear]))
}
.chartForegroundStyleScale([
\(viewModels.first?.stat.primitivePlottable ?? Result): color
])
.chartYAxisLabel(position: .trailing, alignment: .center) {
Text(Result)
}
// .chartXAxisLabel(position: .bottom, alignment: .center) {
// Text(“Date”)
// }
}
}

CoreData

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

Combine

Использование реактивного програмирования позволило уменьшить колличество делегатов и кода для связи объектов между друг другом.

AVFoundation

Озвучивание иностранного слова полезно для обучения языку. По нажатию на кнопку speaker в уроке, слово синтезируется речь использую эту библиотеку.

func speech() {
// Speech text
let text = current.question.question
// Create an utterance.
let utterance = AVSpeechUtterance(string: text)
// Configure the utterance.
utterance.rate = 0.4
utterance.pitchMultiplier = 0.8
utterance.postUtteranceDelay = 0.2
utterance.volume = 0.8
// Retrieve voice bu setting language
let languageCode = UserDefaultsHelper.targetLanguage
guard let language = Language.language(by: languageCode) else { return }
let voice = AVSpeechSynthesisVoice(language: language.speechVoice)
// Assign the voice to the utterance.
utterance.voice = voice
synthesizer.speak(utterance)
}

Хранение данных

  • CoreData3

  • UserDefaults3 Хранит значения ключей настроек обучения, профиля пользователя,

    private enum UserDefaultsKeys: String {
    case nativeLanguage = nativeLanguageKey
    case targetLenguage = targetLanguageKey
    case learnModeSetting
    case learnModeTimerSetting
    case learnWordSetting
    case learnImageSetting
    case learnSoundSetting
    case learnAnswerSetting
    case learnLanguageSetting
    var asString: String {
    return self.rawValue
    }
    }
  • Config.xcconfig4 Хранит токены для API. Файл внесен в gitignore, чтоб токены не утекли в сеть.

Требования

  • iOS 16.0+
  • Xcode 14.3

Как запустить

Для запуска приложения, нужно внести токены API в Config.xcconfig.

Зачем

Проект создан в рамках курса “Командная разработка на Swift” в школе GeekBrains. Преподаватель курса Александр Рубцов.

To Do

  • Добавить интерактивный виджет урок
  • Добавить трансфер списка слов на другой язык
  • Сделать экран настроек приложения
  • Сделать несколько списков слов по умолчанию при первом открытии приложения
  • Добавить возможность сменить язык в приложении
  • Заменить сервис изображений
  • Добавить графики для отображения статистики

Команда проекта

Сноски на реализованные части

2 3 4 5 6 7 8 9 10 11 12 13

  • OksanaKam 2

  • Анастасия 2 3

  • Heoh888 2 3

  • GitHub

    View Github