Skip to content
Snippets Groups Projects
Commit b11fa960 authored by Baryshnikov Oleg's avatar Baryshnikov Oleg
Browse files

Add the hilt, retrofit, room and paging 3 libraries.

Add the api and repository classes.
Add dependency injection.
Create new packages and move some classes to them.
Implement characters list loading.
parent a39f7f98
Branches master
No related tags found
No related merge requests found
Showing
with 498 additions and 44 deletions
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'com.google.dagger.hilt.android'
id 'kotlin-kapt'
}
android {
......@@ -24,6 +26,7 @@ android {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.debug
}
}
compileOptions {
......@@ -71,10 +74,39 @@ dependencies {
// Compose integration with RxJava
implementation 'androidx.compose.runtime:runtime-rxjava2'
//splash screen
// Splash screen
implementation "androidx.core:core-splashscreen:1.0.1"
// Navigation component
def nav_version = "2.6.0"
implementation "androidx.navigation:navigation-compose:$nav_version"
// DI
implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"
implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
// Retrofit
def retrofit_version = "2.9.0"
implementation "com.squareup.retrofit2:retrofit:%retrofit_version"
api "com.squareup.retrofit2:converter-gson:$retrofit_version"
// Room
def room_version = "2.5.2"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-paging:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// Compose paging library
implementation "androidx.paging:paging-runtime:3.1.1"
implementation "androidx.paging:paging-compose:3.2.0-rc01"
// Glide image loader
implementation "com.github.bumptech.glide:compose:1.0.0-alpha.1"
}
kapt {
correctErrorTypes true
}
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" >
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/app_logo"
android:label="@string/app_name"
android:roundIcon="@drawable/app_logo"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting"
tools:targetApi="31" >
android:name=".AckeeTestApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/app_logo"
android:label="@string/app_name"
android:roundIcon="@drawable/app_logo"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
......
package cz.fel.barysole.ackeetesttask
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class AckeeTestApp : Application()
\ No newline at end of file
......@@ -16,14 +16,15 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import cz.fel.barysole.ackeetesttask.ui.characterlist.CharacterList
import cz.fel.barysole.ackeetesttask.ui.characterlist.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.ui.mainuielements.MyNavigationBar
import cz.fel.barysole.ackeetesttask.ui.mainuielements.MyTopAppBar
import cz.fel.barysole.ackeetesttask.ui.navigation.Screen
import cz.fel.barysole.ackeetesttask.ui.navigation.appScreenList
import cz.fel.barysole.ackeetesttask.ui.mainuielement.MyNavigationBar
import cz.fel.barysole.ackeetesttask.ui.mainuielement.MyTopAppBar
import cz.fel.barysole.ackeetesttask.ui.screen.Screen
import cz.fel.barysole.ackeetesttask.ui.screen.appScreenList
import cz.fel.barysole.ackeetesttask.ui.screen.characterlist.CharacterListScreen
import cz.fel.barysole.ackeetesttask.ui.theme.AckeeTestTaskTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Handle the splash screen transition.
......@@ -57,15 +58,7 @@ fun MainScreen() {
Modifier.padding(innerPadding)
) {
composable(Screen.Characters.route) {
CharacterList(
listOf(
CharacterInfo(
"Rick",
"Sanchez",
true
), CharacterInfo("Morty", "", false)
)
)
CharacterListScreen()
}
composable(Screen.Favorite.route) { }
composable(Screen.CharacterDetail.route) { }
......
package cz.fel.barysole.ackeetesttask.api
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class AppApiModule {
@Singleton
@Provides
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl(ESHOP_SERVER_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
internal fun rickAndMortyApi(retrofit: Retrofit): RickAndMortyApi {
return retrofit.create(RickAndMortyApi::class.java)
}
companion object {
const val ESHOP_SERVER_URL = "https://rickandmortyapi.com/api/"
}
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.api
import cz.fel.barysole.ackeetesttask.api.response.CharacterListResponse
import retrofit2.http.GET
import retrofit2.http.Query
interface RickAndMortyApi {
/**
* Get a characters list by parameters.
*/
@GET("character")
suspend fun searchCharacters(
@Query("name") name: String,
@Query("page") page: Int,
): CharacterListResponse
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.api.response
import com.google.gson.annotations.SerializedName
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
/**
* Data class to hold character list and pagination info retrieved from the server.
*/
data class CharacterListResponse(
@SerializedName("info") val paginationInfo: PaginationInfo? = null,
@SerializedName("results") val items: List<CharacterInfo> = emptyList(),
)
package cz.fel.barysole.ackeetesttask.api.response
import com.google.gson.annotations.SerializedName
/**
* Holds server pagination info.
*/
data class PaginationInfo(
@SerializedName("count") val count: Int = 0,
@SerializedName("pages") val pages: Int = 0,
//if contains null then page does not exist
@SerializedName("next") val next: String? = null,
//if contains null then page does not exist
@SerializedName("prev") val prev: String? = null,
)
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName
/**
* Immutable model class for the Retrofit and the Room.
*/
@Entity(tableName = "repos")
data class CharacterInfo(
@PrimaryKey @field:SerializedName("id") val id: Long,
@field:SerializedName("name") val name: String?,
@field:SerializedName("status") val status: String?,
@field:SerializedName("image") val image: String?,
)
package cz.fel.barysole.ackeetesttask.repository
import cz.fel.barysole.ackeetesttask.repository.characters.CharacterRepository
import cz.fel.barysole.ackeetesttask.repository.characters.CharacterRepositoryImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class RepositoryModule {
@Singleton
@Provides
fun provideCharacterRepository(characterRepositoryImpl: CharacterRepositoryImpl): CharacterRepository {
return characterRepositoryImpl
}
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.repository.characters
import androidx.paging.PagingData
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import kotlinx.coroutines.flow.Flow
interface CharacterRepository {
fun getCharactersSearchResultStream(query: String): Flow<PagingData<CharacterInfo>>
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.repository.characters
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import cz.fel.barysole.ackeetesttask.api.RickAndMortyApi
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CharacterRepositoryImpl @Inject constructor(
private val rickAndMortyApi: RickAndMortyApi
) : CharacterRepository {
override fun getCharactersSearchResultStream(query: String): Flow<PagingData<CharacterInfo>> {
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = { CharactersPagingSource(rickAndMortyApi, query) }
).flow
}
companion object {
//based on this description - https://rickandmortyapi.com/documentation/#info-and-pagination
const val NETWORK_PAGE_SIZE = 20
}
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.repository.characters
import androidx.paging.PagingSource
import androidx.paging.PagingState
import cz.fel.barysole.ackeetesttask.api.RickAndMortyApi
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import retrofit2.HttpException
import java.io.IOException
class CharactersPagingSource(
private val rickAndMortyApi: RickAndMortyApi,
private val apiQuery: String
) : PagingSource<Int, CharacterInfo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CharacterInfo> {
val pageNumber = params.key ?: STARTING_PAGE_NUMBER
return try {
val response = rickAndMortyApi.searchCharacters(apiQuery, pageNumber)
val nextKey = if (response.paginationInfo?.next == null) null else pageNumber + 1
LoadResult.Page(
data = response.items,
prevKey = if (response.paginationInfo?.prev == null) null else pageNumber - 1,
nextKey = nextKey
)
} catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
return LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, CharacterInfo>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
companion object {
const val STARTING_PAGE_NUMBER = 0;
}
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.ui.characterlist
import androidx.lifecycle.ViewModel
class CharacterListViewModel : ViewModel() {
}
\ No newline at end of file
package cz.fel.barysole.ackeetesttask.ui.characterlist.model
data class CharacterInfo(val firstName: String, val secondName: String, val isAlive: Boolean)
package cz.fel.barysole.ackeetesttask.ui.mainuielements
package cz.fel.barysole.ackeetesttask.ui.mainuielement
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
......@@ -14,7 +14,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import cz.fel.barysole.ackeetesttask.ui.navigation.Screen
import cz.fel.barysole.ackeetesttask.ui.screen.Screen
@Composable
......
package cz.fel.barysole.ackeetesttask.ui.mainuielements
package cz.fel.barysole.ackeetesttask.ui.mainuielement
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
......@@ -15,8 +15,8 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cz.fel.barysole.ackeetesttask.ui.navigation.Screen
import cz.fel.barysole.ackeetesttask.ui.navigation.screenWithTopAppBarList
import cz.fel.barysole.ackeetesttask.ui.screen.Screen
import cz.fel.barysole.ackeetesttask.ui.screen.screenWithTopAppBarList
//Main screen contains the TopAppBar, which is experimental and is likely to change or to be removed in the future.
......
package cz.fel.barysole.ackeetesttask.ui.navigation
package cz.fel.barysole.ackeetesttask.ui.screen
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
......
package cz.fel.barysole.ackeetesttask.ui.characterlist
package cz.fel.barysole.ackeetesttask.ui.screen.characterlist
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
......@@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
......@@ -18,25 +16,42 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cz.fel.barysole.ackeetesttask.R
import cz.fel.barysole.ackeetesttask.ui.characterlist.model.CharacterInfo
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
@Composable
fun CharacterList(characterList: List<CharacterInfo>) {
fun CharacterListScreen(characterListViewModel: CharacterListViewModel = hiltViewModel()) {
Surface(
color = MaterialTheme.colorScheme.background
) {
LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) {
items(characterList) { character ->
CharacterItem(character)
characterListViewModel.pagingDataFlow.collectAsLazyPagingItems().let {
CharacterList(it)
}
}
}
@Composable
fun CharacterList(characterList: LazyPagingItems<CharacterInfo>) {
LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) {
items(
count = characterList.itemCount,
key = { characterList[it]?.id ?: it },
contentType = { if (characterList[it] == null) 1 else 0 }
) { index ->
characterList[index]?.let {
CharacterItem(it)
}
}
}
}
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun CharacterItem(character: CharacterInfo) {
Surface(
......@@ -47,35 +62,54 @@ fun CharacterItem(character: CharacterInfo) {
) {
Row(
modifier = Modifier
.padding(all = 12.dp)
.padding(all = 10.dp)
.fillMaxWidth(1f),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.app_logo),
contentDescription = "Character image",
modifier = Modifier
.size(40.dp)
.clip(MaterialTheme.shapes.small)
)
character.image?.let {
GlideImage(
model = it,
contentDescription = "Character image",
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.small)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "${character.firstName} ${character.secondName}",
text = "${character.name}",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (character.isAlive) "Alive" else "Not alive",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
character.status?.let {
Text(
text = character.status,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
\ No newline at end of file
}
/*
@Preview
@Composable
fun CharacterListPreview() {
CharacterList(
(
CharacterInfo(
"1",
"Rick Sanchez",
"Alive"
), CharacterInfo("2", "Morty", "Alive")
)
)
}*/
package cz.fel.barysole.ackeetesttask.ui.screen.characterlist
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import cz.fel.barysole.ackeetesttask.model.CharacterInfo
import cz.fel.barysole.ackeetesttask.repository.characters.CharacterRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class CharacterListViewModel @Inject constructor(
private val characterRepository: CharacterRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// The UI state
val uiState: StateFlow<UiState>
val pagingDataFlow: Flow<PagingData<CharacterInfo>>
/**
* Processor of side effects from the UI which in turn feedback into [uiState]
*/
val accept: (UiAction) -> Unit
init {
val initialQuery: String = savedStateHandle[LAST_SEARCH_QUERY] ?: ""
val lastQueryScrolled: String = savedStateHandle[LAST_QUERY_SCROLLED] ?: ""
val actionStateFlow = MutableSharedFlow<UiAction>()
val searches = actionStateFlow
.filterIsInstance<UiAction.Search>()
.distinctUntilChanged()
.onStart { emit(UiAction.Search(query = initialQuery)) }
val queriesScrolled = actionStateFlow
.filterIsInstance<UiAction.Scroll>()
.distinctUntilChanged()
// This is shared to keep the flow "hot" while caching the last query scrolled,
// otherwise each flatMapLatest invocation would lose the last query scrolled,
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
replay = 1
)
.onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
pagingDataFlow = searches
.flatMapLatest { searchCharacter(queryString = it.query) }
.cachedIn(viewModelScope)
uiState = combine(
searches,
queriesScrolled,
::Pair
).map { (search, scroll) ->
UiState(
query = search.query,
lastQueryScrolled = scroll.currentQuery,
// If the search query matches the scroll query, the user has scrolled
hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = UiState()
)
accept = { action ->
viewModelScope.launch { actionStateFlow.emit(action) }
}
}
private fun searchCharacter(queryString: String): Flow<PagingData<CharacterInfo>> =
characterRepository.getCharactersSearchResultStream(queryString)
override fun onCleared() {
savedStateHandle[LAST_SEARCH_QUERY] = uiState.value.query
savedStateHandle[LAST_QUERY_SCROLLED] = uiState.value.lastQueryScrolled
super.onCleared()
}
}
sealed class UiAction {
data class Search(val query: String) : UiAction()
data class Scroll(val currentQuery: String) : UiAction()
}
data class UiState(
val query: String = "",
val lastQueryScrolled: String = "",
val hasNotScrolledForCurrentSearch: Boolean = false
)
private const val LAST_SEARCH_QUERY: String = "last_search_query"
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment