diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..fb7f4a8 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..526b4c2 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..911dc21 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd22761 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# NewsProject + +This is a test project for job opening. And skill showcase in some degree, I guess. + +Full task description here - http://testtask.sebbia.com/swagger-ui.html#!/news-controller/detailsUsingGET + +I've spent around 19-20 hours completing the task. That's including some time on studying/reading about Retrofit2, Hilt, View Binding, mb some other stuff. (all off which was relatively new to me) + +In current state I don't really like transitions between different screen, I would like to add Loading and Fail states sometime in the future diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..58e3843 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id'androidx.navigation.safeargs.kotlin' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' +} + +android { + compileSdk 31 + + defaultConfig { + applicationId "com.example.newsproject" + minSdk 26 + targetSdk 31 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + viewBinding true + } +} + +dependencies { + + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'com.google.android.material:material:1.5.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' + + implementation 'androidx.navigation:navigation-fragment-ktx:2.4.0' + implementation 'androidx.navigation:navigation-ui-ktx:2.4.0' + implementation "androidx.paging:paging-runtime:3.1.0" + + implementation "com.google.dagger:hilt-android:2.38.1" + kapt "com.google.dagger:hilt-compiler:2.38.1" + + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.5.0' + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/newsproject/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/newsproject/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..85fa8be --- /dev/null +++ b/app/src/androidTest/java/com/example/newsproject/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.newsproject + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.newsproject", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..90545cf --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/App.kt b/app/src/main/java/com/example/newsproject/App.kt new file mode 100644 index 0000000..c1ca6b6 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/App.kt @@ -0,0 +1,8 @@ +package com.example.newsproject + +import android.app.Application +import dagger.hilt.InstallIn +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class App : Application() \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/MainActivity.kt b/app/src/main/java/com/example/newsproject/MainActivity.kt new file mode 100644 index 0000000..757639d --- /dev/null +++ b/app/src/main/java/com/example/newsproject/MainActivity.kt @@ -0,0 +1,23 @@ +package com.example.newsproject + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavController +import androidx.navigation.findNavController +import com.example.newsproject.databinding.ActivityMainBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + private val TAG = "MyMainActivity" + private lateinit var binding: ActivityMainBinding + private lateinit var navController: NavController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + navController = findNavController(R.id.nav_host_fragment_activity_main) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/data/Models.kt b/app/src/main/java/com/example/newsproject/data/Models.kt new file mode 100644 index 0000000..ae84d5f --- /dev/null +++ b/app/src/main/java/com/example/newsproject/data/Models.kt @@ -0,0 +1,85 @@ +package com.example.newsproject.data + +import java.time.Instant +import java.util.Date + +//API Models +data class ApiCategoryList( + val code: Int = -1, + val message: String = "", + val list: List = emptyList() +) + +data class ApiNewsList( + val code: Int = -1, + val message: String = "", + val list: MutableList = mutableListOf() +) + +data class ApiNews( + val code: Int = -1, + val message: String = "", + val news: News = News() +) + +//Cached Data +data class NewsCache( + val newsPageList: MutableList = mutableListOf(), + var categoryList: List = listOf() +) { + + fun containsNewsPage(categoryId: Long, page: Int): Boolean { + for (item in newsPageList) + if (item.categoryId == categoryId && item.page == page) return true + return false + } + + fun addNewsPage(categoryId: Long, page: Int, list: MutableList) { + newsPageList.add(NewsPage(categoryId, page, list)) + } + + fun getNewsList(categoryId: Long, page: Int): List { + for (item in newsPageList) + if (item.categoryId == categoryId && item.page == page) return item.newsList + return listOf() + } + + fun getNews(newsId: Long): News { + for (page in newsPageList) + for (news in page.newsList) + if (news.id == newsId) return news + return News() + } + + fun setNews(news: News, state: Boolean = false) { + for (page in newsPageList) + for (i in page.newsList.indices) + if (page.newsList[i].id == news.id) { + news.state = state + page.newsList[i] = news + return + } + } +} + +data class NewsPage( + val categoryId: Long = -1, + val page: Int = -1, + val newsList: MutableList = mutableListOf() +) + + +//Data Model +data class Category( + val id: Long = -1, + val name: String = "" +) + +data class News( + val id: Long = -1, + val title: String = "", + val date: Date = Date.from(Instant.now()), + val shortDescription: String = "", + val fullDescription: String = "", + var state: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/data/NewsRemoteDataSource.kt b/app/src/main/java/com/example/newsproject/data/NewsRemoteDataSource.kt new file mode 100644 index 0000000..b3fd4ce --- /dev/null +++ b/app/src/main/java/com/example/newsproject/data/NewsRemoteDataSource.kt @@ -0,0 +1,16 @@ +package com.example.newsproject.data + +interface NewsRemoteDataSource { + fun getCategoryList(callback: (Result) -> Unit) + + fun getNewsList( + categoryId: Long, + page: Int, + callback: (Result) -> Unit + ) + + fun getNews( + newsId: Long, + callback: (Result) -> Unit + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/data/NewsRemoteDataSourceImpl.kt b/app/src/main/java/com/example/newsproject/data/NewsRemoteDataSourceImpl.kt new file mode 100644 index 0000000..c25772a --- /dev/null +++ b/app/src/main/java/com/example/newsproject/data/NewsRemoteDataSourceImpl.kt @@ -0,0 +1,108 @@ +package com.example.newsproject.data + +import android.util.Log +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Inject + +class NewsRemoteDataSourceImpl @Inject constructor() : + NewsRemoteDataSource{ + private val BASE_URL = "http://testtask.sebbia.com/v1/news/" + private val TAG = "MyNewsRemoteDataSource" + + private val retrofitService = Retrofit.Builder() + .baseUrl(BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(RetrofitService::class.java) + + override fun getCategoryList(callback: (Result) -> Unit) { + Log.d(TAG, "retrofitService.getCategoryList called") + val call = retrofitService.getCategoryList() + call.enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + callback( + Result.success( + response.body() + ) + ) + } + + override fun onFailure(call: Call, throwable: Throwable) { + callback( + Result.failure( + throwable + ) + ) + } + } + ) + } + + + override fun getNewsList( + categoryId: Long, + page: Int, + callback: (Result) -> Unit + ) { + Log.d(TAG, "retrofitService.getNewsList called with categoryId = $categoryId, page = $page") + val call = retrofitService.getNewsList(categoryId, page) + call.enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + callback( + Result.success( + response.body() + ) + ) + } + + override fun onFailure(call: Call, throwable: Throwable) { + callback( + Result.failure( + throwable + ) + ) + } + } + ) + } + + override fun getNews( + newsId: Long, + callback: (Result) -> Unit + ) { + Log.d(TAG, "retrofitService.getNews called with newsId = $newsId") + val call = retrofitService.getNews(newsId) + call.enqueue(object : Callback { + override fun onResponse( + call: Call, + response: Response + ) { + callback( + Result.success( + response.body() + ) + ) + } + + override fun onFailure(call: Call, throwable: Throwable) { + callback( + Result.failure( + throwable + ) + ) + } + } + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/data/NewsRepository.kt b/app/src/main/java/com/example/newsproject/data/NewsRepository.kt new file mode 100644 index 0000000..0a64699 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/data/NewsRepository.kt @@ -0,0 +1,21 @@ +package com.example.newsproject.data + +interface NewsRepository { + fun getCategoryList( + onSuccess: (List) -> Unit, + onFailure: (String) -> Unit + ) + + fun getNewsList( + categoryId: Long, + page: Int, + onSuccess: (List) -> Unit, + onFailure: (String) -> Unit + ) + fun getNews( + newsId: Long, + onSuccess: (News) -> Unit, + onPartialSuccess: (News) -> Unit, + onFailure: (String) -> Unit + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/data/NewsRepositoryImpl.kt b/app/src/main/java/com/example/newsproject/data/NewsRepositoryImpl.kt new file mode 100644 index 0000000..523570b --- /dev/null +++ b/app/src/main/java/com/example/newsproject/data/NewsRepositoryImpl.kt @@ -0,0 +1,131 @@ +package com.example.newsproject.data; + +import android.util.Log +import javax.inject.Inject + +class NewsRepositoryImpl @Inject constructor( + private val remoteDS: NewsRemoteDataSource +) : NewsRepository { + private val TAG = "MyNewsRepository" + + private var cache: NewsCache = NewsCache() + + override fun getCategoryList( + onSuccess: (List) -> Unit, + onFailure: (String) -> Unit + ) { + Log.d(TAG, "getCategoryList was called") + if (cache.categoryList.isNotEmpty()) { + onSuccess(cache.categoryList) + } else { + remoteDS.getCategoryList { result -> + result + .onSuccess { + Log.d(TAG, "getCategoryList onSuccess called") + if (it != null) { + if (it.code == 0) { + cache.categoryList = it.list + onSuccess(it.list) + } else { + Log.i(TAG, it.message) + onFailure(it.message) + } + } else { + Log.i(TAG, "categoryList == null error") + onFailure("Undefined error") + } + } + .onFailure { + Log.d(TAG, "getCategoryList onFailure called") + Log.i(TAG, it.message ?: "Undefine error") + onFailure(it.message ?: "Undefine error") + } + } + } + } + + override fun getNewsList( + categoryId: Long, + page: Int, + onSuccess: (List) -> Unit, + onFailure: (String) -> Unit + ) { + Log.d(TAG, "getNewsList was called") + if (cache.containsNewsPage(categoryId, page)) { + onSuccess(cache.getNewsList(categoryId, page)) + } else { + remoteDS.getNewsList(categoryId, page) { result -> + result + .onSuccess { + Log.d(TAG, "getNewsList onSuccess called") + if (it != null) { + if (it.code == 0) { + cache.addNewsPage(categoryId, page, it.list) + onSuccess(it.list) + } else { + Log.i(TAG, it.message) + onFailure(it.message) + } + } else { + Log.i(TAG, "newsList == null error") + onFailure("Undefined error") + } + } + .onFailure { + Log.d(TAG, "getNewsList onFailure called") + Log.i(TAG, it.message ?: "Undefine error") + onFailure(it.message ?: "Undefine error") + } + } + } + } + + override fun getNews( + newsId: Long, + onSuccess: (News) -> Unit, + onPartialSuccess: (News) -> Unit, + //here Failure is error during fullDescription loading + onFailure: (String) -> Unit + ) { + Log.d(TAG, "getNews was called") + val currentNews = cache.getNews(newsId) + //State, where currentNews == null (empty object in this case), + // is unreachable with current navigation logic + if (currentNews.state) { + onSuccess(currentNews) + } else { + onPartialSuccess(currentNews) + getNewsFromRemote(newsId, onSuccess, onFailure) + } + } + + private fun getNewsFromRemote( + newsId: Long, + onSuccess: (News) -> Unit, + onFailure: (String) -> Unit + ) { + remoteDS.getNews(newsId) { result -> + result + .onSuccess { + Log.d(TAG, "getNews onSuccess called") + if (it != null) { //we got something from api + if (it.code == 0) { + cache.setNews(it.news, true) + onSuccess(it.news) + } else { + Log.i(TAG, it.message) + onFailure(it.message) + } + } else { //we got null object from api + Log.i(TAG, "news == null error") + onFailure("Undefined error") + } + } + .onFailure { + Log.d(TAG, "getNews onFailure called") + Log.i(TAG, it.message ?: "Undefine error") + onFailure(it.message ?: "Undefine error") + } + } + } +} diff --git a/app/src/main/java/com/example/newsproject/data/RetrofitService.kt b/app/src/main/java/com/example/newsproject/data/RetrofitService.kt new file mode 100644 index 0000000..de25c50 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/data/RetrofitService.kt @@ -0,0 +1,18 @@ +package com.example.newsproject.data + +import retrofit2.Call +import retrofit2.http.* + +interface RetrofitService { + @GET("categories") + fun getCategoryList(): Call + + @GET("categories/{id}/news") + fun getNewsList( + @Path("id") id: Long, + @Query("page") page: Int + ): Call + + @GET("details") + fun getNews(@Query("id") id: Long): Call +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/di/NewsModules.kt b/app/src/main/java/com/example/newsproject/di/NewsModules.kt new file mode 100644 index 0000000..0e6b53a --- /dev/null +++ b/app/src/main/java/com/example/newsproject/di/NewsModules.kt @@ -0,0 +1,62 @@ +package com.example.newsproject.di + +import com.example.newsproject.data.NewsRemoteDataSource +import com.example.newsproject.data.NewsRemoteDataSourceImpl +import com.example.newsproject.data.NewsRepository +import com.example.newsproject.data.NewsRepositoryImpl +import com.example.newsproject.ui.categoryList.CategoryListViewModel +import com.example.newsproject.ui.categoryList.CategoryListViewModelImpl +import com.example.newsproject.ui.news.NewsViewModel +import com.example.newsproject.ui.news.NewsViewModelImpl +import com.example.newsproject.ui.newsList.NewsListViewModel +import com.example.newsproject.ui.newsList.NewsListViewModelImpl +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.assisted.Assisted +import dagger.hilt.InstallIn +import dagger.hilt.android.components.FragmentComponent +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class AppBindModule { + @Singleton + @Binds + abstract fun bindNewsRemoteDataSource( + impl: NewsRemoteDataSourceImpl + ): NewsRemoteDataSource + +} + +@Module +@InstallIn(SingletonComponent::class) +class AppProvideModule { + @Singleton + @Provides + fun provideNewsRepositoryImpl( + remoteDS: NewsRemoteDataSource + ): NewsRepository = NewsRepositoryImpl(remoteDS) +} + +@Module +@InstallIn(ViewModelComponent::class) +abstract class ViewModelProvideModule { + @Binds + abstract fun bindCategoryListViewModelImpl( + impl: CategoryListViewModelImpl + ): CategoryListViewModel + + @Binds + abstract fun bindNewsListViewModelImpl( + impl: NewsListViewModelImpl + ): NewsListViewModel + + @Binds + abstract fun bindNewsViewModelImpl( + impl: NewsViewModelImpl + ): NewsViewModel + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/ItemClickListener.kt b/app/src/main/java/com/example/newsproject/ui/ItemClickListener.kt new file mode 100644 index 0000000..ae20cff --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/ItemClickListener.kt @@ -0,0 +1,5 @@ +package com.example.newsproject.ui + +interface ItemClickListener { + fun onItemClicked(id: Long) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/categoryList/CategoryListFragment.kt b/app/src/main/java/com/example/newsproject/ui/categoryList/CategoryListFragment.kt new file mode 100644 index 0000000..96e9808 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/categoryList/CategoryListFragment.kt @@ -0,0 +1,73 @@ +package com.example.newsproject.ui.categoryList + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.example.newsproject.R +import com.example.newsproject.databinding.FragmentCategoryListBinding +import com.example.newsproject.ui.ItemClickListener +import com.example.newsproject.ui.categoryList.recycler.CategoryListAdapter +import com.example.newsproject.ui.categoryList.recycler.CategoryListDecorator +import com.example.newsproject.utils.SpanCount +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class CategoryListFragment : + Fragment(), + ItemClickListener { + private val TAG = "MyCategoryListFragment" + private var _binding: FragmentCategoryListBinding? = null + private val binding get() = _binding!! + + val viewModel: CategoryListViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Log.d(TAG, "onCreateView called") + _binding = FragmentCategoryListBinding.inflate(inflater, container, false) + val categoryAdapter = CategoryListAdapter(this) + binding.categoryList.apply { + val spanCount = SpanCount.getSpanCount( + context, + resources.getDimension(R.dimen.min_category_card_width) + ) + layoutManager = GridLayoutManager(context, spanCount) + adapter = categoryAdapter + addItemDecoration( + CategoryListDecorator( + spanCount, + resources.getDimension(R.dimen.content_margin), + true + ) + ) + } + viewModel.list.observe(viewLifecycleOwner) { + Log.d(TAG, "CategoryList data was changed") + categoryAdapter.updateList(it) + } + viewModel.navEvent.observe(viewLifecycleOwner) { + Log.d(TAG, "NavEvent was called") + binding.root.findNavController().navigate(it) + } + return binding.root + } + + override fun onDestroyView() { + Log.d(TAG, "onDestroyView called") + super.onDestroyView() + _binding = null + } + + override fun onItemClicked(id: Long) { + viewModel.onItemClicked(id) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/categoryList/CategoryListViewModel.kt b/app/src/main/java/com/example/newsproject/ui/categoryList/CategoryListViewModel.kt new file mode 100644 index 0000000..db2b7d8 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/categoryList/CategoryListViewModel.kt @@ -0,0 +1,12 @@ +package com.example.newsproject.ui.categoryList + +import androidx.lifecycle.MutableLiveData +import androidx.navigation.NavDirections +import com.example.newsproject.data.Category +import com.example.newsproject.utils.SingleLiveEvent + +interface CategoryListViewModel { + val list: MutableLiveData> + val navEvent: SingleLiveEvent + fun onItemClicked(categoryId: Long) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/categoryList/CategoryListViewModelImpl.kt b/app/src/main/java/com/example/newsproject/ui/categoryList/CategoryListViewModelImpl.kt new file mode 100644 index 0000000..dd27b0f --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/categoryList/CategoryListViewModelImpl.kt @@ -0,0 +1,43 @@ +package com.example.newsproject.ui.categoryList + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.navigation.NavDirections +import com.example.newsproject.utils.SingleLiveEvent +import com.example.newsproject.data.Category +import com.example.newsproject.data.NewsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class CategoryListViewModelImpl @Inject constructor( + private val repository: NewsRepository +) : + ViewModel(), + CategoryListViewModel { + private val TAG = "MyCategoryListViewModel" + override val list: MutableLiveData> = MutableLiveData() + override val navEvent: SingleLiveEvent = SingleLiveEvent() + + init { + Log.d(TAG, "was initialized") + repository.getCategoryList( + onSuccess = { + Log.d(TAG, "getCategoryList onSuccess called") + list.value = it + }, + onFailure = { + Log.d(TAG, "getCategoryList onFailure called") + //TODO not yet impl + } + ) + + } + + override fun onItemClicked(categoryId: Long) { + Log.d(TAG, "onItemClicked called") + navEvent.value = CategoryListFragmentDirections + .actionCategoryListFragmentToNewsListFragment(categoryId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/categoryList/recycler/CategoryListAdapter.kt b/app/src/main/java/com/example/newsproject/ui/categoryList/recycler/CategoryListAdapter.kt new file mode 100644 index 0000000..87c1d5c --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/categoryList/recycler/CategoryListAdapter.kt @@ -0,0 +1,32 @@ +package com.example.newsproject.ui.categoryList.recycler; + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.example.newsproject.R +import com.example.newsproject.data.Category +import com.example.newsproject.ui.ItemClickListener + +class CategoryListAdapter(val listener: ItemClickListener) : RecyclerView.Adapter() { + private var list: List = listOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryListItemViewHolder { + val view = LayoutInflater + .from(parent.context) + .inflate(R.layout.category_list_item, parent, false) + return CategoryListItemViewHolder(view) + } + + override fun onBindViewHolder(holder: CategoryListItemViewHolder, position: Int) { + holder.bind(list.get(position), listener) + } + + override fun getItemCount() = list.size + + @SuppressLint("NotifyDataSetChanged") + fun updateList(categoryList: List) { + list = categoryList + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/com/example/newsproject/ui/categoryList/recycler/CategoryListDecorator.kt b/app/src/main/java/com/example/newsproject/ui/categoryList/recycler/CategoryListDecorator.kt new file mode 100644 index 0000000..63468df --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/categoryList/recycler/CategoryListDecorator.kt @@ -0,0 +1,39 @@ +package com.example.newsproject.ui.categoryList.recycler; + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + + +class CategoryListDecorator( + private val spanCount: Int = 1, + private val spacing: Float = 0F, + private val includeEdge: Boolean = false +) : ItemDecoration() { + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildAdapterPosition(view) + val column = position % spanCount + val spacingInt = spacing.toInt() + if (includeEdge) { + outRect.left = spacingInt - column * spacingInt / spanCount + outRect.right = (column + 1) * spacingInt / spanCount + if (position < spanCount) { + outRect.top = spacingInt + } + outRect.bottom = spacingInt + } else { + outRect.left = column * spacingInt / spanCount + outRect.right = spacingInt - (column + 1) * spacingInt / spanCount + if (position >= spanCount) { + outRect.top = spacingInt + } + } + } +} diff --git a/app/src/main/java/com/example/newsproject/ui/categoryList/recycler/CategoryListItemViewHolder.kt b/app/src/main/java/com/example/newsproject/ui/categoryList/recycler/CategoryListItemViewHolder.kt new file mode 100644 index 0000000..97a1c97 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/categoryList/recycler/CategoryListItemViewHolder.kt @@ -0,0 +1,21 @@ +package com.example.newsproject.ui.categoryList.recycler + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.newsproject.R +import com.example.newsproject.data.Category +import com.example.newsproject.ui.ItemClickListener + +class CategoryListItemViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { + private val nameView: TextView = view.findViewById(R.id.category_name) + + fun bind(category: Category? = null, lister: ItemClickListener) { + if (category != null) { + view.setOnClickListener { + lister.onItemClicked(category.id) + } + nameView.text = category.name + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/news/NewsFragment.kt b/app/src/main/java/com/example/newsproject/ui/news/NewsFragment.kt new file mode 100644 index 0000000..9eda863 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/news/NewsFragment.kt @@ -0,0 +1,45 @@ +package com.example.newsproject.ui.news + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import com.example.newsproject.databinding.FragmentNewsBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class NewsFragment : Fragment() { + private val TAG = "MyNewsFragment" + val args: NewsFragmentArgs by navArgs() + + private var _binding: FragmentNewsBinding? = null + private val binding get() = _binding!! + + val viewModel: NewsViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Log.d(TAG, "onCreateView called") + _binding = FragmentNewsBinding.inflate(inflater, container, false) + viewModel.news.observe(viewLifecycleOwner) { + Log.d(TAG, "News data was changed") + binding.newsTitle.text = it.title + binding.newsShortDescription.text = it.shortDescription + binding.newsPage.loadData(it.fullDescription, "text/html; charset=utf-8", "UTF-8") + } + return binding.root + } + + override fun onDestroyView() { + Log.d(TAG, "onDestroyView called") + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/news/NewsViewModel.kt b/app/src/main/java/com/example/newsproject/ui/news/NewsViewModel.kt new file mode 100644 index 0000000..18c0960 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/news/NewsViewModel.kt @@ -0,0 +1,8 @@ +package com.example.newsproject.ui.news + +import androidx.lifecycle.MutableLiveData +import com.example.newsproject.data.News + +interface NewsViewModel { + val news: MutableLiveData +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/news/NewsViewModelImpl.kt b/app/src/main/java/com/example/newsproject/ui/news/NewsViewModelImpl.kt new file mode 100644 index 0000000..1fb2830 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/news/NewsViewModelImpl.kt @@ -0,0 +1,40 @@ +package com.example.newsproject.ui.news + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.example.newsproject.data.News +import com.example.newsproject.data.NewsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class NewsViewModelImpl @Inject constructor( + private val repository: NewsRepository, + private val state: SavedStateHandle +) : ViewModel(), + NewsViewModel { + private val TAG = "MyNewsViewModel" + override val news: MutableLiveData = MutableLiveData() + val newsId = state.get("newsId") ?: -1 + + init { + Log.d(TAG, "was initialized") + repository.getNews( + newsId, + onSuccess = { + Log.d(TAG, "getNews onSuccess called") + news.value = it + }, + onPartialSuccess = { + Log.d(TAG, "getNews onPartialSuccess called") + news.value = it + }, + onFailure = { + Log.d(TAG, "getNews onFailure called") + //TODO not yet impl + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/newsList/NewsListFragment.kt b/app/src/main/java/com/example/newsproject/ui/newsList/NewsListFragment.kt new file mode 100644 index 0000000..72a3123 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/newsList/NewsListFragment.kt @@ -0,0 +1,91 @@ +package com.example.newsproject.ui.newsList + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.newsproject.R +import com.example.newsproject.databinding.FragmentNewsListBinding +import com.example.newsproject.ui.ItemClickListener +import com.example.newsproject.ui.newsList.recycler.NewsListAdapter +import com.example.newsproject.ui.newsList.recycler.NewsListDecorator +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class NewsListFragment : + Fragment(), + ItemClickListener { + private val TAG = "MyNewsListFragment" + val args: NewsListFragmentArgs by navArgs() + + private var _binding: FragmentNewsListBinding? = null + private val binding get() = _binding!! + + val viewModel: NewsListViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Log.d(TAG, "onCreateView called") + _binding = FragmentNewsListBinding.inflate(inflater, container, false) + viewModel.onCreateView() + val newsAdapter = NewsListAdapter(this) + binding.newsList.apply { + layoutManager = LinearLayoutManager(context) + adapter = newsAdapter + val contentMargin = + (resources.getDimension(R.dimen.content_margin) / resources.displayMetrics.density).toInt() + addItemDecoration( + NewsListDecorator( + contentMargin, + contentMargin, + contentMargin + ) + ) + /* + pretty lazy implementation of paging :/ + It's bad because: + 1. Do something on each scroll can drastically worsen performance + 2. ScrollListener sees each gesture as multiple inputs and have time to call getNewPage several times + */ + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState); + if (!recyclerView.canScrollVertically(1)) { + viewModel.getNewPage() + } + } + }) + } + viewModel.list.observe(viewLifecycleOwner) { + Log.d(TAG, "NewsList data was changed") + newsAdapter.updateList(it) + } + viewModel.navEvent.observe(viewLifecycleOwner) { + Log.d(TAG, "NavEvent was called") + binding.root.findNavController().navigate(it) + } + return binding.root + } + + override fun onDestroyView() { + Log.d(TAG, "onDestroyView called") + super.onDestroyView() + _binding = null + viewModel.onDestroyView() + } + + override fun onItemClicked(id: Long) { + viewModel.onItemClicked(id) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/newsList/NewsListViewModel.kt b/app/src/main/java/com/example/newsproject/ui/newsList/NewsListViewModel.kt new file mode 100644 index 0000000..afe79ca --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/newsList/NewsListViewModel.kt @@ -0,0 +1,15 @@ +package com.example.newsproject.ui.newsList + +import androidx.lifecycle.MutableLiveData +import androidx.navigation.NavDirections +import com.example.newsproject.data.News +import com.example.newsproject.utils.SingleLiveEvent + +interface NewsListViewModel { + val list: MutableLiveData> + val navEvent: SingleLiveEvent + fun onCreateView() + fun getNewPage() + fun onDestroyView() + fun onItemClicked(newsId: Long) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/newsList/NewsListViewModelImpl.kt b/app/src/main/java/com/example/newsproject/ui/newsList/NewsListViewModelImpl.kt new file mode 100644 index 0000000..8e1f1b7 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/newsList/NewsListViewModelImpl.kt @@ -0,0 +1,74 @@ +package com.example.newsproject.ui.newsList + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.navigation.NavDirections +import com.example.newsproject.utils.SingleLiveEvent +import com.example.newsproject.data.News +import com.example.newsproject.data.NewsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class NewsListViewModelImpl @Inject constructor( + private val repository: NewsRepository, + private val state: SavedStateHandle +) : ViewModel(), + NewsListViewModel { + private val TAG = "MyNewsListViewModel" + + override val list: MutableLiveData> = MutableLiveData() + override val navEvent: SingleLiveEvent = SingleLiveEvent() + private val categoryId = state.get("categoryId") ?: -1 + //if nextPage == null -> last page loading returned empty list aka it's the last page + private var nextPage: Int? = 0 + + init { + Log.d(TAG, "was initialized") + } + + override fun onCreateView() { + list.value = mutableListOf() + getNewPage() + } + + /* + Load next page, viewModel pae count will be increased by 1 + Call it from Fragment, when user scrolled to the end of the recycler + */ + override fun getNewPage() { + if (nextPage != null) { + repository.getNewsList( + categoryId, + nextPage!!, + onSuccess = { + Log.d(TAG, "getNewsList onSuccess called") + if (it.isNotEmpty()) { + val array = list.value ?: mutableListOf() + array.addAll(it) + //only setValue triggers callback + list.value = array + nextPage = nextPage!! + 1 + } else + nextPage = null + }, + onFailure = { + Log.d(TAG, "getNewsList onFailure called") + //TODO not yet impl + } + ) + } + } + + override fun onDestroyView() { + nextPage = 0 + } + + override fun onItemClicked(newsId: Long) { + Log.d(TAG, "onItemClicked called") + navEvent.value = NewsListFragmentDirections + .actionNewsListFragmentToNewsFragment(newsId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/newsList/recycler/NewsListAdapter.kt b/app/src/main/java/com/example/newsproject/ui/newsList/recycler/NewsListAdapter.kt new file mode 100644 index 0000000..07ba5d3 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/newsList/recycler/NewsListAdapter.kt @@ -0,0 +1,33 @@ +package com.example.newsproject.ui.newsList.recycler + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.example.newsproject.R +import com.example.newsproject.data.News +import com.example.newsproject.ui.ItemClickListener + +class NewsListAdapter(val listener: ItemClickListener) : + RecyclerView.Adapter() { + private var list: List = listOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsListItemViewHolder { + val view = LayoutInflater + .from(parent.context) + .inflate(R.layout.news_list_item, parent, false) + return NewsListItemViewHolder(view) + } + + override fun onBindViewHolder(holder: NewsListItemViewHolder, position: Int) { + holder.bind(list.get(position), listener) + } + + override fun getItemCount() = list.size + + @SuppressLint("NotifyDataSetChanged") + fun updateList(newsList: List) { + list = newsList + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/newsList/recycler/NewsListDecorator.kt b/app/src/main/java/com/example/newsproject/ui/newsList/recycler/NewsListDecorator.kt new file mode 100644 index 0000000..70bd056 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/newsList/recycler/NewsListDecorator.kt @@ -0,0 +1,31 @@ +package com.example.newsproject.ui.newsList.recycler + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +/** + Pass parameters without screen density multiplier + */ +class NewsListDecorator constructor( + private val marginHorizontal: Int = 0, + private val marginVertical: Int = 0, + private val marginBetweenItems: Int = 0 +) : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildLayoutPosition(view); + outRect.right = marginHorizontal + outRect.left = marginHorizontal + if (position == state.itemCount-1) // if item is last + outRect.bottom = marginVertical + if (position == 0) + outRect.top = marginVertical + else + outRect.top = marginBetweenItems + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/ui/newsList/recycler/NewsListItemViewHolder.kt b/app/src/main/java/com/example/newsproject/ui/newsList/recycler/NewsListItemViewHolder.kt new file mode 100644 index 0000000..5c46ff6 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/ui/newsList/recycler/NewsListItemViewHolder.kt @@ -0,0 +1,28 @@ +package com.example.newsproject.ui.newsList.recycler + +import android.annotation.SuppressLint +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.newsproject.R +import com.example.newsproject.data.News +import com.example.newsproject.ui.ItemClickListener +import java.text.SimpleDateFormat + +class NewsListItemViewHolder(private val view: View) : RecyclerView.ViewHolder(view) { + private val titleView: TextView = view.findViewById(R.id.news_title) + private val dateView: TextView = view.findViewById(R.id.news_date) + private val shortDesView: TextView = view.findViewById(R.id.news_shortDescription) + + @SuppressLint("SimpleDateFormat") + fun bind(news: News? = null, lister: ItemClickListener) { + if (news != null) { + view.setOnClickListener { + lister.onItemClicked(news.id) + } + titleView.text = news.title + dateView.text = SimpleDateFormat("dd.MM.yyyy HH:mm").format(news.date) + shortDesView.text = news.shortDescription + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/utils/SingleLiveEvent.kt b/app/src/main/java/com/example/newsproject/utils/SingleLiveEvent.kt new file mode 100644 index 0000000..e744900 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/utils/SingleLiveEvent.kt @@ -0,0 +1,56 @@ +package com.example.newsproject.utils + +import android.util.Log +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A lifecycle-aware observable that sends only new updates after subscription, used for events like + * navigation and Snackbar messages. + * + * + * This avoids a common problem with events: on configuration change (like rotation) an update + * can be emitted if the observer is active. This LiveData only calls the observable if there's an + * explicit call to setValue() or call(). + * + * + * Note that only one observer is going to be notified of changes. + */ +class SingleLiveEvent : MutableLiveData() { + private val mPending = AtomicBoolean(false) + + @MainThread + override fun observe(owner: LifecycleOwner, observer: Observer) { + if (hasActiveObservers()) { + Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") + } + // Observe the internal MutableLiveData + super.observe(owner, + Observer { t: T -> + if (mPending.compareAndSet(true, false)) { + observer.onChanged(t) + } + }) + } + + @MainThread + override fun setValue(t: T?) { + mPending.set(true) + super.setValue(t) + } + + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + fun call() { + value = null + } + + companion object { + private const val TAG = "SingleLiveEvent" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/newsproject/utils/SpanCount.kt b/app/src/main/java/com/example/newsproject/utils/SpanCount.kt new file mode 100644 index 0000000..35b7bb1 --- /dev/null +++ b/app/src/main/java/com/example/newsproject/utils/SpanCount.kt @@ -0,0 +1,17 @@ +package com.example.newsproject.utils; + +import android.content.Context + +/** + Pass parameters with screen density multiplier. + resources.getDimension() will return Float = (dp value * screen density) + just pass it as itemWidth + */ +object SpanCount { + fun getSpanCount(context: Context, itemWidth: Float): Int { + val displayMetrics = context.resources.displayMetrics + val dpWidth = displayMetrics.widthPixels / displayMetrics.density + val itemTrueWidth = itemWidth / displayMetrics.density + return (dpWidth / itemTrueWidth).toInt() + } +} diff --git a/app/src/main/res/anim/slide_in_left.xml b/app/src/main/res/anim/slide_in_left.xml new file mode 100644 index 0000000..85901fa --- /dev/null +++ b/app/src/main/res/anim/slide_in_left.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml new file mode 100644 index 0000000..91ecf0b --- /dev/null +++ b/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_left.xml b/app/src/main/res/anim/slide_out_left.xml new file mode 100644 index 0000000..891007f --- /dev/null +++ b/app/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml new file mode 100644 index 0000000..b4be277 --- /dev/null +++ b/app/src/main/res/anim/slide_out_right.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b773ac3 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/category_list_item.xml b/app/src/main/res/layout/category_list_item.xml new file mode 100644 index 0000000..bca871a --- /dev/null +++ b/app/src/main/res/layout/category_list_item.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_category_list.xml b/app/src/main/res/layout/fragment_category_list.xml new file mode 100644 index 0000000..5480f07 --- /dev/null +++ b/app/src/main/res/layout/fragment_category_list.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_news.xml b/app/src/main/res/layout/fragment_news.xml new file mode 100644 index 0000000..d49340a --- /dev/null +++ b/app/src/main/res/layout/fragment_news.xml @@ -0,0 +1,44 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_news_list.xml b/app/src/main/res/layout/fragment_news_list.xml new file mode 100644 index 0000000..c7d40c8 --- /dev/null +++ b/app/src/main/res/layout/fragment_news_list.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/news_list_item.xml b/app/src/main/res/layout/news_list_item.xml new file mode 100644 index 0000000..bbb4bcd --- /dev/null +++ b/app/src/main/res/layout/news_list_item.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml new file mode 100644 index 0000000..759b1c9 --- /dev/null +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..0fc4d07 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..5720f3e --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,16 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #7b7b7b + #434343 + #e9e9e9 + #e1e1e1 + #dcdcdc + #FFFFFFFF + #FF000000 + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..c51079a --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,19 @@ + + + 16dp + 8dp + 16dp + 8dp + 20dp + 4dp + 150dp + 85dp + 2 + 1 + 5dp + + + 20sp + 12sp + 16sp + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..479511f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + NewsProject + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c9fba20 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..b5fede5 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + testtask.sebbia.com + + \ No newline at end of file diff --git a/app/src/test/java/com/example/newsproject/ExampleUnitTest.kt b/app/src/test/java/com/example/newsproject/ExampleUnitTest.kt new file mode 100644 index 0000000..587598d --- /dev/null +++ b/app/src/test/java/com/example/newsproject/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.newsproject + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..bbbe07e --- /dev/null +++ b/build.gradle @@ -0,0 +1,18 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + } + dependencies { + classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.4.1") + classpath("com.google.dagger:hilt-android-gradle-plugin:2.38.1") + } +} +plugins { + id 'com.android.application' version '7.1.1' apply false + id 'com.android.library' version '7.1.1' apply false + id 'org.jetbrains.kotlin.android' version '1.6.10' apply false +} +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..cd0519b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +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 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..0aaae44 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Feb 11 21:51:33 MSK 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..139e70b --- /dev/null +++ b/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "NewsProject" +include ':app'