Как создать приложение для изучения языка с помощью Compose — часть 1

Как создать приложение для изучения языка с помощью Compose — часть 1

23 января 2023 г.

Это первая статья из новой серии, в которой я расскажу о своем пути создания приложения для изучения языка с помощью Jetpack Compose.

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

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

Если вы младший инженер, эта серия статей покажет вам мой процесс — как я прошел путь от ничего до создания приложения, готового к публикации в Play Store. Если вы старший инженер, мы надеемся, что эта серия статей научит вас чему-то новому о Jetpack Compose и даже заставит вас переосмыслить способы решения некоторых проблем.

Фон

Я назвал это приложение Lingua, что означает "язык" на латыни. Предполагается, что это будет комбинация Duolingo и Anki, а также некоторые другие вещи, которые, как мне кажется, будут полезны.

Одна из моих целей на 2023 год — выучить итальянский, и мне также нужно потратить часть своего времени на активное создание чего-то, а не просто пассивное изучение новых вещей. Я много читал о Compose, теперь пришло время применить эти знания на практике (позже я понял, что на самом деле очень мало знал о Compose).

Если я получу версию, которая выглядит хорошо и мне кажется, что она имеет смысл, я опубликую ее в Play Store.

Это первый черновик, который я создал в Figma; она далека от окончательной версии, но ведет меня в правильном направлении.

Initial draft as of 01/09/2023

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

Я разобью свои обновления по дням, чтобы вы имели разумное представление о том, сколько времени ушло на это создание. Я трачу на это около 1 часа в день.

Инструменты и библиотеки

Чтобы отслеживать свои задачи, я использую Notion. Для дизайна я использую Figma.

Я использую Kotlin и Jetpack Compose. Я буду упоминать другие библиотеки по мере их появления.

День 1

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

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

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

И, наконец, я хочу показать список курсов/колод, на которые подписан пользователь. С этого момента я буду называть эти карты «колодами», но название может измениться в будущем.

Home screen initial design

Мои экраны делятся на маршрут и экран. В приведенном выше примере я создал HomeRoute и HomeScreen. Маршрут — это то, что добавляется к навигационному графику, экрану и реальному визуальному контенту.

Здесь у нас есть основной NavHost для приложения.

NavHost(navController = navController, startDestination = Routes.Home.route) {
    composable(Routes.Home) { HomeRoute(navController) }
}

А здесь HomeRoute, определяющий главный экран.

@Composable
fun HomeRoute(
    navController: NavController,
) {
    HomeScreen(...)
}

Сейчас я просто использую NavController, но, возможно, абстрагируюсь от этого позже. Для маршрутов я только что определил запечатанный класс с моими маршрутами, на данный момент это соответствует моим потребностям, но я, вероятно, изменю это в будущем.

sealed class Routes(val route: String) {
    object Home : Routes("/")
}

Имейте в виду, что это мое первое «большое» приложение с Compose, поэтому я обязательно буду делать ошибки и учиться по ходу дела.

Затем я создал другие компоненты, но пока просто присваиваю им жестко запрограммированные значения.

Стоит упомянуть, как я добился обрезки изображений флагов. Я использую катушку. У меня не было возможности обрезать изображение непосредственно, поэтому я обернул его Surface и определил закругленный угол только для нижнего конечного угла, потому что верхний начальный угол уже обрезан картой.

Я также установил соотношение сторон 16/9, но я не на 100% доволен результатом.

Surface(
    shape = RoundedCornerShape(bottomEnd = 8.dp),
) {
    AsyncImage(
        model = model.imageUrl,
        contentDescription = "Icon",
        // TODO: add placeholder
        contentScale = ContentScale.FillBounds,
        modifier = Modifier
            .width(36.dp)
            .aspectRatio(16 / 9f)
    )
}

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

Progress by the end of day 1

День 2

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

У него также есть кнопка, с помощью которой пользователь может создать новую колоду.

Library initial design

Вот чего я добился к концу второго дня, ничего особенного. Просто LazyColumn и FloatingActionButton. На самом деле я также создал ViewModel для этого экрана, поэтому мне просто нужно подключить его к источнику данных позже, и будут перечислены правильные данные.

Progress by the end of day 2

День 3

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

Как вы можете видеть ниже, этот экран не так прост, как другие.

Во-первых, вы можете назвать свою колоду, я сначала создам ее.

После этого вы можете изменить его на общедоступный/приватный, я оставлю это на потом. Сейчас все будет общедоступно.

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

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

Например, английское слово Bee на итальянском означает Ape, поэтому оно будет выглядеть примерно так: Bee <-> обезьяна Это позволяет мне делать несколько вещей:

  1. Покажите слово «Пчела», и пользователь должен ввести «Обезьяна».
  2. Покажите слово «Обезьяна», и пользователь должен ввести «Пчела».
  3. Если слов больше, я могу показать что-то вроде "Обезьяна", и пользователь должен будет выбрать слово из списка, например ("Яблоко", "Пчела", "Пирог").

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

Наконец, у нас есть кнопка для добавления новых карточек и кнопка сохранения для сохранения всего.

Create Deck initial design

Я столкнулся с несколькими проблемами, например, как передать идентификатор колоды на этот экран? Я использую один и тот же экран для добавления и редактирования колод, поэтому идентификатор должен быть необязательным параметром.

Я решил эту проблему, создав новый объект в Routes и добавив методы createRoute и parse.

object EditDeck : Routes("/deck/{id}/edit") {
    fun createRoute(deckId: String?) = "/deck/$deckId/edit"
    fun parse(bundle: Bundle?): String? = bundle?.getString("id")?.takeIf { it != "null" }
}

И в свои маршруты я добавил новый маршрут.

composable(Routes.EditDeck) {
    val deckId = Routes.EditDeck.parse(it.arguments)
    EditDeckRoute(navController = navController, deckId = deckId)
}

Затем, когда мой экран создан, я использую LaunchedEffect для загрузки колоды в ViewModel.

@Composable
fun EditDeckRoute(
    navController: NavController,
    deckId: String?,
    viewModel: EditDeckViewModel = hiltViewModel()
) {
    LaunchedEffect(deckId ?: "none") {
        viewModel.loadDeck(deckId)
    }

    EditDeckScreen(...)
}

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

К концу третьего дня я построил лишь небольшую часть этого экрана.

Progress by the end of day 3

День 4

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

Пока я буду разрабатывать только тип «Текст» или, как я его называл ранее, Input <-> Вывод. Он будет содержать одно поле для ввода и одно поле для вывода (мне нужно придумать лучшие названия для этих вещей, лол).

Позже я также хочу добавить тип «Информация», который работает немного по-другому, но это на потом.

Create card initial draft

Этот экран очень похож на предыдущий, у меня есть EditDeckCardRoute и

EditDeckCardScreen. У нас также есть 2 ввода текста и FAB для сохранения карты.

Одно отличие состоит в том, что для создания карты мне нужен идентификатор колоды, а для изменения карты мне нужен идентификатор карты. Чтобы решить эту проблему, я создал 2 маршрута: EditCard и AddCard.

composable(Routes.AddCard) {
    val deckId = Routes.AddCard.parse(it.arguments)
    EditCardRoute(navController, deckId = deckId)
}

composable(Routes.EditCard) {
    val cardId = Routes.EditCard.parse(it.arguments)
    EditCardRoute(navController, cardId = cardId)
}

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

Поэтому при создании ViewModel он либо вызывает функцию для создания новой карты, либо функцию для загрузки существующей карты.

fun load(deckId: String?, cardId: String?) {
    when {
        deckId == null && cardId == null -> {
            Logger.e("deckId and cardId are null")
            // navigate up
        }
        deckId != null -> createNewCard(deckId)
        cardId != null -> loadCard(cardId)
    }
}

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

Progress by the end of day 4


В этом обновлении мы рассмотрели первые 4 дня работы этого приложения. Я поделился тем, что я буду строить и почему. Я также описал, что я строил каждый день, и немного о том, как это было построено.

Мне очень нравится эта новая серия, и я надеюсь, что вам тоже. Если вам понравилось это обновление, поделитесь им с друзьями и коллегами, ….

Следите за обновлениями.

Фото на обложке Келли Сиккема на Unsplash.


Также опубликовано здесь.


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE