diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..8a34a9b
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+NewsApp
\ No newline at end of file
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
new file mode 100644
index 0000000..e6fee04
--- /dev/null
+++ b/.idea/deploymentTargetDropDown.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..b268ef3
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index ae388c2..0897082 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,16 +4,15 @@
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 9f71c83..8978d23 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,7 +1,6 @@
-
-
+
diff --git a/.idea/other.xml b/.idea/other.xml
new file mode 100644
index 0000000..0d3a1fb
--- /dev/null
+++ b/.idea/other.xml
@@ -0,0 +1,263 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index a45378b..d6ca32d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -21,6 +21,14 @@ android {
vectorDrawables {
useSupportLibrary true
}
+
+ Properties properties = new Properties()
+ properties.load(project.rootProject.file("local.properties").newDataInputStream())
+
+ buildConfigField("String","API_KEY",properties.getProperty("API_KEY"))
+ buildConfigField("String","NEWS_DATABASE_NAME",properties.getProperty("NEWS_DATABASE_NAME"))
+ buildConfigField("String","BASE_URL",properties.getProperty("BASE_URL"))
+
}
buildTypes {
@@ -38,6 +46,7 @@ android {
}
buildFeatures {
compose true
+ buildConfig true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.7"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 27cffd9..c6deea4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,8 +1,11 @@
+ xmlns:tools="http://schemas.android.com/tools">
+
+
+ android:theme="@style/App.Starting.Theme"
+ tools:targetApi="31">
+ android:theme="@style/App.Starting.Theme">
-
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/MainActivity.kt b/app/src/main/java/com/loc/newsapp/MainActivity.kt
index c001ad9..54c567d 100644
--- a/app/src/main/java/com/loc/newsapp/MainActivity.kt
+++ b/app/src/main/java/com/loc/newsapp/MainActivity.kt
@@ -3,21 +3,50 @@ package com.loc.newsapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import androidx.compose.foundation.layout.fillMaxSize
+import androidx.activity.viewModels
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.graphics.Color
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.WindowCompat
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import com.loc.newsapp.ui.navigation.graph.NavGraph
import com.loc.newsapp.ui.theme.NewsAppTheme
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+ private val viewModel by viewModels()
+
override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen().apply {
+ setKeepOnScreenCondition {
+ viewModel.splashCondition
+ }
+ }
super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
NewsAppTheme {
+ val isSystemInDarkMode = isSystemInDarkTheme()
+ val systemController = rememberSystemUiController()
+
+ SideEffect {
+ systemController.setSystemBarsColor(
+ color = Color.Transparent,
+ darkIcons = !isSystemInDarkMode
+ )
+ }
+
+ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ val startDestination = viewModel.startDestination
+ NavGraph(startDestination = startDestination)
+ }
}
}
}
diff --git a/app/src/main/java/com/loc/newsapp/MainViewModel.kt b/app/src/main/java/com/loc/newsapp/MainViewModel.kt
new file mode 100644
index 0000000..b132a5d
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/MainViewModel.kt
@@ -0,0 +1,37 @@
+package com.loc.newsapp
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.loc.newsapp.domain.usecases.manager.AppEntryUseCase
+import com.loc.newsapp.ui.navigation.graph.Route
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import javax.inject.Inject
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val appEntryUseCase: AppEntryUseCase
+) : ViewModel() {
+
+ var splashCondition by mutableStateOf(true)
+ private set
+
+ var startDestination by mutableStateOf(Route.AppStartNavigation.route)
+ private set
+
+ init {
+ appEntryUseCase.readAppEntry().onEach { shouldStartFromHomeScreen ->
+ if(shouldStartFromHomeScreen){
+ startDestination = Route.NewsNavigation.route
+ }else{
+ startDestination = Route.AppStartNavigation.route
+ }
+ delay(300)
+ splashCondition= false
+ }.launchIn(viewModelScope)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/NewsApplication.kt b/app/src/main/java/com/loc/newsapp/NewsApplication.kt
new file mode 100644
index 0000000..0198b49
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/NewsApplication.kt
@@ -0,0 +1,7 @@
+package com.loc.newsapp
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class NewsApplication: Application()
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/data/database/NewsDao.kt b/app/src/main/java/com/loc/newsapp/data/database/NewsDao.kt
new file mode 100644
index 0000000..d781849
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/data/database/NewsDao.kt
@@ -0,0 +1,25 @@
+package com.loc.newsapp.data.database
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.loc.newsapp.domain.model.Article
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface NewsDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertArticleToDatabase(article: Article)
+
+ @Delete
+ suspend fun deleteArticleFromDatabase(article: Article)
+
+ @Query("SELECT * FROM Article")
+ fun getAllArticlesFromDatabase(): Flow>
+
+ @Query("SELECT * FROM Article WHERE url=:url")
+ suspend fun getSelectedArticleFromDatabase(url: String): Article?
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/data/database/NewsDatabase.kt b/app/src/main/java/com/loc/newsapp/data/database/NewsDatabase.kt
new file mode 100644
index 0000000..5c56ec8
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/data/database/NewsDatabase.kt
@@ -0,0 +1,14 @@
+package com.loc.newsapp.data.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import com.loc.newsapp.domain.model.Article
+
+@Database(entities = [Article::class], version = 3)
+@TypeConverters(NewsTypeConvertor::class)
+abstract class NewsDatabase: RoomDatabase() {
+
+ abstract val newsDao: NewsDao
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/data/database/NewsTypeConvertor.kt b/app/src/main/java/com/loc/newsapp/data/database/NewsTypeConvertor.kt
new file mode 100644
index 0000000..a930b6e
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/data/database/NewsTypeConvertor.kt
@@ -0,0 +1,22 @@
+package com.loc.newsapp.data.database
+
+import androidx.room.ProvidedTypeConverter
+import androidx.room.TypeConverter
+import com.loc.newsapp.domain.model.Source
+
+@ProvidedTypeConverter
+class NewsTypeConvertor {
+
+ @TypeConverter
+ fun sourceToString(source: Source): String {
+ return "${source.id},${source.name}"
+ }
+
+ @TypeConverter
+ fun stringToSource(source: String): Source {
+ return source.split(",").let { sourceArray ->
+ Source(sourceArray[0], sourceArray[1])
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/data/manager/LocalUserManagerImpl.kt b/app/src/main/java/com/loc/newsapp/data/manager/LocalUserManagerImpl.kt
new file mode 100644
index 0000000..df53b67
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/data/manager/LocalUserManagerImpl.kt
@@ -0,0 +1,36 @@
+package com.loc.newsapp.data.manager
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import com.loc.newsapp.domain.manager.LocalUserManager
+import com.loc.newsapp.util.Constants
+import com.loc.newsapp.util.Constants.USER_SETTINGS
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+class LocalUserManagerImpl(
+ private val context: Context
+) : LocalUserManager {
+
+ override suspend fun saveAppEntry() {
+ context.dataStore.edit { settings ->
+ settings[PreferencesKeys.APP_ENTRY] = true
+ }
+ }
+
+ override fun readAppEntry(): Flow {
+ return context.dataStore.data.map { preferences ->
+ preferences[PreferencesKeys.APP_ENTRY] ?: false
+ }
+ }
+}
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = USER_SETTINGS)
+
+private object PreferencesKeys {
+ val APP_ENTRY = booleanPreferencesKey(name = Constants.APP_ENTRY)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/data/network/NewsAPI.kt b/app/src/main/java/com/loc/newsapp/data/network/NewsAPI.kt
new file mode 100644
index 0000000..94d3ae6
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/data/network/NewsAPI.kt
@@ -0,0 +1,25 @@
+package com.loc.newsapp.data.network
+
+import com.loc.newsapp.BuildConfig
+import com.loc.newsapp.data.network.dto.NewsResponse
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface NewsAPI {
+
+ @GET("everything")
+ suspend fun getNews(
+ @Query("page") page: Int,
+ @Query("sources") sources: String,
+ @Query("apiKey") apiKey: String = BuildConfig.API_KEY
+ ): NewsResponse
+
+ @GET("everything")
+ suspend fun searchNews(
+ @Query("q") searchQuery: String,
+ @Query("page") page: Int,
+ @Query("sources") sources: String,
+ @Query("apiKey") apiKey: String = BuildConfig.API_KEY
+ ): NewsResponse
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/data/network/dto/NewsResponse.kt b/app/src/main/java/com/loc/newsapp/data/network/dto/NewsResponse.kt
new file mode 100644
index 0000000..ac1590d
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/data/network/dto/NewsResponse.kt
@@ -0,0 +1,9 @@
+package com.loc.newsapp.data.network.dto
+
+import com.loc.newsapp.domain.model.Article
+
+data class NewsResponse(
+ val articles: List,
+ val status: String,
+ val totalResults: Int
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/data/network/paging/NewsPagingSource.kt b/app/src/main/java/com/loc/newsapp/data/network/paging/NewsPagingSource.kt
new file mode 100644
index 0000000..12c9ecb
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/data/network/paging/NewsPagingSource.kt
@@ -0,0 +1,40 @@
+package com.loc.newsapp.data.network.paging
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.loc.newsapp.data.network.NewsAPI
+import com.loc.newsapp.domain.model.Article
+
+class NewsPagingSource(
+ private val newsApi: NewsAPI,
+ private val sources: String
+) : PagingSource() {
+
+ private var totalNewsCount = 0
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ val page = params.key ?: 1
+ return try {
+ val newsResponse = newsApi.getNews(sources = sources, page = page)
+ totalNewsCount += newsResponse.articles.size
+ val articles = newsResponse.articles.distinctBy { it.title } // Remove Duplicates
+ LoadResult.Page(
+ data = articles,
+ nextKey = if (totalNewsCount == newsResponse.totalResults) null else page + 1,
+ prevKey = null
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ LoadResult.Error(
+ throwable = e
+ )
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition?.let { anchorPosition ->
+ val anchorPage = state.closestPageToPosition(anchorPosition)
+ anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/data/network/paging/SearchNewsPagingSource.kt b/app/src/main/java/com/loc/newsapp/data/network/paging/SearchNewsPagingSource.kt
new file mode 100644
index 0000000..978aef6
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/data/network/paging/SearchNewsPagingSource.kt
@@ -0,0 +1,42 @@
+package com.loc.newsapp.data.network.paging
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.loc.newsapp.data.network.NewsAPI
+import com.loc.newsapp.domain.model.Article
+
+class SearchNewsPagingSource(
+ private val newsApi: NewsAPI,
+ private val searchQuery: String,
+ private val sources: String
+) : PagingSource() {
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition?.let { anchorPosition ->
+ val anchorPage = state.closestPageToPosition(anchorPosition)
+ anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
+ }
+ }
+
+ private var totalNewsCount = 0
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ val page = params.key ?: 1
+ return try {
+ val newsResponse =
+ newsApi.searchNews(searchQuery = searchQuery, sources = sources, page = page)
+ totalNewsCount += newsResponse.articles.size
+ val articles = newsResponse.articles.distinctBy { it.title } // Remove Duplicates
+ LoadResult.Page(
+ data = articles,
+ nextKey = if (totalNewsCount == newsResponse.totalResults) null else page + 1,
+ prevKey = null
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ LoadResult.Error(
+ throwable = e
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/data/repository/NewsRepositoryImpl.kt b/app/src/main/java/com/loc/newsapp/data/repository/NewsRepositoryImpl.kt
new file mode 100644
index 0000000..6928053
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/data/repository/NewsRepositoryImpl.kt
@@ -0,0 +1,59 @@
+package com.loc.newsapp.data.repository
+
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import com.loc.newsapp.data.database.NewsDao
+import com.loc.newsapp.data.network.NewsAPI
+import com.loc.newsapp.data.network.paging.NewsPagingSource
+import com.loc.newsapp.data.network.paging.SearchNewsPagingSource
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.repository.NewsRepository
+import kotlinx.coroutines.flow.Flow
+
+class NewsRepositoryImpl(
+ private val newsApi: NewsAPI,
+ private val newsDao: NewsDao
+) : NewsRepository {
+
+ override fun getNewsFromRepository(sources: List): Flow> {
+ return Pager(
+ config = PagingConfig(pageSize = 10),
+ pagingSourceFactory = {
+ NewsPagingSource(
+ newsApi = newsApi,
+ sources = sources.joinToString(separator = ",")
+ )
+ }
+ ).flow
+ }
+
+ override fun searchNewsFromRepository(searchQuery: String, sources: List): Flow> {
+ return Pager(
+ config = PagingConfig(pageSize = 10),
+ pagingSourceFactory = {
+ SearchNewsPagingSource(
+ searchQuery = searchQuery,
+ newsApi = newsApi,
+ sources = sources.joinToString(separator = ",")
+ )
+ }
+ ).flow
+ }
+
+ override suspend fun upsertArticleRepository(article: Article) {
+ newsDao.upsertArticleToDatabase(article)
+ }
+
+ override suspend fun deleteArticleRepository(article: Article) {
+ newsDao.deleteArticleFromDatabase(article)
+ }
+
+ override fun selectBookmarkArticlesRepository(): Flow> {
+ return newsDao.getAllArticlesFromDatabase()
+ }
+
+ override suspend fun selectBookmarkArticleRepository(url: String): Article? {
+ return newsDao.getSelectedArticleFromDatabase(url)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/dependencyinjection/DatabaseModule.kt b/app/src/main/java/com/loc/newsapp/dependencyinjection/DatabaseModule.kt
new file mode 100644
index 0000000..bdf2aa4
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/dependencyinjection/DatabaseModule.kt
@@ -0,0 +1,39 @@
+package com.loc.newsapp.dependencyinjection
+
+import android.app.Application
+import androidx.room.Room
+import com.loc.newsapp.BuildConfig
+import com.loc.newsapp.data.database.NewsDao
+import com.loc.newsapp.data.database.NewsDatabase
+import com.loc.newsapp.data.database.NewsTypeConvertor
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun provideNewsDatabase(
+ application: Application
+ ): NewsDatabase {
+ return Room.databaseBuilder(
+ context = application,
+ klass = NewsDatabase::class.java,
+ name = BuildConfig.NEWS_DATABASE_NAME
+ ).addTypeConverter(NewsTypeConvertor())
+ .fallbackToDestructiveMigration()
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideNewsDao(
+ newsDatabase: NewsDatabase
+ ): NewsDao = newsDatabase.newsDao
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/dependencyinjection/ManagerModule.kt b/app/src/main/java/com/loc/newsapp/dependencyinjection/ManagerModule.kt
new file mode 100644
index 0000000..0223ddc
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/dependencyinjection/ManagerModule.kt
@@ -0,0 +1,35 @@
+package com.loc.newsapp.dependencyinjection
+
+import android.app.Application
+import com.loc.newsapp.data.manager.LocalUserManagerImpl
+import com.loc.newsapp.domain.manager.LocalUserManager
+import com.loc.newsapp.domain.usecases.manager.AppEntryUseCase
+import com.loc.newsapp.domain.usecases.manager.ReadAppEntry
+import com.loc.newsapp.domain.usecases.manager.SaveAppEntry
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ManagerModule {
+
+ @Provides
+ @Singleton
+ fun provideLocalUserManger(
+ application: Application
+ ): LocalUserManager = LocalUserManagerImpl(application)
+
+
+ @Provides
+ @Singleton
+ fun provideAppEntryUseCases(
+ localUserManager: LocalUserManager
+ ) = AppEntryUseCase(
+ readAppEntry = ReadAppEntry(localUserManager),
+ saveAppEntry = SaveAppEntry(localUserManager)
+ )
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/dependencyinjection/NetworkModule.kt b/app/src/main/java/com/loc/newsapp/dependencyinjection/NetworkModule.kt
new file mode 100644
index 0000000..ab9a6b2
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/dependencyinjection/NetworkModule.kt
@@ -0,0 +1,27 @@
+package com.loc.newsapp.dependencyinjection
+
+import com.loc.newsapp.BuildConfig
+import com.loc.newsapp.data.network.NewsAPI
+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)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideNewsApi(): NewsAPI {
+ return Retrofit.Builder()
+ .baseUrl(BuildConfig.BASE_URL)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ .create(NewsAPI::class.java)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/dependencyinjection/RepositoryModule.kt b/app/src/main/java/com/loc/newsapp/dependencyinjection/RepositoryModule.kt
new file mode 100644
index 0000000..c3316db
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/dependencyinjection/RepositoryModule.kt
@@ -0,0 +1,23 @@
+package com.loc.newsapp.dependencyinjection
+
+import com.loc.newsapp.data.database.NewsDao
+import com.loc.newsapp.data.network.NewsAPI
+import com.loc.newsapp.data.repository.NewsRepositoryImpl
+import com.loc.newsapp.domain.repository.NewsRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RepositoryModule {
+
+ @Provides
+ @Singleton
+ fun provideNewsRepository(
+ newsApi: NewsAPI,
+ newsDao: NewsDao
+ ): NewsRepository = NewsRepositoryImpl(newsApi,newsDao)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/dependencyinjection/UseCaseModule.kt b/app/src/main/java/com/loc/newsapp/dependencyinjection/UseCaseModule.kt
new file mode 100644
index 0000000..2b9c62f
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/dependencyinjection/UseCaseModule.kt
@@ -0,0 +1,38 @@
+package com.loc.newsapp.dependencyinjection
+
+import com.loc.newsapp.data.database.NewsDao
+import com.loc.newsapp.domain.repository.NewsRepository
+import com.loc.newsapp.domain.usecases.news.DeleteArticleDatabase
+import com.loc.newsapp.domain.usecases.news.GetAllNews
+import com.loc.newsapp.domain.usecases.news.NewsUseCase
+import com.loc.newsapp.domain.usecases.news.GetSearchNews
+import com.loc.newsapp.domain.usecases.news.SelectBookmarkArticleDatabase
+import com.loc.newsapp.domain.usecases.news.SelectBookmarkAllArticleDatabase
+import com.loc.newsapp.domain.usecases.news.UpsertArticleDatabase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object UseCaseModule {
+
+ @Provides
+ @Singleton
+ fun provideNewsUseCases(
+ newsRepository: NewsRepository,
+ newsDao: NewsDao
+ ): NewsUseCase {
+ return NewsUseCase(
+ getAllNews = GetAllNews(newsRepository),
+ getSearchNews = GetSearchNews(newsRepository),
+ upsertArticleDatabase = UpsertArticleDatabase(newsRepository),
+ deleteArticleDatabase = DeleteArticleDatabase(newsRepository),
+ selectBookmarkAllArticleDatabase = SelectBookmarkAllArticleDatabase(newsRepository),
+ selectBookmarkArticleDatabase = SelectBookmarkArticleDatabase(newsRepository)
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/manager/LocalUserManager.kt b/app/src/main/java/com/loc/newsapp/domain/manager/LocalUserManager.kt
new file mode 100644
index 0000000..a10cdc6
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/manager/LocalUserManager.kt
@@ -0,0 +1,10 @@
+package com.loc.newsapp.domain.manager
+
+import kotlinx.coroutines.flow.Flow
+
+interface LocalUserManager {
+
+ suspend fun saveAppEntry()
+
+ fun readAppEntry(): Flow
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/model/Article.kt b/app/src/main/java/com/loc/newsapp/domain/model/Article.kt
new file mode 100644
index 0000000..1c068cd
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/model/Article.kt
@@ -0,0 +1,19 @@
+package com.loc.newsapp.domain.model
+
+import android.os.Parcelable
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+@Entity
+data class Article(
+ val author: String?,
+ val content: String,
+ val description: String,
+ val publishedAt: String,
+ val source: Source,
+ val title: String,
+ @PrimaryKey val url: String,
+ val urlToImage: String
+): Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/model/Source.kt b/app/src/main/java/com/loc/newsapp/domain/model/Source.kt
new file mode 100644
index 0000000..d3b86ee
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/model/Source.kt
@@ -0,0 +1,10 @@
+package com.loc.newsapp.domain.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class Source(
+ val id: String,
+ val name: String
+): Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/repository/NewsRepository.kt b/app/src/main/java/com/loc/newsapp/domain/repository/NewsRepository.kt
new file mode 100644
index 0000000..b88b1ce
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/repository/NewsRepository.kt
@@ -0,0 +1,21 @@
+package com.loc.newsapp.domain.repository
+
+import androidx.paging.PagingData
+import com.loc.newsapp.domain.model.Article
+import kotlinx.coroutines.flow.Flow
+
+interface NewsRepository {
+
+ fun getNewsFromRepository(sources: List): Flow>
+
+ fun searchNewsFromRepository(searchQuery: String, sources: List): Flow>
+
+ suspend fun upsertArticleRepository(article: Article)
+
+ suspend fun deleteArticleRepository(article: Article)
+
+ fun selectBookmarkArticlesRepository(): Flow>
+
+ suspend fun selectBookmarkArticleRepository(url: String): Article?
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/manager/AppEntryUseCase.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/manager/AppEntryUseCase.kt
new file mode 100644
index 0000000..d1f1f05
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/manager/AppEntryUseCase.kt
@@ -0,0 +1,6 @@
+package com.loc.newsapp.domain.usecases.manager
+
+data class AppEntryUseCase(
+ val readAppEntry: ReadAppEntry,
+ val saveAppEntry: SaveAppEntry
+)
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/manager/ReadAppEntry.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/manager/ReadAppEntry.kt
new file mode 100644
index 0000000..941044c
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/manager/ReadAppEntry.kt
@@ -0,0 +1,14 @@
+package com.loc.newsapp.domain.usecases.manager
+
+import com.loc.newsapp.domain.manager.LocalUserManager
+import kotlinx.coroutines.flow.Flow
+
+class ReadAppEntry(
+ private val localUserManager: LocalUserManager
+) {
+
+ operator fun invoke(): Flow{
+ return localUserManager.readAppEntry()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/manager/SaveAppEntry.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/manager/SaveAppEntry.kt
new file mode 100644
index 0000000..c1f62fa
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/manager/SaveAppEntry.kt
@@ -0,0 +1,13 @@
+package com.loc.newsapp.domain.usecases.manager
+
+import com.loc.newsapp.domain.manager.LocalUserManager
+
+class SaveAppEntry(
+ private val localUserManager: LocalUserManager
+) {
+
+ suspend operator fun invoke(){
+ localUserManager.saveAppEntry()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/news/DeleteArticleDatabase.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/news/DeleteArticleDatabase.kt
new file mode 100644
index 0000000..d413e6c
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/news/DeleteArticleDatabase.kt
@@ -0,0 +1,14 @@
+package com.loc.newsapp.domain.usecases.news
+
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.repository.NewsRepository
+
+class DeleteArticleDatabase(
+ private val newsRepository: NewsRepository
+) {
+
+ suspend operator fun invoke(article: Article){
+ newsRepository.deleteArticleRepository(article)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/news/GetAllNews.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/news/GetAllNews.kt
new file mode 100644
index 0000000..4ce1846
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/news/GetAllNews.kt
@@ -0,0 +1,16 @@
+package com.loc.newsapp.domain.usecases.news
+
+import androidx.paging.PagingData
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.repository.NewsRepository
+import kotlinx.coroutines.flow.Flow
+
+class GetAllNews(
+ private val newsRepository: NewsRepository
+) {
+
+ operator fun invoke(sources: List): Flow>{
+ return newsRepository.getNewsFromRepository(sources = sources)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/news/GetSearchNews.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/news/GetSearchNews.kt
new file mode 100644
index 0000000..a615ac1
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/news/GetSearchNews.kt
@@ -0,0 +1,16 @@
+package com.loc.newsapp.domain.usecases.news
+
+import androidx.paging.PagingData
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.repository.NewsRepository
+import kotlinx.coroutines.flow.Flow
+
+class GetSearchNews(
+ private val newsRepository: NewsRepository
+) {
+
+ operator fun invoke(searchQuery: String,sources: List): Flow> {
+ return newsRepository.searchNewsFromRepository(searchQuery = searchQuery,sources = sources)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/news/NewsUseCase.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/news/NewsUseCase.kt
new file mode 100644
index 0000000..fb52bb8
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/news/NewsUseCase.kt
@@ -0,0 +1,10 @@
+package com.loc.newsapp.domain.usecases.news
+
+data class NewsUseCase(
+ val getAllNews: GetAllNews,
+ val getSearchNews: GetSearchNews,
+ val upsertArticleDatabase: UpsertArticleDatabase,
+ val deleteArticleDatabase: DeleteArticleDatabase,
+ val selectBookmarkAllArticleDatabase: SelectBookmarkAllArticleDatabase,
+ val selectBookmarkArticleDatabase: SelectBookmarkArticleDatabase
+)
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/news/SelectBookmarkAllArticleDatabase.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/news/SelectBookmarkAllArticleDatabase.kt
new file mode 100644
index 0000000..3546399
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/news/SelectBookmarkAllArticleDatabase.kt
@@ -0,0 +1,15 @@
+package com.loc.newsapp.domain.usecases.news
+
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.repository.NewsRepository
+import kotlinx.coroutines.flow.Flow
+
+class SelectBookmarkAllArticleDatabase(
+ private val newsRepository: NewsRepository
+) {
+
+ operator fun invoke(): Flow>{
+ return newsRepository.selectBookmarkArticlesRepository()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/news/SelectBookmarkArticleDatabase.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/news/SelectBookmarkArticleDatabase.kt
new file mode 100644
index 0000000..c340941
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/news/SelectBookmarkArticleDatabase.kt
@@ -0,0 +1,14 @@
+package com.loc.newsapp.domain.usecases.news
+
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.repository.NewsRepository
+
+class SelectBookmarkArticleDatabase(
+ private val newsRepository: NewsRepository
+) {
+
+ suspend operator fun invoke(url: String): Article?{
+ return newsRepository.selectBookmarkArticleRepository(url)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/domain/usecases/news/UpsertArticleDatabase.kt b/app/src/main/java/com/loc/newsapp/domain/usecases/news/UpsertArticleDatabase.kt
new file mode 100644
index 0000000..12ac94b
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/domain/usecases/news/UpsertArticleDatabase.kt
@@ -0,0 +1,14 @@
+package com.loc.newsapp.domain.usecases.news
+
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.repository.NewsRepository
+
+class UpsertArticleDatabase(
+ private val newsRepository: NewsRepository
+) {
+
+ suspend operator fun invoke(article: Article){
+ newsRepository.upsertArticleRepository(article)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/component/common/ArticleCard.kt b/app/src/main/java/com/loc/newsapp/ui/component/common/ArticleCard.kt
new file mode 100644
index 0000000..4c2c4b2
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/component/common/ArticleCard.kt
@@ -0,0 +1,132 @@
+package com.loc.newsapp.ui.component.common
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import com.loc.newsapp.R
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.model.Source
+import com.loc.newsapp.util.Dimens.ArticleCardSize
+import com.loc.newsapp.util.Dimens.ExtraSmallPadding
+import com.loc.newsapp.util.Dimens.ExtraSmallPadding2
+import com.loc.newsapp.util.Dimens.SmallIconSize
+import com.loc.newsapp.ui.theme.NewsAppTheme
+
+@Composable
+fun ArticleCard(
+ modifier: Modifier = Modifier,
+ article: Article,
+ onClick: () -> Unit
+) {
+ val context = LocalContext.current
+
+ Row(modifier = modifier.clickable { onClick() }) {
+
+ AsyncImage(
+ modifier = Modifier
+ .size(ArticleCardSize)
+ .padding(5.dp)
+ .clip(MaterialTheme.shapes.medium),
+ model = ImageRequest.Builder(context).data(article.urlToImage).build(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop
+ )
+
+ Column(
+ verticalArrangement = Arrangement.SpaceAround,
+ modifier = Modifier
+ .padding(horizontal = ExtraSmallPadding)
+ .height(ArticleCardSize)
+ ) {
+ Text(
+ text = article.title,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorResource(id = R.color.text_title),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = article.source.name,
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
+ color = colorResource(id = R.color.body)
+ )
+
+ Spacer(modifier = Modifier.width(ExtraSmallPadding2))
+ Icon(
+ painter = painterResource(id = R.drawable.ic_time), contentDescription = null,
+ modifier = Modifier.size(SmallIconSize),
+ tint = colorResource(id = R.color.body)
+ )
+ Spacer(modifier = Modifier.width(ExtraSmallPadding2))
+ Text(
+ text = article.publishedAt,
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
+ color = colorResource(id = R.color.body)
+ )
+ }
+
+ }
+
+ }
+
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+@Composable
+fun ArticleCardPreview() {
+ NewsAppTheme {
+ ArticleCard(
+ article = Article(
+ author = "",
+ content = "",
+ description = "",
+ publishedAt = "2 hours",
+ source = Source(id = "", name = "BBC"),
+ title = "Her traint broke down. Her phone died. And then she met her saver in a",
+ url = "",
+ urlToImage = ""
+ )
+ ) {
+
+ }
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/loc/newsapp/ui/component/common/ArticlesList.kt b/app/src/main/java/com/loc/newsapp/ui/component/common/ArticlesList.kt
new file mode 100644
index 0000000..f48187d
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/component/common/ArticlesList.kt
@@ -0,0 +1,97 @@
+package com.loc.newsapp.ui.component.common
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.paging.LoadState
+import androidx.paging.compose.LazyPagingItems
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.util.Dimens.ExtraSmallPadding2
+import com.loc.newsapp.util.Dimens.MediumPadding1
+
+@Composable
+fun ArticlesList(
+ modifier: Modifier = Modifier,
+ articles: List,
+ onClick: (Article) -> Unit
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(MediumPadding1),
+ contentPadding = PaddingValues(all = ExtraSmallPadding2)
+ ) {
+ items(count = articles.size) {
+ val article = articles[it]
+ ArticleCard(article = article, onClick = { onClick(article) })
+ }
+ }
+}
+
+@Composable
+fun ArticlesList(
+ modifier: Modifier = Modifier,
+ articles: LazyPagingItems,
+ onClick: (Article) -> Unit
+) {
+ val handlePagingResult = handlePagingResult(articles = articles)
+ if (handlePagingResult) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(MediumPadding1),
+ contentPadding = PaddingValues(all = ExtraSmallPadding2)
+ ) {
+ items(count = articles.itemCount) {
+ articles[it]?.let {
+ ArticleCard(article = it, onClick = { onClick(it) })
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun handlePagingResult(
+ articles: LazyPagingItems,
+): Boolean {
+
+ val loadState = articles.loadState
+ val error = when {
+ loadState.refresh is LoadState.Error -> loadState.refresh as LoadState.Error
+ loadState.prepend is LoadState.Error -> loadState.prepend as LoadState.Error
+ loadState.append is LoadState.Error -> loadState.append as LoadState.Error
+ else -> null
+ }
+
+ return when {
+ loadState.refresh is LoadState.Loading -> {
+ ShimmerEffect()
+ false
+ }
+
+ error != null -> {
+ EmptyScreen()
+ false
+ }
+
+ else -> {
+ true
+ }
+ }
+
+}
+
+@Composable
+private fun ShimmerEffect() {
+ Column(verticalArrangement = Arrangement.spacedBy(MediumPadding1)) {
+ repeat(10) {
+ ArticleCardShimmerEffect(
+ modifier = Modifier.padding(horizontal = MediumPadding1)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/component/common/EmptyScreen.kt b/app/src/main/java/com/loc/newsapp/ui/component/common/EmptyScreen.kt
new file mode 100644
index 0000000..b28be04
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/component/common/EmptyScreen.kt
@@ -0,0 +1,116 @@
+package com.loc.newsapp.ui.component.common
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color.Companion.DarkGray
+import androidx.compose.ui.graphics.Color.Companion.LightGray
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.paging.LoadState
+
+import com.loc.newsapp.R
+import java.net.ConnectException
+import java.net.SocketTimeoutException
+
+@Composable
+fun EmptyScreen(error: LoadState.Error? = null) {
+
+ var message by remember {
+ mutableStateOf(parseErrorMessage(error = error))
+ }
+
+ var icon by remember {
+ mutableStateOf(R.drawable.ic_network_error)
+ }
+
+ if (error == null){
+ message = "You have not saved news so far !"
+ icon = R.drawable.ic_search_document
+ }
+
+ var startAnimation by remember {
+ mutableStateOf(false)
+ }
+
+ val alphaAnimation by animateFloatAsState(
+ targetValue = if (startAnimation) 0.3f else 0f,
+ animationSpec = tween(durationMillis = 1000)
+ )
+
+ LaunchedEffect(key1 = true) {
+ startAnimation = true
+ }
+
+ EmptyContent(alphaAnim = alphaAnimation, message = message, iconId = icon)
+
+}
+
+@Composable
+fun EmptyContent(alphaAnim: Float, message: String, iconId: Int) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ painter = painterResource(id = iconId),
+ contentDescription = null,
+ tint = if (isSystemInDarkTheme()) LightGray else DarkGray,
+ modifier = Modifier
+ .size(120.dp)
+ .alpha(alphaAnim)
+ )
+ Text(
+ modifier = Modifier
+ .padding(10.dp)
+ .alpha(alphaAnim),
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isSystemInDarkTheme()) LightGray else DarkGray,
+ )
+ }
+}
+
+
+fun parseErrorMessage(error: LoadState.Error?): String {
+ return when (error?.error) {
+ is SocketTimeoutException -> {
+ "Server Unavailable."
+ }
+
+ is ConnectException -> {
+ "Internet Unavailable."
+ }
+
+ else -> {
+ "Unknown Error."
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+@Composable
+fun EmptyScreenPreview() {
+ EmptyContent(alphaAnim = 0.3f, message = "Internet Unavailable.",R.drawable.ic_network_error)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/component/common/NewsButton.kt b/app/src/main/java/com/loc/newsapp/ui/component/common/NewsButton.kt
new file mode 100644
index 0000000..44b683a
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/component/common/NewsButton.kt
@@ -0,0 +1,55 @@
+package com.loc.newsapp.ui.component.common
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.loc.newsapp.ui.theme.WhiteGray
+
+@Composable
+fun NewsButton(
+ text: String,
+ onClick: () -> Unit
+) {
+
+ Button(
+ onClick = onClick, colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = Color.White
+ ),
+ shape = RoundedCornerShape(size = 6.dp)
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold)
+ )
+ }
+}
+
+
+@Composable
+fun NewsTextButton(
+ text: String,
+ onClick: () -> Unit
+) {
+ TextButton(onClick = onClick) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = WhiteGray
+ )
+ }
+}
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/loc/newsapp/ui/component/common/SearchBar.kt b/app/src/main/java/com/loc/newsapp/ui/component/common/SearchBar.kt
new file mode 100644
index 0000000..f30901e
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/component/common/SearchBar.kt
@@ -0,0 +1,129 @@
+package com.loc.newsapp.ui.component.common
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.border
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.loc.newsapp.R
+import com.loc.newsapp.util.Dimens.IconSize
+import com.loc.newsapp.ui.theme.NewsAppTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchBar(
+ modifier: Modifier = Modifier,
+ text: String,
+ readOnly: Boolean,
+ onClick: (() -> Unit)? = null,
+ onValueChange: (String) -> Unit,
+ onSearch: () -> Unit
+) {
+
+ val interactionSource = remember {
+ MutableInteractionSource()
+ }
+ val isClicked = interactionSource.collectIsPressedAsState().value
+ LaunchedEffect(key1 = isClicked) {
+ if (isClicked) {
+ onClick?.invoke()
+ }
+ }
+
+ Box(modifier = modifier) {
+
+ TextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .searchBarBorder(),
+ value = text,
+ onValueChange = onValueChange,
+ readOnly = readOnly,
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_search),
+ contentDescription = null,
+ modifier = Modifier.size(IconSize),
+ tint = colorResource(id = R.color.body)
+ )
+ },
+ placeholder = {
+ Text(
+ text = "Search",
+ style = MaterialTheme.typography.bodySmall,
+ color = colorResource(
+ id = R.color.placeholder
+ )
+ )
+ },
+ shape = MaterialTheme.shapes.medium,
+ colors = TextFieldDefaults.textFieldColors(
+ containerColor = colorResource(id = R.color.input_background),
+ textColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
+ cursorColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
+ disabledIndicatorColor = Color.Transparent,
+ errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent
+ ),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(
+ onSearch = {
+ onSearch()
+ }
+ ),
+ textStyle = MaterialTheme.typography.bodySmall,
+ interactionSource = interactionSource
+ )
+
+
+ }
+
+
+}
+
+fun Modifier.searchBarBorder() = composed {
+ if (!isSystemInDarkTheme()) {
+ border(
+ width = 1.dp,
+ color = Color.Black,
+ shape = MaterialTheme.shapes.medium
+ )
+ } else {
+ this
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+@Composable
+fun SearchBarPreview() {
+ NewsAppTheme {
+ SearchBar(text = "", readOnly = false, onValueChange = {}) {
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/component/common/ShimmerEffect.kt b/app/src/main/java/com/loc/newsapp/ui/component/common/ShimmerEffect.kt
new file mode 100644
index 0000000..e35daf1
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/component/common/ShimmerEffect.kt
@@ -0,0 +1,89 @@
+package com.loc.newsapp.ui.component.common
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.loc.newsapp.R
+import com.loc.newsapp.util.Dimens
+import com.loc.newsapp.util.Dimens.MediumPadding1
+import com.loc.newsapp.ui.theme.NewsAppTheme
+
+@Composable
+fun ArticleCardShimmerEffect(
+ modifier: Modifier = Modifier
+) {
+
+ Row(modifier = modifier) {
+
+ Box(
+ modifier = shimmerEffect()
+ .size(Dimens.ArticleCardSize)
+ .clip(MaterialTheme.shapes.medium)
+ )
+ Column(
+ verticalArrangement = Arrangement.SpaceAround,
+ modifier = Modifier
+ .padding(horizontal = Dimens.ExtraSmallPadding)
+ .height(Dimens.ArticleCardSize)
+ ) {
+ Box(
+ modifier = shimmerEffect()
+ .fillMaxWidth()
+ .height(30.dp)
+ .padding(horizontal = MediumPadding1)
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = shimmerEffect()
+ .fillMaxWidth(0.5f)
+ .height(15.dp)
+ .padding(horizontal = MediumPadding1)
+ )
+ }
+ }
+ }
+}
+
+private fun shimmerEffect(): Modifier = Modifier.composed {
+ val transition = rememberInfiniteTransition(label = "Shimmer Effect")
+ val alpha = transition.animateFloat(
+ initialValue = 0.2f,
+ targetValue = 0.9f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1000),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "Shimmer Effect"
+ ).value
+ background(color = colorResource(id = R.color.shimmer).copy(alpha = alpha))
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+@Composable
+fun ArticleCardShimmerEffectPreview() {
+ NewsAppTheme {
+ ArticleCardShimmerEffect()
+ }
+}
diff --git a/app/src/main/java/com/loc/newsapp/ui/component/detail/DetailsTopBar.kt b/app/src/main/java/com/loc/newsapp/ui/component/detail/DetailsTopBar.kt
new file mode 100644
index 0000000..f5c7fb1
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/component/detail/DetailsTopBar.kt
@@ -0,0 +1,107 @@
+package com.loc.newsapp.ui.component.detail
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import com.loc.newsapp.R
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.model.Source
+import com.loc.newsapp.ui.theme.NewsAppTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DetailsTopBar(
+ sideEffect: Boolean?,
+ onBrowsingClick: () -> Unit,
+ onShareClick: () -> Unit,
+ onBookmarkClick: () -> Unit,
+ onBackClick: () -> Unit
+) {
+ TopAppBar(
+ title = { Text(text = "") },
+ modifier = Modifier.fillMaxWidth(),
+ colors = TopAppBarDefaults.mediumTopAppBarColors(
+ containerColor = Color.Transparent,
+ actionIconContentColor = colorResource(id = R.color.body),
+ navigationIconContentColor = colorResource(id = R.color.body),
+ ),
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_back_arrow),
+ contentDescription = null
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = onBookmarkClick) {
+ if (sideEffect == true) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_bookmark_remove),
+ contentDescription = null
+ )
+ } else {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_bookmark_add),
+ contentDescription = null
+ )
+ }
+ }
+ IconButton(onClick = onShareClick) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = null
+ )
+ }
+ IconButton(onClick = onBrowsingClick) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_network),
+ contentDescription = null
+ )
+ }
+ }
+ )
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
+@Composable
+fun DetailsTopBarPreivew() {
+ NewsAppTheme {
+ Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
+ DetailsTopBar(
+ sideEffect = true,
+ onBrowsingClick = { /*TODO*/ },
+ onShareClick = { /*TODO*/ },
+ onBookmarkClick = { /*TODO*/ }) {
+ }
+ }
+ }
+}
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/loc/newsapp/ui/component/onboarding/OnBoardingPage.kt b/app/src/main/java/com/loc/newsapp/ui/component/onboarding/OnBoardingPage.kt
new file mode 100644
index 0000000..9c83733
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/component/onboarding/OnBoardingPage.kt
@@ -0,0 +1,67 @@
+package com.loc.newsapp.ui.component.onboarding
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import com.loc.newsapp.R
+import com.loc.newsapp.util.Dimens.MediumPadding1
+import com.loc.newsapp.util.Dimens.MediumPadding2
+import com.loc.newsapp.ui.screen.onboarding.Page
+import com.loc.newsapp.ui.screen.onboarding.pages
+import com.loc.newsapp.ui.theme.NewsAppTheme
+
+@Composable
+fun OnBoardingPage(
+ modifier: Modifier = Modifier,
+ page: Page
+) {
+ Column(modifier = modifier) {
+ Image(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(fraction = 0.6f),
+ painter = painterResource(id = page.image),
+ contentDescription = null,
+ contentScale = ContentScale.Crop
+ )
+ Spacer(modifier = Modifier.height(MediumPadding1))
+ Text(
+ text = page.title,
+ modifier = Modifier.padding(horizontal = MediumPadding2),
+ style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
+ color = colorResource(id = R.color.display_small)
+ )
+ Text(
+ text = page.description,
+ modifier = Modifier.padding(horizontal = MediumPadding2),
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorResource(id = R.color.text_medium)
+ )
+ }
+}
+
+
+@Preview(showBackground = true)
+@Preview(uiMode = UI_MODE_NIGHT_YES,showBackground = true)
+@Composable
+fun OnBoardingPagePreview() {
+ NewsAppTheme {
+ OnBoardingPage(
+ page = pages[0]
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/component/onboarding/PageIndicator.kt b/app/src/main/java/com/loc/newsapp/ui/component/onboarding/PageIndicator.kt
new file mode 100644
index 0000000..99ff44b
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/component/onboarding/PageIndicator.kt
@@ -0,0 +1,33 @@
+package com.loc.newsapp.ui.component.onboarding
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import com.loc.newsapp.util.Dimens.IndicatorSize
+import com.loc.newsapp.ui.theme.BlueGray
+
+@Composable
+fun PageIndicator(
+ modifier: Modifier = Modifier,
+ pageSize: Int,
+ selectedPage: Int,
+ selectedColor: Color = MaterialTheme.colorScheme.primary,
+ unselectedColor: Color = BlueGray
+) {
+ Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween) {
+ repeat(pageSize) { page ->
+ Box(
+ modifier = Modifier.size(IndicatorSize).clip(CircleShape)
+ .background(color = if (page == selectedPage) selectedColor else unselectedColor)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/navigation/NewsNavigator.kt b/app/src/main/java/com/loc/newsapp/ui/navigation/NewsNavigator.kt
new file mode 100644
index 0000000..3c1a0d5
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/navigation/NewsNavigator.kt
@@ -0,0 +1,201 @@
+package com.loc.newsapp.ui.navigation
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.paging.compose.collectAsLazyPagingItems
+import com.loc.newsapp.R
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.ui.screen.bookmark.BookmarkScreen
+import com.loc.newsapp.ui.screen.bookmark.BookmarkViewModel
+import com.loc.newsapp.ui.screen.details.DetailsEvent
+import com.loc.newsapp.ui.screen.details.DetailsScreen
+import com.loc.newsapp.ui.screen.details.DetailsViewModel
+import com.loc.newsapp.ui.screen.home.HomeScreen
+import com.loc.newsapp.ui.screen.home.HomeViewModel
+import com.loc.newsapp.ui.navigation.components.BottomNavigationItem
+import com.loc.newsapp.ui.navigation.components.NewsBottomNavigation
+import com.loc.newsapp.ui.navigation.graph.Route
+import com.loc.newsapp.ui.screen.search.SearchScreen
+import com.loc.newsapp.ui.screen.search.SearchViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NewsNavigator() {
+
+ val bottomNavigationItems = remember {
+ listOf(
+ BottomNavigationItem(icon = R.drawable.ic_home, text = "Home"),
+ BottomNavigationItem(icon = R.drawable.ic_search, text = "Search"),
+ BottomNavigationItem(icon = R.drawable.ic_bookmark, text = "Bookmark")
+ )
+ }
+
+ val navController = rememberNavController()
+ val backstackState = navController.currentBackStackEntryAsState().value
+ var selectedItem by rememberSaveable { mutableIntStateOf(0) }
+
+ selectedItem = remember(key1 = backstackState) {
+ when (backstackState?.destination?.route) {
+ Route.HomeScreen.route -> 0
+ Route.SearchScreen.route -> 1
+ Route.BookmarkScreen.route -> 2
+ else -> 0
+ }
+
+ }
+
+
+ val isBottomBarVisible = remember(key1 = backstackState) {
+ backstackState?.destination?.route == Route.HomeScreen.route ||
+ backstackState?.destination?.route == Route.SearchScreen.route ||
+ backstackState?.destination?.route == Route.BookmarkScreen.route
+ }
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ bottomBar = {
+ if (isBottomBarVisible) {
+ NewsBottomNavigation(
+ items = bottomNavigationItems,
+ selected = selectedItem,
+ onItemClick = { index ->
+ when (index) {
+ 0 -> navigateToTap(
+ navController = navController,
+ route = Route.HomeScreen.route
+ )
+ 1 -> navigateToTap(
+ navController = navController,
+ route = Route.SearchScreen.route
+ )
+ 2 -> navigateToTap(
+ navController = navController,
+ route = Route.BookmarkScreen.route
+ )
+ }
+ }
+ )
+ }
+ }
+ ) {
+ val bottomPadding = it.calculateBottomPadding()
+ NavHost(
+ navController = navController,
+ startDestination = Route.HomeScreen.route,
+ modifier = Modifier.padding(bottom = bottomPadding)
+ ) {
+ composable(route = Route.HomeScreen.route) {
+ val viewModel: HomeViewModel = hiltViewModel()
+ val articles = viewModel.news.collectAsLazyPagingItems()
+ val state by viewModel.state
+ HomeScreen(
+ articles = articles,
+ navigateToSearch = {
+ navigateToTap(
+ navController = navController,
+ route = Route.SearchScreen.route
+ )
+ },
+ navigateToDetails = { article ->
+ navigateToDetails(
+ navController = navController,
+ article = article
+ )
+ },
+ state = state,
+ event = { event ->
+ viewModel.onEvent(event)
+ }
+ )
+ }
+
+ composable(route = Route.SearchScreen.route) {
+ val viewModel: SearchViewModel = hiltViewModel()
+ val state = viewModel.state.value
+ SearchScreen(
+ state = state,
+ event = viewModel::onEvent,
+ navigateToDetails = { article ->
+ navigateToDetails(
+ navController = navController,
+ article = article
+ )
+ }
+ )
+ }
+
+ composable(route = Route.DetailsScreen.route) {
+ val viewModel: DetailsViewModel = hiltViewModel()
+
+ navController.previousBackStackEntry?.savedStateHandle?.get("article")
+ ?.let { article ->
+ DetailsScreen(
+ article = article,
+ sideEffect = viewModel.sideEffect,
+ event = viewModel::onEvent,
+ controlBookmark = {
+ viewModel.controlBookmarkedArticle(article)
+ },
+ navigateUp = { navController.navigateUp() })
+ }
+ }
+
+ composable(route = Route.BookmarkScreen.route) {
+ val viewModel: BookmarkViewModel = hiltViewModel()
+ val state = viewModel.state.value
+ BookmarkScreen(state = state, navigateToDetails = { article ->
+ navigateToDetails(navController = navController, article = article)
+
+ })
+ }
+ }
+ }
+}
+
+private fun navigateToTap(navController: NavController, route: String) {
+ navController.navigate(route) {
+ navController.graph.startDestinationRoute?.let { homeScreen ->
+ popUpTo(homeScreen) {
+ saveState = true
+ }
+ restoreState = true
+ launchSingleTop = true
+ }
+ }
+}
+
+private fun navigateToDetails(navController: NavController, article: Article) {
+ navController.currentBackStackEntry?.savedStateHandle?.set("article", article)
+ navController.navigate(
+ route = Route.DetailsScreen.route
+ )
+}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/loc/newsapp/ui/navigation/components/NewsBottomNavigation.kt b/app/src/main/java/com/loc/newsapp/ui/navigation/components/NewsBottomNavigation.kt
new file mode 100644
index 0000000..8f3aeb2
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/navigation/components/NewsBottomNavigation.kt
@@ -0,0 +1,86 @@
+package com.loc.newsapp.ui.navigation.components
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.loc.newsapp.R
+import com.loc.newsapp.util.Dimens.ExtraSmallPadding2
+import com.loc.newsapp.util.Dimens.IconSize
+import com.loc.newsapp.ui.theme.NewsAppTheme
+
+@Composable
+fun NewsBottomNavigation(
+ items: List,
+ selected: Int,
+ onItemClick: (Int) -> Unit
+) {
+ NavigationBar(
+ modifier = Modifier.fillMaxWidth(),
+ containerColor = MaterialTheme.colorScheme.background,
+ tonalElevation = 10.dp
+ ) {
+ items.forEachIndexed { index, item ->
+ NavigationBarItem(
+ selected = index == selected,
+ onClick = { onItemClick(index) },
+ icon = {
+ Column(horizontalAlignment = CenterHorizontally) {
+ Icon(
+ painter = painterResource(id = item.icon),
+ contentDescription = null,
+ modifier = Modifier.size(IconSize)
+ )
+ Spacer(modifier = Modifier.height(ExtraSmallPadding2))
+ Text(text = item.text, style = MaterialTheme.typography.labelSmall)
+ }
+ },
+ colors = NavigationBarItemDefaults.colors(
+ selectedIconColor = MaterialTheme.colorScheme.primary,
+ selectedTextColor = MaterialTheme.colorScheme.primary,
+ unselectedIconColor = colorResource(id = R.color.body),
+ unselectedTextColor = colorResource(id = R.color.body),
+ indicatorColor = MaterialTheme.colorScheme.background
+ )
+ )
+ }
+ }
+}
+
+data class BottomNavigationItem(
+ @DrawableRes val icon: Int,
+ val text: String
+)
+
+@Preview
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Composable
+fun NewsBottomNavigationPreview() {
+ NewsAppTheme {
+ NewsBottomNavigation(
+ items = listOf(
+ BottomNavigationItem(icon = R.drawable.ic_home, text = "Home"),
+ BottomNavigationItem(icon = R.drawable.ic_search, text = "Search"),
+ BottomNavigationItem(icon = R.drawable.ic_bookmark, text = "Bookmark")
+ ),
+ selected = 0,
+ onItemClick = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/navigation/graph/NavGraph.kt b/app/src/main/java/com/loc/newsapp/ui/navigation/graph/NavGraph.kt
new file mode 100644
index 0000000..398a17a
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/navigation/graph/NavGraph.kt
@@ -0,0 +1,43 @@
+package com.loc.newsapp.ui.navigation.graph
+
+import androidx.compose.runtime.Composable
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navigation
+import com.loc.newsapp.ui.navigation.NewsNavigator
+import com.loc.newsapp.ui.screen.onboarding.OnBoardingScreen
+import com.loc.newsapp.ui.screen.onboarding.OnBoardingViewModel
+
+@Composable
+fun NavGraph(
+ startDestination: String
+) {
+ val navController = rememberNavController()
+
+ NavHost(navController = navController, startDestination = startDestination) {
+ navigation(
+ route = Route.AppStartNavigation.route,
+ startDestination = Route.OnBoardingScreen.route
+ ) {
+ composable(
+ route = Route.OnBoardingScreen.route
+ ) {
+ val viewModel: OnBoardingViewModel = hiltViewModel()
+ OnBoardingScreen(
+ event = viewModel::onEvent
+ )
+ }
+ }
+
+ navigation(
+ route = Route.NewsNavigation.route,
+ startDestination = Route.NewsNavigatorScreen.route
+ ) {
+ composable(route = Route.NewsNavigatorScreen.route) {
+ NewsNavigator()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/navigation/graph/Route.kt b/app/src/main/java/com/loc/newsapp/ui/navigation/graph/Route.kt
new file mode 100644
index 0000000..456c118
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/navigation/graph/Route.kt
@@ -0,0 +1,15 @@
+package com.loc.newsapp.ui.navigation.graph
+
+sealed class Route(
+ val route: String
+){
+
+ object OnBoardingScreen : Route(route = "onBoardingScreen")
+ object HomeScreen : Route(route = "homeScreen")
+ object SearchScreen : Route(route = "searchScreen")
+ object BookmarkScreen : Route(route = "bookmarkScreen")
+ object DetailsScreen : Route(route = "detailsScreen")
+ object AppStartNavigation : Route(route = "appStartNavigation")
+ object NewsNavigation : Route(route = "newsNavigation")
+ object NewsNavigatorScreen : Route(route = "newsNavigator")
+}
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/bookmark/BookmarkScreen.kt b/app/src/main/java/com/loc/newsapp/ui/screen/bookmark/BookmarkScreen.kt
new file mode 100644
index 0000000..cf285cf
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/bookmark/BookmarkScreen.kt
@@ -0,0 +1,41 @@
+package com.loc.newsapp.ui.screen.bookmark
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.text.font.FontWeight
+import com.loc.newsapp.R
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.util.Dimens.MediumPadding1
+import com.loc.newsapp.ui.component.common.ArticlesList
+
+@Composable
+fun BookmarkScreen(
+ state: BookmarkState,
+ navigateToDetails: (Article) -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .padding(top = MediumPadding1, start = MediumPadding1, end = MediumPadding1)
+ ) {
+ Text(
+ text = "Bookmark",
+ style = MaterialTheme.typography.displayMedium.copy(fontWeight = FontWeight.Bold),
+ color = colorResource(id = R.color.text_title)
+ )
+
+ Spacer(modifier = Modifier.height(MediumPadding1))
+
+ ArticlesList(articles = state.articles, onClick = { navigateToDetails(it) })
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/bookmark/BookmarkState.kt b/app/src/main/java/com/loc/newsapp/ui/screen/bookmark/BookmarkState.kt
new file mode 100644
index 0000000..739c449
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/bookmark/BookmarkState.kt
@@ -0,0 +1,7 @@
+package com.loc.newsapp.ui.screen.bookmark
+
+import com.loc.newsapp.domain.model.Article
+
+data class BookmarkState(
+ val articles: List = emptyList()
+)
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/bookmark/BookmarkViewModel.kt b/app/src/main/java/com/loc/newsapp/ui/screen/bookmark/BookmarkViewModel.kt
new file mode 100644
index 0000000..8bf58fa
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/bookmark/BookmarkViewModel.kt
@@ -0,0 +1,32 @@
+package com.loc.newsapp.ui.screen.bookmark
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.loc.newsapp.domain.usecases.news.NewsUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import javax.inject.Inject
+
+@HiltViewModel
+class BookmarkViewModel @Inject constructor(
+ private val newsUseCase: NewsUseCase
+) : ViewModel() {
+
+
+ private val _state = mutableStateOf(BookmarkState())
+ val state: State = _state
+
+ init {
+ getArticles()
+ }
+
+ private fun getArticles() {
+ newsUseCase.selectBookmarkAllArticleDatabase().onEach {
+ _state.value = _state.value.copy(articles = it.asReversed())
+ }.launchIn(viewModelScope)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/details/DetailsEvent.kt b/app/src/main/java/com/loc/newsapp/ui/screen/details/DetailsEvent.kt
new file mode 100644
index 0000000..908845b
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/details/DetailsEvent.kt
@@ -0,0 +1,11 @@
+package com.loc.newsapp.ui.screen.details
+
+import com.loc.newsapp.domain.model.Article
+
+sealed class DetailsEvent {
+
+ data class UpsertDeleteArticle(val article: Article) : DetailsEvent()
+
+ object RemoveSideEffect : DetailsEvent()
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/details/DetailsScreen.kt b/app/src/main/java/com/loc/newsapp/ui/screen/details/DetailsScreen.kt
new file mode 100644
index 0000000..e866ba2
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/details/DetailsScreen.kt
@@ -0,0 +1,148 @@
+package com.loc.newsapp.ui.screen.details
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.tooling.preview.Preview
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import com.loc.newsapp.R
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.model.Source
+import com.loc.newsapp.util.Dimens.ArticleImageHeight
+import com.loc.newsapp.util.Dimens.MediumPadding1
+import com.loc.newsapp.ui.component.detail.DetailsTopBar
+import com.loc.newsapp.ui.theme.NewsAppTheme
+
+@Composable
+fun DetailsScreen(
+ article: Article,
+ sideEffect: Boolean?,
+ event: (DetailsEvent) -> Unit,
+ controlBookmark: () -> Unit,
+ navigateUp: () -> Unit
+) {
+ val context = LocalContext.current
+
+ LaunchedEffect(key1 = article) {
+ controlBookmark.invoke()
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ ) {
+ DetailsTopBar(
+ sideEffect = sideEffect,
+ onBrowsingClick = {
+ Intent(Intent.ACTION_VIEW).also {
+ it.data = Uri.parse(article.url)
+ if (it.resolveActivity(context.packageManager) != null) {
+ context.startActivity(it)
+ }
+ }
+ },
+ onShareClick = {
+ Intent(Intent.ACTION_SEND).also {
+ it.putExtra(Intent.EXTRA_TEXT, article.url)
+ it.type = "text/plain"
+ if (it.resolveActivity(context.packageManager) != null) {
+ context.startActivity(it)
+ }
+ }
+ },
+ onBookmarkClick = { event(DetailsEvent.UpsertDeleteArticle(article)) },
+ onBackClick = navigateUp
+ )
+
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(
+ start = MediumPadding1,
+ end = MediumPadding1,
+ top = MediumPadding1
+ )
+ ) {
+ item {
+
+ AsyncImage(
+ model = ImageRequest.Builder(context = context).data(article.urlToImage)
+ .build(),
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(ArticleImageHeight)
+ .clip(MaterialTheme.shapes.medium),
+ contentScale = ContentScale.Crop
+ )
+
+ Spacer(modifier = Modifier.height(MediumPadding1))
+
+ Text(
+ text = article.title,
+ style = MaterialTheme.typography.displaySmall,
+ color = colorResource(
+ id = R.color.text_title
+ )
+ )
+
+ Text(
+ text = article.content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorResource(
+ id = R.color.body
+ )
+ )
+ }
+ }
+ }
+}
+
+
+@Preview(showBackground = true)
+@Composable
+fun DetailsScreenPreview() {
+ NewsAppTheme(dynamicColor = false) {
+ DetailsScreen(
+ article = Article(
+ author = "",
+ title = "Coinbase says Apple blocked its last app release on NFTs in Wallet ... - CryptoSaurus",
+ description = "Coinbase says Apple blocked its last app release on NFTs in Wallet ... - CryptoSaurus",
+ content = "We use cookies and data to Deliver and maintain Google services Track outages and protect against spam, fraud, and abuse Measure audience engagement and site statistics to undeā¦ [+1131 chars]",
+ publishedAt = "2023-06-16T22:24:33Z",
+ source = Source(
+ id = "", name = "bbc"
+ ),
+ url = "https://consent.google.com/ml?continue=https://news.google.com/rss/articles/CBMiaWh0dHBzOi8vY3J5cHRvc2F1cnVzLnRlY2gvY29pbmJhc2Utc2F5cy1hcHBsZS1ibG9ja2VkLWl0cy1sYXN0LWFwcC1yZWxlYXNlLW9uLW5mdHMtaW4td2FsbGV0LXJldXRlcnMtY29tL9IBAA?oc%3D5&gl=FR&hl=en-US&cm=2&pc=n&src=1",
+ urlToImage = "https://media.wired.com/photos/6495d5e893ba5cd8bbdc95af/191:100/w_1280,c_limit/The-EU-Rules-Phone-Batteries-Must-Be-Replaceable-Gear-2BE6PRN.jpg"
+ ),
+ sideEffect = false,
+ event = {},
+ controlBookmark = {}
+ ) {}
+ }
+}
+
+
+
+
+
+
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/details/DetailsViewModel.kt b/app/src/main/java/com/loc/newsapp/ui/screen/details/DetailsViewModel.kt
new file mode 100644
index 0000000..966f138
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/details/DetailsViewModel.kt
@@ -0,0 +1,62 @@
+package com.loc.newsapp.ui.screen.details
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.domain.usecases.news.NewsUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.forEach
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class DetailsViewModel @Inject constructor(
+ private val newsUseCase: NewsUseCase
+) : ViewModel() {
+
+ var sideEffect by mutableStateOf(null)
+
+ fun onEvent(event: DetailsEvent) {
+ when (event) {
+ is DetailsEvent.UpsertDeleteArticle -> {
+ viewModelScope.launch {
+ val article = newsUseCase.selectBookmarkArticleDatabase(event.article.url)
+ if (article == null) {
+ upsertArticle(event.article)
+ } else {
+ deleteArticle(event.article)
+ }
+ }
+ }
+
+ is DetailsEvent.RemoveSideEffect -> {
+ sideEffect = null
+ }
+ }
+ }
+
+ private suspend fun deleteArticle(article: Article) {
+ newsUseCase.deleteArticleDatabase(article = article)
+ sideEffect = false
+ }
+
+ private suspend fun upsertArticle(article: Article) {
+ newsUseCase.upsertArticleDatabase(article = article)
+ sideEffect = true
+ }
+
+ fun controlBookmarkedArticle(article: Article) {
+ viewModelScope.launch {
+ newsUseCase.selectBookmarkAllArticleDatabase().collect {
+ sideEffect = it.contains(article)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeEvent.kt b/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeEvent.kt
new file mode 100644
index 0000000..7750cef
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeEvent.kt
@@ -0,0 +1,6 @@
+package com.loc.newsapp.ui.screen.home
+
+sealed class HomeEvent {
+ data class UpdateScrollValue(val newValue: Int): HomeEvent()
+ data class UpdateMaxScrollingValue(val newValue: Int): HomeEvent()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeScreen.kt b/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeScreen.kt
new file mode 100644
index 0000000..f4053ea
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeScreen.kt
@@ -0,0 +1,134 @@
+package com.loc.newsapp.ui.screen.home
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.remember
+import androidx.paging.compose.LazyPagingItems
+import com.loc.newsapp.domain.model.Article
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.loc.newsapp.R
+import com.loc.newsapp.util.Dimens.MediumPadding1
+import com.loc.newsapp.ui.component.common.ArticlesList
+import com.loc.newsapp.ui.component.common.SearchBar
+import kotlinx.coroutines.delay
+
+@Composable
+fun HomeScreen(
+ state: HomeState,
+ articles: LazyPagingItems,
+ navigateToSearch: () -> Unit,
+ navigateToDetails: (Article) -> Unit,
+ event: (HomeEvent) -> Unit,
+) {
+ val titles by remember {
+ derivedStateOf {
+ if (articles.itemCount > 10) {
+ articles.itemSnapshotList.items
+ .slice(IntRange(start = 0, endInclusive = 9))
+ .joinToString(separator = " \uD83d\uDFE5 ") { it.title }
+ } else {
+ ""
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = MediumPadding1)
+ .statusBarsPadding()
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_logo),
+ contentDescription = null,
+ modifier = Modifier
+ .width(150.dp)
+ .height(30.dp)
+ .padding(horizontal = MediumPadding1)
+ )
+
+ Spacer(modifier = Modifier.height(MediumPadding1))
+
+ SearchBar(
+ modifier = Modifier.padding(horizontal = MediumPadding1),
+ text = "",
+ readOnly = true,
+ onValueChange = {},
+ onClick = {
+ navigateToSearch()
+ },
+ onSearch = {}
+ )
+
+ Spacer(modifier = Modifier.height(MediumPadding1))
+
+ val scrollState = rememberScrollState()
+
+ LaunchedEffect(key1 = state.maxScrollingValue) {
+ delay(500)
+ if (state.maxScrollingValue > 0) {
+ scrollState.animateScrollTo(
+ value = state.maxScrollingValue,
+ animationSpec = infiniteRepeatable(
+ tween(
+ durationMillis = (state.maxScrollingValue - state.scrollValue) * 50_000 / state.maxScrollingValue,
+ easing = LinearEasing,
+ delayMillis = 1000
+ )
+ )
+ )
+ }
+ }
+ // Update the maxScrollingValue
+ LaunchedEffect(key1 = scrollState.maxValue) {
+ event(HomeEvent.UpdateMaxScrollingValue(scrollState.maxValue))
+ }
+ // Save the state of the scrolling position
+ LaunchedEffect(key1 = scrollState.value) {
+ event(HomeEvent.UpdateScrollValue(scrollState.value))
+ }
+
+ Text(
+ text = titles,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = MediumPadding1)
+ .horizontalScroll(scrollState,false),
+ fontSize = 12.sp,
+ color = colorResource(id = R.color.placeholder)
+ )
+
+ Spacer(modifier = Modifier.height(MediumPadding1))
+
+ ArticlesList(
+ modifier = Modifier.padding(horizontal = MediumPadding1),
+ articles = articles,
+ onClick = {
+ navigateToDetails(it)
+ }
+ )
+ }
+}
+
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeState.kt b/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeState.kt
new file mode 100644
index 0000000..4ec280f
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeState.kt
@@ -0,0 +1,6 @@
+package com.loc.newsapp.ui.screen.home
+
+data class HomeState(
+ val scrollValue: Int = 0,
+ val maxScrollingValue: Int = 0
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeViewModel.kt b/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeViewModel.kt
new file mode 100644
index 0000000..9bbd1a7
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/home/HomeViewModel.kt
@@ -0,0 +1,36 @@
+package com.loc.newsapp.ui.screen.home
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.cachedIn
+import com.loc.newsapp.domain.usecases.news.NewsUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class HomeViewModel @Inject constructor(
+ private val newsUseCase: NewsUseCase
+) : ViewModel() {
+
+ private val _state = mutableStateOf(HomeState())
+ val state: State = _state
+
+ val news = newsUseCase.getAllNews(
+ sources = listOf("bbc-news", "abc-news", "al-jazeera-english")
+ ).cachedIn(viewModelScope)
+ fun onEvent(event: HomeEvent){
+ when(event){
+ is HomeEvent.UpdateScrollValue -> updateScrollValue(event.newValue)
+ is HomeEvent.UpdateMaxScrollingValue -> updateMaxScrollingValue(event.newValue)
+ }
+ }
+ private fun updateScrollValue(newValue: Int){
+ _state.value = state.value.copy(scrollValue = newValue)
+ }
+ private fun updateMaxScrollingValue(newValue: Int){
+ _state.value = state.value.copy(maxScrollingValue = newValue)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/OnBoardingEvent.kt b/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/OnBoardingEvent.kt
new file mode 100644
index 0000000..af42b98
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/OnBoardingEvent.kt
@@ -0,0 +1,7 @@
+package com.loc.newsapp.ui.screen.onboarding
+
+sealed class OnBoardingEvent {
+
+ object SaveAppEntry: OnBoardingEvent()
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/OnBoardingScreen.kt b/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/OnBoardingScreen.kt
new file mode 100644
index 0000000..eb8c656
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/OnBoardingScreen.kt
@@ -0,0 +1,105 @@
+package com.loc.newsapp.ui.screen.onboarding
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.loc.newsapp.util.Dimens.MediumPadding2
+import com.loc.newsapp.util.Dimens.PageIndicatorWidth
+import com.loc.newsapp.ui.component.common.NewsButton
+import com.loc.newsapp.ui.component.common.NewsTextButton
+import com.loc.newsapp.ui.component.onboarding.OnBoardingPage
+import com.loc.newsapp.ui.component.onboarding.PageIndicator
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun OnBoardingScreen(
+ event: (OnBoardingEvent) -> Unit
+) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ val pagerState = rememberPagerState(initialPage = 0) {
+ pages.size
+ }
+ val buttonState = remember {
+ derivedStateOf {
+ when (pagerState.currentPage) {
+ 0 -> listOf("", "Next")
+ 1 -> listOf("Back", "Next")
+ 2 -> listOf("Back", "Get Started")
+ else -> listOf("", "")
+ }
+ }
+ }
+
+ HorizontalPager(state = pagerState) { index ->
+ OnBoardingPage(page = pages[index])
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = MediumPadding2)
+ .navigationBarsPadding(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ PageIndicator(
+ modifier = Modifier.width(PageIndicatorWidth),
+ pageSize = pages.size,
+ selectedPage = pagerState.currentPage
+ )
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+
+ val scope = rememberCoroutineScope()
+
+ if (buttonState.value[0].isNotEmpty()) {
+ NewsTextButton(
+ text = buttonState.value[0],
+ onClick = {
+ scope.launch {
+ pagerState.animateScrollToPage(page = pagerState.currentPage - 1)
+ }
+ }
+ )
+ }
+
+ NewsButton(
+ text = buttonState.value[1],
+ onClick = {
+ scope.launch {
+ if (pagerState.currentPage == 2) {
+ event(OnBoardingEvent.SaveAppEntry)
+ } else {
+ pagerState.animateScrollToPage(
+ page = pagerState.currentPage + 1
+ )
+ }
+ }
+ }
+ )
+ }
+ }
+ Spacer(modifier = Modifier.weight(0.5f))
+ }
+}
+
+
+
+
+
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/OnBoardingViewModel.kt b/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/OnBoardingViewModel.kt
new file mode 100644
index 0000000..57bd4e0
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/OnBoardingViewModel.kt
@@ -0,0 +1,31 @@
+package com.loc.newsapp.ui.screen.onboarding
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.loc.newsapp.domain.usecases.manager.AppEntryUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class OnBoardingViewModel @Inject constructor(
+ private val appEntryUseCase: AppEntryUseCase
+): ViewModel() {
+
+
+ fun onEvent(event: OnBoardingEvent){
+ when(event){
+ is OnBoardingEvent.SaveAppEntry -> {
+ saveAppEntry()
+ }
+ }
+ }
+
+ private fun saveAppEntry() {
+ viewModelScope.launch {
+ appEntryUseCase.saveAppEntry()
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/Page.kt b/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/Page.kt
new file mode 100644
index 0000000..43e5255
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/onboarding/Page.kt
@@ -0,0 +1,31 @@
+package com.loc.newsapp.ui.screen.onboarding
+
+import androidx.annotation.DrawableRes
+import com.loc.newsapp.R
+
+data class Page(
+ val title: String,
+ val description: String,
+ @DrawableRes val image: Int
+)
+
+
+val pages = listOf(
+ Page(
+ title = "Lorem Ipsum is simply dummy",
+ description = "Lorem Ipsum is simply dummy text of the printing and typesetting industry.",
+ image = R.drawable.onboarding1
+ ),
+ Page(
+ title = "Lorem Ipsum is simply dummy",
+ description = "Lorem Ipsum is simply dummy text of the printing and typesetting industry.",
+ image = R.drawable.onboarding2
+ ),
+ Page(
+ title = "Lorem Ipsum is simply dummy",
+ description = "Lorem Ipsum is simply dummy text of the printing and typesetting industry.",
+ image = R.drawable.onboarding3
+ )
+)
+
+
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchEvent.kt b/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchEvent.kt
new file mode 100644
index 0000000..1e700b7
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchEvent.kt
@@ -0,0 +1,8 @@
+package com.loc.newsapp.ui.screen.search
+
+sealed class SearchEvent {
+
+ data class UpdateSearchQuery(val searchQuery: String): SearchEvent()
+
+ object SearchNews : SearchEvent()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchScreen.kt b/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchScreen.kt
new file mode 100644
index 0000000..bf1fd00
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchScreen.kt
@@ -0,0 +1,47 @@
+package com.loc.newsapp.ui.screen.search
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.paging.compose.collectAsLazyPagingItems
+import com.loc.newsapp.domain.model.Article
+import com.loc.newsapp.util.Dimens.MediumPadding1
+import com.loc.newsapp.ui.component.common.ArticlesList
+import com.loc.newsapp.ui.component.common.SearchBar
+
+@Composable
+fun SearchScreen(
+ state: SearchState,
+ event: (SearchEvent) -> Unit,
+ navigateToDetails: (Article) -> Unit
+) {
+
+ Column(
+ modifier = Modifier
+ .padding(
+ top = MediumPadding1,
+ start = MediumPadding1,
+ end = MediumPadding1
+ )
+ .statusBarsPadding()
+ .fillMaxSize()
+ ) {
+ SearchBar(
+ text = state.searchQuery,
+ readOnly = false,
+ onValueChange = { event(SearchEvent.UpdateSearchQuery(it)) },
+ onSearch = { event(SearchEvent.SearchNews) })
+
+ Spacer(modifier = Modifier.height(MediumPadding1))
+ state.articles?.let {
+ val articles = it.collectAsLazyPagingItems()
+ ArticlesList(articles = articles, onClick = { navigateToDetails(it) })
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchState.kt b/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchState.kt
new file mode 100644
index 0000000..c00efc0
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchState.kt
@@ -0,0 +1,11 @@
+package com.loc.newsapp.ui.screen.search
+
+import androidx.paging.PagingData
+import com.loc.newsapp.domain.model.Article
+import kotlinx.coroutines.flow.Flow
+
+data class SearchState(
+ val searchQuery: String = "",
+ val articles: Flow>? = null
+) {
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchViewModel.kt b/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchViewModel.kt
new file mode 100644
index 0000000..7ce3e9a
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/ui/screen/search/SearchViewModel.kt
@@ -0,0 +1,40 @@
+package com.loc.newsapp.ui.screen.search
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.cachedIn
+import com.loc.newsapp.domain.usecases.news.NewsUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class SearchViewModel @Inject constructor(
+ private val newsUseCase: NewsUseCase
+) : ViewModel() {
+
+ private val _state = mutableStateOf(SearchState())
+ val state: State = _state
+
+ fun onEvent(event: SearchEvent) {
+ when (event) {
+ is SearchEvent.UpdateSearchQuery -> {
+ _state.value = state.value.copy(searchQuery = event.searchQuery)
+ }
+
+ is SearchEvent.SearchNews -> {
+ searchNews()
+ }
+ }
+ }
+
+ private fun searchNews() {
+ val articles = newsUseCase.getSearchNews(
+ searchQuery = state.value.searchQuery,
+ sources = listOf("bbc-news", "abc-news", "al-jazeera-english")
+ ).cachedIn(viewModelScope)
+ _state.value = state.value.copy(articles = articles)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/ui/theme/Theme.kt b/app/src/main/java/com/loc/newsapp/ui/theme/Theme.kt
index 3191bd0..c68b97f 100644
--- a/app/src/main/java/com/loc/newsapp/ui/theme/Theme.kt
+++ b/app/src/main/java/com/loc/newsapp/ui/theme/Theme.kt
@@ -34,7 +34,7 @@ private val LightColorScheme = lightColorScheme(
fun NewsAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
- dynamicColor: Boolean = true,
+ dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
diff --git a/app/src/main/java/com/loc/newsapp/util/Constants.kt b/app/src/main/java/com/loc/newsapp/util/Constants.kt
new file mode 100644
index 0000000..9f40e2e
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/util/Constants.kt
@@ -0,0 +1,8 @@
+package com.loc.newsapp.util
+
+object Constants {
+
+ const val USER_SETTINGS = "userSettings"
+
+ const val APP_ENTRY = "appEntry"
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/loc/newsapp/util/Dimens.kt b/app/src/main/java/com/loc/newsapp/util/Dimens.kt
new file mode 100644
index 0000000..cd9f02a
--- /dev/null
+++ b/app/src/main/java/com/loc/newsapp/util/Dimens.kt
@@ -0,0 +1,27 @@
+package com.loc.newsapp.util
+
+import androidx.compose.ui.unit.dp
+
+object Dimens {
+
+ val MediumPadding1 = 24.dp
+
+ val MediumPadding2 = 30.dp
+
+ val IndicatorSize = 14.dp
+
+ val PageIndicatorWidth = 52.dp
+
+ val ArticleCardSize = 96.dp
+
+ val ExtraSmallPadding = 3.dp
+
+ val ExtraSmallPadding2 = 6.dp
+
+ val SmallIconSize = 11.dp
+
+ val IconSize = 20.dp
+
+ val ArticleImageHeight = 248.dp
+
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_bookmark_add.xml b/app/src/main/res/drawable/ic_bookmark_add.xml
new file mode 100644
index 0000000..a8ffca2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bookmark_add.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_bookmark_remove.xml b/app/src/main/res/drawable/ic_bookmark_remove.xml
new file mode 100644
index 0000000..8d29d4c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bookmark_remove.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/values-night/splash.xml b/app/src/main/res/values-night/splash.xml
new file mode 100644
index 0000000..2871686
--- /dev/null
+++ b/app/src/main/res/values-night/splash.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/splash.xml b/app/src/main/res/values/splash.xml
new file mode 100644
index 0000000..b637479
--- /dev/null
+++ b/app/src/main/res/values/splash.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 6a842e5..30979df 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,4 +1,6 @@
-
+
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 3c5031e..6920c05 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -20,4 +20,5 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
+android.defaults.buildfeatures.buildconfig=true
\ No newline at end of file