Доброго времени суток всем читателям Хабр.

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

В данной статье я хотел бы разобрать тестовое задание на позицию Android developer и продемонстрировать свой способ решения.

Начнем с ознакомления с ТЗ задания

Скрин ТЗ
Скрин ТЗ

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

Начнем с того что в данном ТЗ кроме требований к языку (код должен быть написан на Kotlin), есть требования к архитектуре приложения (MVVM или MVP), так же сразу становится понятно, что нам понадобится список (не простой список конечно, нужна будет сортировка по дате, об этом я напишу далее) и в этом списке мы будем отображать GET запрос, который получим по API. Что же, на первый взгляд ТЗ не выглядит сложным, из интересного это сортировка по дате. Теперь можно приступать к решению! 

Шаг 1. Структура

Для начала предлагаю подумать об архитектуре

Структура проекта будет следующая

ui – в этой папке будет храниться все что связано с ui интерфейсом: MainFragment – фрагмент где будет отображаться список, MainViewModelFactory – фабрика для создания экземпляра ViewModelTraining. ViewModelTraining – вью модель для работы с данными и отображения в MainFragment.

adapter – в этой паке будет класс TrainingAdapter, который отвечает за связь данных с ui

api – в данной паке будет BASE_URL,  интерфейс ApiService с GET запросом, а так же ApiResult с обработкой результатов работы с API.

data -  в этой паке будут модели, которые мы будем отображать с помощью GET запроса.

di -  в этой папке будет внедрение зависимостей

Шаг 2. Реализация

Теперь приступаем к самому интересному, к реализации.

  1. Подключаем библиотеки. Нам понадобятся:

viewBinding - для облегчения доступа к элементам пользовательского интерфейса.

 buildFeatures {
        viewBinding true
    }

Lifecycle - чтобы обеспечить более удобное и надежное управление жизненным циклом компонентов.

    // Lifecycle
    def lifecycleVersion = "2.4.1"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"

Coroutines -  для работы с асинхронным кодом.

    // Coroutines
    def coroutinesVersion = "1.6.4"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"

Retrofit - для работы с API.

    // Retrofit
    def retrofitVersion = "2.9.0"
    implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
    implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"

Navigation Components - в данном случае не обязательно, но обычно я использую именно Navigation Components для работы с fragments.

    // Navigation Components
    def navVersion = "2.5.3"
    implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
    implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
  1. Верстаем разметку.

В данном приложении будет single activity, поэтому activity_main будет содержать FragmentContainerView.

С фрагментами, на мой взгляд, лучше использовать navigation, поскольку это более лаконично и позволит масштабировать приложение, если это понадобится

 <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:defaultNavHost = "true"
        app:navGraph="@navigation/nav_graph"
        app:layout_constraintBottom_toTopOf="@id/bottom_nav_menu"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        />

Кроме этого, в примере присутствует BottomNavigationView без возможности перехода на другие Fragments, так же реализуем ее в нашем activity_main.

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/bottom_nav_menu"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        app:menu="@menu/bottom_nav_menu"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

Далее fragment_main где будет список, для списка используем recyclerview.

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/training_adapter"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="12dp"
        android:clipToPadding="false"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        tools:ignore="MissingConstraints" />

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

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="10dp"
    android:layout_margin="8dp"
    xmlns:app="http://schemas.android.com/apk/res-auto">

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/frame">

    <TextView
        android:id="@+id/startTime"
        android:layout_width="50dp"
        android:layout_height="20dp"
        android:layout_marginStart="16dp"
        android:layout_marginTop="32dp"
        android:text="10:00"
        app:layout_constraintStart_toEndOf="@+id/color"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/lastTime"
        android:layout_width="50dp"
        android:layout_height="20dp"
        android:layout_marginStart="16dp"
        android:layout_marginTop="16dp"
        android:text="11:00"
        app:layout_constraintStart_toEndOf="@+id/color"
        app:layout_constraintTop_toBottomOf="@+id/startTime" />

    <TextView
        android:id="@+id/trening"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="32dp"
        android:text="Силовая тренировка"
        app:layout_constraintStart_toEndOf="@+id/startTime"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/trainingTime"
        android:layout_width="100dp"
        android:layout_height="20dp"
        android:layout_marginTop="32dp"
        android:layout_marginEnd="32dp"
        android:text="1ч. 15мин."
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/name"
        android:layout_width="150dp"
        android:layout_height="20dp"
        android:layout_marginTop="16dp"
        android:text="Григорьев Ярослав"
        app:layout_constraintStart_toEndOf="@+id/profile"
        app:layout_constraintTop_toBottomOf="@+id/trening" />

    <TextView
        android:id="@+id/hall"
        android:layout_width="100dp"
        android:layout_height="20dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="16dp"
        android:text="Студия 2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/geo"
        app:layout_constraintTop_toBottomOf="@+id/trainingTime" />

    <ImageView
        android:id="@+id/profile"
        android:layout_width="20dp"
        android:layout_height="20dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="16dp"
        android:background="@drawable/ic_person"
        app:layout_constraintStart_toEndOf="@+id/lastTime"
        app:layout_constraintTop_toBottomOf="@+id/trening" />

    <ImageView
        android:id="@+id/geo"
        android:layout_width="20dp"
        android:layout_height="20dp"
        android:layout_marginStart="4dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="7dp"
        android:background="@drawable/ic_location"
        app:layout_constraintEnd_toStartOf="@+id/hall"
        app:layout_constraintStart_toEndOf="@+id/name"
        app:layout_constraintTop_toBottomOf="@+id/trainingTime" />

    <TextView
        android:id="@+id/color"
        android:layout_width="5dp"
        android:layout_height="100dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>


</androidx.cardview.widget.CardView>

На макете это будет выглядеть примерно так:

Кроме этого нужен header_item для отображения даты

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/date"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="20dp"
        android:layout_gravity="center_vertical"
        android:textColor="@color/gray"/>

</FrameLayout>

MainActivity код будет выглядеть так:

class MainActivity : AppCompatActivity() {

    private var _binding: ActivityMainBinding? = null
    private val mBinding get() = _binding!!

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        _binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(mBinding.root)
        val navHostFragment =
            supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
        val navController = navHostFragment.navController
        mBinding.bottomNavMenu.setupWithNavController(navController)
    }

    override fun onDestroy() {
        super.onDestroy()
        _binding = null
    }
}

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

  1. С помощью расширения Kotlin data class File from JSON создаем дата классы для модели.

data class Training(
    @SerializedName("lessons")
    val lessons: List<Lesson>,
    @SerializedName("option")
    val option: Option,
    @SerializedName("tabs")
    val tabs: List<Tab>,
    @SerializedName("trainers")
    val trainers: List<Trainer>
)
data class Trainer(
    @SerializedName("description")
    val description: String,
    @SerializedName("full_name")
    val fullName: String,
    @SerializedName("id")
    val id: String,
    @SerializedName("image_url")
    val imageUrl: String,
    @SerializedName("image_url_medium")
    val imageUrlMedium: String,
    @SerializedName("image_url_small")
    val imageUrlSmall: String,
    @SerializedName("last_name")
    val lastName: String,
    @SerializedName("name")
    val name: String,
    @SerializedName("position")
    val position: String
)
data class Tab(
    @SerializedName("id")
    val id: Int,
    @SerializedName("name")
    val name: String
)
data class Option(
    @SerializedName("club_name")
    val clubName: String,
    @SerializedName("link_android")
    val linkAndroid: String,
    @SerializedName("link_ios")
    val linkIos: String,
    @SerializedName("primary_color")
    val primaryColor: String,
    @SerializedName("secondary_color")
    val secondaryColor: String
)
data class Lesson(
    @SerializedName("appointment_id")
    val appointmentId: String,
    @SerializedName("available_slots")
    val availableSlots: Int,
    @SerializedName("client_recorded")
    val clientRecorded: Boolean,
    @SerializedName("coach_id")
    val coachId: String,
    @SerializedName("color")
    val color: String,
    @SerializedName("commercial")
    val commercial: Boolean,
    @SerializedName("date")
    val date: String,
    @SerializedName("description")
    val description: String,
    @SerializedName("endTime")
    val endTime: String,
    @SerializedName("is_cancelled")
    val isCancelled: Boolean,
    @SerializedName("name")
    val name: String,
    @SerializedName("place")
    val place: String,
    @SerializedName("service_id")
    val serviceId: String,
    @SerializedName("startTime")
    val startTime: String,
    @SerializedName("tab")
    val tab: String,
    @SerializedName("tab_id")
    val tabId: Int
)

enum class TrainingType понадобится в будущем для работы в адаптере.

 4. Внедряем зависимости, поскольку приложение небольшое, я решил не использовать Hilt.

class App : Application() {

    private lateinit var viewModelFactory: MainViewModelFactory
    private lateinit var retrofit: ApiService

    override fun onCreate() {
        super.onCreate()
        retrofit = Retrofit.Builder()
            .baseUrl(RetrofitClient.BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build().create(ApiService::class.java)
        viewModelFactory = MainViewModelFactory(retrofit)
    }

    fun <T : ViewModel> provideViewModel(clazz: Class<T>, owner: ViewModelStoreOwner) =
        ViewModelProvider(owner, viewModelFactory)[clazz]
}

Данный код создает экземпляры retrofit и viewModelFactory в методе onCreate(), и предоставляет экземпляры ViewModel через функцию provideViewModel().

В классе App мы используем ApiService и MainViewModelFactor, создадим их немного позже

Не забываем указать App в манифесте.

      android:name=".di.App"

 5. Пишем ретрофит.

Тут все стандартно, интерфейс с запросом, обработка ответов и BASE_URL, напомню, билдер находится в App. Так же нужно указать работу с сетью в манифесте.

class RetrofitClient {
    companion object {
        val BASE_URL = "https://olimpia.fitnesskit-admin.ru/"
    }
}

В классе RetrofitClient указываем BASE_URL

interface ApiService {

    @GET("schedule/get_v3/?club_id=2")
    suspend fun getTrainingList(): Response<Training>
}

В интерфейсе ApiService пишем GET запрос который будет возвращать Response<Training>

sealed class ApiResult<out T>(
    val data: T?,
    val errorMessage: String?
) {

    class Success<out T>(_data: T?) : ApiResult<T>(
        data = _data,
        errorMessage = null
    )

    class Error<out T>(
        val exception: String
    ) : ApiResult<T>(
        data = null,
        errorMessage = exception
    )
}

Класс ApiResult предоставляет общую структуру для представления результатов операций API. Подклассы Success и Error предоставляют специфические реализации для успешных результатов и ошибок соответственно.

    <uses-permission android:name="android.permission.INTERNET"/>

Обязательно указываем работу с сетью.

  1.  Пишем адаптер

Адаптер это часто используемый паттерн в андроид разработке он есть в любом приложении где есть список. В данном случае задача усложнена тем, что нам нужно отобразить не один listitem, а еще и header_item (обычно мы отображаем какой-то один список).

class TrainingAdapter(private val getTrainerById: (String) -> String) :
    RecyclerView.Adapter<TrainingAdapter.TrainingViewHolder>() {

    class TrainingViewHolder(view: View) : RecyclerView.ViewHolder(view)

    private var training: List<LessonEntity> = emptyList()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrainingViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val viewToInflate = when (viewType) {
            TrainingType.TRAIN.ordinal -> R.layout.listitem
            else -> R.layout.header_item
        }
        return TrainingViewHolder(layoutInflater.inflate(viewToInflate, parent, false))
    }

    override fun getItemViewType(position: Int): Int {
        return training[position].type.ordinal
    }

    override fun onBindViewHolder(holder: TrainingViewHolder, position: Int) {
        val item = training[position]
        if (item.type == TrainingType.TRAIN) {
            with(holder.itemView) {
                findViewById<TextView>(R.id.startTime).text = item.lesson?.startTime
                findViewById<TextView>(R.id.lastTime).text = item.lesson?.endTime
                findViewById<TextView>(R.id.trening).text = item.lesson?.name
                findViewById<TextView>(R.id.color).setBackgroundColor(Color.parseColor(item.lesson?.color))
                item.lesson?.coachId?.let {
                    findViewById<TextView>(R.id.name).text = getTrainerById(it)
                }
            }
        } else {
            holder.itemView.findViewById<TextView>(R.id.date).text = item.header
        }
    }

    override fun getItemCount() = training.size

    @SuppressLint("NotifyDataSetChanged")
    fun setData(data: List<LessonEntity>) {
        training = data
        notifyDataSetChanged()
    }
}

В TrainingViewHolder в конструкторе которого будет не (val binding: ListitemBinding), а будет (view: View), таким образом мы сможем обращаться и к списку и к дате.

Метод onCreateViewHolder переопределен для создания экземпляра TrainingViewHolder и в зависимости от типа элемента, определенного в viewType. Возвращается экземпляр TrainingViewHolder, связанный с inflate.

Метод getItemViewType переопределен для получения типа элемента по его позиции в списке. Возвращается значение ordinal типа элемента TrainingType.

Метод onBindViewHolder переопределен для связывания данных с представлениями элементов списка. Если тип элемента TrainingType.TRAIN, то значения связываются с соответствующими представлениями (TextView) внутри TrainingViewHolder.itemView. Если тип элемента отличается от TrainingType.TRAIN, то значение связывается с TextView внутри TrainingViewHolder.itemView, которые отображают заголовок элемента.

Метод getItemCount возвращает количество элементов в списке training.

Метод setData используется для установки новых данных в адаптер и вызова notifyDataSetChanged(), чтобы обновить список.

  1. Отображаем все в MainFragment и сортируем список .

Для этого создадим класс MainViewModelFactory.

class MainViewModelFactory constructor(
    private val retrofit: ApiService
) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(ViewModelTraining::class.java)) {
            return ViewModelTraining(retrofit) as T
        }
        throw java.lang.IllegalArgumentException("Unknown class name")
    }
}

MainViewModelFactory используется для создания экземпляров ViewModelTraining с использованием ApiService в качестве зависимости.

Создаем ViewModelTraining.

class ViewModelTraining(private val retrofit: ApiService) : ViewModel() {

    var liveData = MutableLiveData<ApiResult<Training>>()

    init {
        loadListTraining()
    }

    fun loadListTraining() {
        viewModelScope.launch(Dispatchers.IO) {
            val result = retrofit.getTrainingList()
            if (result.isSuccessful) {
                liveData.postValue(ApiResult.Success(result.body()))
            } else liveData.postValue(ApiResult.Error("Not Loading"))
        }
    }

    fun getTrainerById(id: String): String {
        val trainer = liveData.value?.data?.trainers?.find {
            it.id == id
        }
        return trainer?.name ?: " "
    }
}

Внутри класса ViewModelTraining определена переменная liveData, которая является экземпляром MutableLiveData с параметризованным типом ApiResult<Training>. MutableLiveData предоставляет возможность наблюдать за изменениями данных и обновлять пользовательский интерфейс при их изменении.

В блоке init определен инициализационный блок, который вызывает метод loadListTraining(). Этот метод используется для загрузки списка тренировок и обновления значения liveData.

Метод loadListTraining() использует viewModelScope.launch для запуска корутины в контексте Dispatchers.IO. Внутри корутины выполняется запрос к методу getTrainingList() объекта retrofit. Если запрос успешен, то результат помещается в liveData с помощью liveData.postValue(ApiResult.Success(result.body())), иначе помещается сообщение об ошибке с помощью liveData.postValue(ApiResult.Error("Not Loading")).

Метод getTrainerById принимает аргумент id типа String и используется для получения имени тренера по его идентификатору. Внутри метода используется функция find для поиска тренера в списке данных liveData. Если тренер найден, то возвращается его имя, иначе возвращается пустая строка.

Создаем MainFragment

class MainFragment : Fragment() {

    private var _binding: FragmentMainBinding? = null
    private val mBinding get() = _binding!!
    private lateinit var viewModel: ViewModelTraining

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentMainBinding.inflate(layoutInflater, container, false)
        return mBinding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel =
            (activity?.application as App).provideViewModel(ViewModelTraining::class.java, this)

        val trainingAdapter = TrainingAdapter { viewModel.getTrainerById(it) }
        mBinding.trainingAdapter.adapter = trainingAdapter
        viewModel.liveData.observe(requireActivity()) {
            if (it is ApiResult.Success && it.data != null) {
                trainingAdapter.setData(sortLessons(it.data))
            } else {
                Toast.makeText(requireContext(), it.errorMessage, Toast.LENGTH_SHORT).show()
            }
        }
    }

    fun sortLessons(body: Training): ArrayList<LessonEntity> {
        val list = body.lessons.sortedByDescending {
            it.formatedDate
        }
        val map = emptyMap<String, ArrayList<Lesson>>().toMutableMap()
        list.forEach {
            if (map[it.dateWithDay] == null)
                map[it.dateWithDay] = ArrayList()
            map[it.dateWithDay]?.add(it)
        }

        val newList = ArrayList<LessonEntity>()
        map.forEach() { entry ->
            newList.add(LessonEntity(type = TrainingType.HEADER, null, entry.key))
            entry.value.mapTo(newList) {
                LessonEntity(type = TrainingType.TRAIN, it, null)
            }
        }
        return newList
    }

    override fun onDestroy() {
        super.onDestroy()
        _binding = null
    }
}

Создается приватное свойство _binding типа FragmentMainBinding?, которое используется для привязки разметки фрагмента. Создается свойство mBinding, которое возвращает _binding!!, чтобы обеспечить доступ к привязке фрагмента.

Создается свойство viewModel типа ViewModelTraining, которое будет использоваться для обработки бизнес-логики и управления данными, связанными с тренировками.

В методе onCreateView создается привязка разметки фрагмента с использованием _binding, который получает доступ к разметке фрагмента с помощью FragmentMainBinding.inflate(layoutInflater, container, false). Затем возвращается корневой вид разметки.

В методе onViewCreated происходит инициализация viewModel, используя provideViewModel из класса App, который предоставляет экземпляр ViewModelTraining с использованием activity?.application и передает текущий фрагмент this. Затем создается экземпляр TrainingAdapter, передавая лямбда-выражение viewModel.getTrainerById(it) в конструктор. Далее устанавливается адаптер для mBinding.trainingAdapter.adapter. Наблюдатель liveData в viewModel слушает изменения и обновляет UI в соответствии с результатами. Если результат является ApiResult.Success и данные не равны null, вызывается setData для trainingAdapter с отсортированными данными. В противном случае показывается Toast с сообщением об ошибке.

В классе определен метод sortLessons, который принимает объект Training и возвращает список LessonEntity. В этом методе происходит сортировка списка lessons по убыванию даты. Затем создается пустой изменяемый словарь map, куда добавляются элементы из списка lessons с группировкой по дате. Затем создается новый список newList, в который добавляются заголовки и тренировки из словаря map. Наконец, возвращается отсортированный список newList.

В методе onDestroy _binding устанавливается в null, чтобы избежать утечки памяти.

Все, задние готово! На эмуляторе выглядит приложение так:

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

Более подробно с кодом можете ознакомиться тут

ТГ для обратной связи: @AndrewNB

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


  1. ptyrss
    05.07.2023 20:27

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

    Вёрстку особо трогать не буду, но что там абсолютные значения высоты - а задумывались что будет если фамилия например длинная и не влезет в одну строку?

    ApiResult выглядит опасно, у вас можно быть в теории Success с null значением? Мне кажется это уже совсем не успех.

    Адаптер - смесь when и if выглядит странно, плюс у вас адаптер и холдер склеены в один класс.

    Куча мест где есть проверка на null выглядит странно, чисто для примера Color.parseColor(item.lesson?.color) что тут будет если цвет null?

    sortLessons выглядит максимально странно, создание мутабельного map куча конвертов данных, отдача изменяемого списка.


    1. Rusrst
      05.07.2023 20:27

      Да ладно, все с чего-то начинают :)

      Будет стараться все получится. Правда дизайн от реализации далёк конечно...


    1. AndreyBN95 Автор
      05.07.2023 20:27

      Отвечу на некоторые замечания

      По верстке да, есть такой недочет длинные ФИО налезают на соседнюю View.

      По поводу ApiResult, согласен, сейчас я использую другой способ обработки ответов сервера.

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

      sortLessons на момент написания тестового такой способ сортировки и заполнения мне казался хорошим решением.   

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


  1. avvsof2050
    05.07.2023 20:27

    Такие простые задания последнее время не встречались. Это из 2020 года. Сейчас разметка будет в разы сложнее, зададут какой нибудь вычурный макет в фигме, Dugger Hilt обязательно, сохранять в бд обязательно, модули по фичам, и несколько экранов с различными recycler'ами. И тд. Плюс к этому всё чаще Compose. А, тесты не забудь. Поиск какой нибудь извращенный. Архитектура - чистая, как слеза, туда же.


    1. AndreyBN95 Автор
      05.07.2023 20:27

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

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

      Остается надеяться что количество сделанных тестовых перерастет в качество и в приглашение на работу)


      1. avvsof2050
        05.07.2023 20:27

        не будешь решать большое тестовое, кто-нибудь другой решит.  

        Там сейчас килополя кандидатов на стажировку-Junior позиции. Требования примерно как раньше были к middle++. Работодатели могут "перебирать харчами", так как много (очень много) хороших кандидатов. Сейчас это больше выглядит, как нанять мидла за ЗП стажера, или вообще бесплатно для начала. Видел уже стажировки, где нужно самому кандидату платить за участие)))


        1. AndreyBN95 Автор
          05.07.2023 20:27

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