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