1:- Add the relevant library
Add library for Retrofit
// Retrofit
implementation ("com.squareup.retrofit2:retrofit:2.9.0")
// OkHttp
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.2")
// JSON Converter
implementation ("com.squareup.retrofit2:converter-gson:2.9.0")
Add library for Hilt
Plugin
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
Dependencies
kapt ("com.google.dagger:hilt-compiler:2.48")
implementation ("androidx.hilt:hilt-navigation-compose:1.0.0-alpha03")// if you are using navigation
implementation ("com.google.dagger:hilt-android:2.48")
In top-level module
id ("com.google.dagger.hilt.android") version "2.48" apply false
For Navigation
implementation(libs.androidx.navigation.compose)
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
navigationCompose = "2.7.7"
For Coroutine
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.5.2")
For Coroutine Lifecycle Scopes
implementation ("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.8.5")
For Material Icon
implementation ("androidx.compose.material:material:1.0.4")
implementation ("androidx.compose.material:material-icons-extended:1.0.4")
For image loading
implementation("io.coil-kt:coil-compose:1.4.0")
2:- Create a Base Class for hilt
@HiltAndroidApp
class BaseApp:Application() {
}
3:- Create an interface and annotate with @Singleton
@Singleton
interface ApiInterface {
@GET("volumes")
suspend fun getAllBooks(@Query("q") query: String): Book
@GET("volumes/{bookId}")
suspend fun getBookDetails(@Path("bookId") bookId: String): Item
}
3:- Create app module to provide retrofit instance, Reositiry, and API interface instance
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Singleton
@Provides
fun provideRetrofit():Retrofit = Retrofit.Builder()
.baseUrl(Constants.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideApiInterface(retrofit: Retrofit): ApiInterface = retrofit.create(ApiInterface::class.java)
@Provides
@Singleton
fun provideDataRepository(apiService: ApiInterface): BookRepository {
return RepoImplementation(apiService)
}
}
4:- Create Repository Interface
interface BookRepository {
suspend fun getAllBook(searchQuery: String): Results<List<Item>>
suspend fun getBookDetails(bookId:String):Results<Item>
}
5:-Create repository Implementation
class RepoImplementation @Inject constructor(private val apiInterface: ApiInterface):BookRepository{
override suspend fun getAllBook(searchQuery: String): Results<List<Item>> {
return try {
Results.Loading(data = true)
val data = apiInterface.getAllBooks(searchQuery).items
Results.Success(data = data)
} catch (e:Exception)
{
Results.Error(msg = e.message.toString())
} }
override suspend fun getBookDetails(bookId: String): Results<Item> {
return try {
Results.Loading(data = true)
val data = apiInterface.getBookDetails(bookId)
Log.e("NetworkData", "data $data", )
Results.Success(data = data)
} catch (e:Exception)
{
Results.Error(msg = e.message.toString())
} }
}
6:-Create ViewModel
@HiltViewModel
class BookListViewModel @Inject constructor(private val repository: BookRepository)
: ViewModel() {
var list: List<Item> by mutableStateOf(listOf())
var isLoading: Boolean by mutableStateOf(true)
init {
loadBooks()
}
private fun loadBooks() {
searchBooks("android")
}
fun searchBooks(query: String) {
viewModelScope.launch(Dispatchers.Default) {
if (query.isEmpty()){
return@launch
}
val response = repository.getAllBook(query)
Log.e("DATA", "${response.data}", )
try {
when(val response = repository.getAllBook(query)) {
is Results.Success -> {
list = response.data!!
if (list.isNotEmpty()) isLoading = false
Log.e("NetworkData", "${list[0].volumeInfo.imageLinks.smallThumbnail}", )
}
is Results.Error -> {
isLoading = false
Log.e("Network", "searchBooks: Failed getting books", )
}
else -> {isLoading = false}
}
}catch (exception: Exception){
isLoading = false
Log.d("Network", "searchBooks: ${exception.message.toString()}")
}
}
}
}
7:- Create a class to store api state
sealed class Results<T>(val data:T? = null, val msg:String?=null) {
class Success<T>(data: T): Results<T>(data)
class Error<T>(msg:String,data: T?=null):Results<T>(data,msg)
class Loading<T>(data: T?= null):Results<T>(data)
}
8:- Now comes to the UI part
1:- Create a Navigation graph to navigation
@Composable
fun ComposeNavGraph() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = BookScreens.ListScreen.name
) {
// Define your ListScreen route
composable(BookScreens.ListScreen.name) {
BookListScreen(navController = navController)
}
// Define your DetailsScreen route
val detailName = BookScreens.DetailsScreen.name
composable(
route = "$detailName/{bookId}",
arguments = listOf(navArgument("bookId") {
type = NavType.StringType
})
) { backStackEntry ->
val bookId = backStackEntry.arguments?.getString("bookId")
val detailsViewModel = hiltViewModel<DetailsViewModel>()
ReaderBookDetailsScreen(
navController = navController,
bookId = bookId.orEmpty(),
viewModel = detailsViewModel
)
}
}
}
enum class BookScreens {
ListScreen,
DetailsScreen;
companion object{
fun fromRoute(rounte:String):BookScreens
= when (rounte?.substringBefore("/")){
ListScreen.name ->ListScreen
DetailsScreen.name ->DetailsScreen
null ->ListScreen
else ->throw IllegalArgumentException("Route $rounte is not recognized")
}
}
}
2:- Create List screen
@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
@Composable
fun BookListScreen(
navController: NavHostController,
viewModel: BookListViewModel = hiltViewModel()
) {
Scaffold(topBar = { SimpleTopBar("Book List") }) {
Surface() {
Column {
SearchForm(
modifier = Modifier
.fillMaxWidth()
.padding(6.dp),
viewModel = viewModel
) { searchQuery ->
viewModel.searchBooks(query = searchQuery)
}
Spacer(modifier = Modifier.height(13.dp))
BookList(navController = navController)
}
}
}
}
@Composable
fun BookList(navController: NavHostController, viewModel: BookListViewModel = hiltViewModel()) {
var list = viewModel.list
if (viewModel.isLoading) {
CenteredLoadingIndicator()
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp)
) {
items(items = list) { book ->
BookRow(book, navController)
}
}
}
}
@Composable
fun BookRow(book: Item, navController: NavHostController) {
Card(
modifier = Modifier
.clickable {
navController.navigate(BookScreens.DetailsScreen.name + "/${book.id}")
}
.fillMaxWidth()
.height(100.dp)
.padding(3.dp),
shape = RectangleShape,
elevation = 7.dp
) {
Row(
modifier = Modifier
.padding(5.dp),
verticalAlignment = Alignment.Top
) {
val imageUrl: String = if (book.volumeInfo.imageLinks.smallThumbnail.isEmpty())
"https://images.unsplash.com/photo-1541963463532-d68292c34b19?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=80&q=80"
else {
book.volumeInfo.imageLinks.smallThumbnail
}
Image(
painter = rememberImagePainter(data = imageUrl),
contentDescription = "book image",
modifier = Modifier
.width(80.dp)
.fillMaxHeight()
.padding(end = 4.dp),
)
Column {
Text(text = book.volumeInfo.title.toString(), overflow = TextOverflow.Ellipsis)
Text(
text = "Author: ${book.volumeInfo.authors}",
overflow = TextOverflow.Clip,
fontStyle = FontStyle.Italic,
style = MaterialTheme.typography.caption
)
Text(
text = "${book.volumeInfo.categories}",
overflow = TextOverflow.Clip,
fontStyle = FontStyle.Italic,
style = MaterialTheme.typography.caption
)
}
}
}
}
@Composable
fun SearchForm(
modifier: Modifier = Modifier,
viewModel: BookListViewModel,
loading: Boolean = false,
hint: String = "Search",
onSearch: (String) -> Unit = {}
) {
Column {
val searchQueryState = rememberSaveable { mutableStateOf("") }
val keyboardController = LocalSoftwareKeyboardController.current
val valid = remember(searchQueryState.value) {
searchQueryState.value.trim().isNotEmpty()
}
InputField(valueState = searchQueryState,
labelId = "Search",
enabled = true,
onAction = KeyboardActions {
if (!valid) return@KeyboardActions
onSearch(searchQueryState.value.trim())
searchQueryState.value = ""
keyboardController?.hide()
})
}
}
3:- Main Activity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TestTheme {
screensNavigation()
}
}
}
}
@Composable
fun screensNavigation(){
Surface (
color = MaterialTheme.colorScheme.background,
modifier = Modifier.fillMaxSize(),
content = {
Column (verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally)
{
ComposeNavGraph()
}
}
)
}