diff --git a/app/build.gradle b/app/build.gradle index 2e827a0..d06a24f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,41 +40,44 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'androidx.core:core-ktx:1.3.2' - implementation 'androidx.appcompat:appcompat:1.2.0' - implementation 'com.google.android.material:material:1.3.0' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation "androidx.core:core-ktx:$core_ktx" + implementation "androidx.appcompat:appcompat:$appcompat" + implementation "com.google.android.material:material:$material" + implementation "androidx.constraintlayout:constraintlayout:$constraint_layout" testImplementation 'junit:junit:4.+' - androidTestImplementation 'androidx.test.ext:junit:1.1.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + androidTestImplementation "androidx.test.ext:junit:$junit" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso" // Navigation implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion" implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion" // Koin - implementation "org.koin:koin-android:2.0.1" - implementation 'org.koin:koin-androidx-viewmodel:2.0.1' - implementation 'org.koin:koin-androidx-scope:2.0.1' + implementation "org.koin:koin-android:$koin" + implementation "org.koin:koin-androidx-viewmodel:$koin" + implementation "org.koin:koin-androidx-scope:$koin" //Retrofit - implementation 'com.squareup.retrofit2:retrofit:2.6.1' - implementation 'com.squareup.okhttp3:logging-interceptor:3.11.0' - - // Gson - implementation 'com.squareup.retrofit2:converter-gson:2.6.1' + implementation "com.squareup.retrofit2:retrofit:$retrofit" + implementation "com.squareup.retrofit2:converter-gson:$retrofit" + implementation "com.squareup.okhttp3:logging-interceptor:$okhttp" //Glide - implementation 'com.github.bumptech.glide:glide:4.11.0' - kapt 'com.github.bumptech.glide:compiler:4.11.0' + implementation "com.github.bumptech.glide:glide:$glide" + kapt "com.github.bumptech.glide:compiler:$glide" //Hawk - implementation 'com.orhanobut:hawk:2.0.1' + implementation "com.orhanobut:hawk:$hawk" //Timber - implementation 'com.jakewharton.timber:timber:4.7.1' + implementation "com.jakewharton.timber:timber:$timber" //Calligraphy - implementation 'io.github.inflationx:calligraphy3:3.1.1' - implementation 'io.github.inflationx:viewpump:2.0.3' + implementation "io.github.inflationx:calligraphy3:$calligraphy" + implementation "io.github.inflationx:viewpump:$viewPump" + + implementation "androidx.room:room-runtime:$room_version" + kapt "androidx.room:room-compiler:$room_version" + implementation "androidx.room:room-ktx:$room_version" + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c5e3c3..fe7b0ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,13 +6,12 @@ + android:theme="@style/AppTheme.FullScreen"> diff --git a/app/src/main/java/com/base_android_template/App.kt b/app/src/main/java/com/base_android_template/App.kt index c883b18..7ebce55 100644 --- a/app/src/main/java/com/base_android_template/App.kt +++ b/app/src/main/java/com/base_android_template/App.kt @@ -3,6 +3,7 @@ package com.base_android_template import android.app.Application import android.content.res.Configuration import com.base_android_template.di.getAppModules +import com.base_android_template.shared.HawkKeys import com.base_android_template.shared.Locales import com.base_android_template.utils.language.LocaleUtils import com.orhanobut.hawk.Hawk @@ -22,10 +23,10 @@ class App : Application() { modules(getAppModules()) } - initLocale() - Hawk.init(this).build() + initLocale() + initCalligraphy() } @@ -35,7 +36,7 @@ class App : Application() { } private fun initLocale() { - val locale = Locale(Locales.ENGLISH) + val locale = Locale(Hawk.get(HawkKeys.HAWK_PREF_LOCALE, Locales.ENGLISH)) LocaleUtils.setLocale(locale) LocaleUtils.updateConfig(this, baseContext.resources.configuration) } diff --git a/app/src/main/java/com/base_android_template/MainActivity.kt b/app/src/main/java/com/base_android_template/MainActivity.kt index 964202b..0f16de5 100644 --- a/app/src/main/java/com/base_android_template/MainActivity.kt +++ b/app/src/main/java/com/base_android_template/MainActivity.kt @@ -1,12 +1,11 @@ package com.base_android_template import android.os.Bundle -import android.view.Menu -import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity -import com.base_android_template.shared.Locales +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.NavigationUI import com.base_android_template.utils.language.LocaleUtils -import java.util.Locale +import com.google.android.material.bottomnavigation.BottomNavigationView class MainActivity : AppCompatActivity() { @@ -18,26 +17,20 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.language_menu, menu) - return true + setUpNavigation() } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.english -> { - LocaleUtils.setLocale(Locale(Locales.ENGLISH)) - recreate() - true - } - R.id.romanian -> { - LocaleUtils.setLocale(Locale(Locales.ROMANIAN)) - recreate() - true - } - else -> super.onOptionsItemSelected(item) + private fun setUpNavigation() { + val bottomNavigationView = findViewById(R.id.bottom_navigation) + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.main_nav_host_fragment) as NavHostFragment? + navHostFragment?.navController?.let { + NavigationUI.setupWithNavController( + bottomNavigationView, + it + ) } } + } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/api/GithubUsersApi.kt b/app/src/main/java/com/base_android_template/api/GithubUsersApi.kt deleted file mode 100644 index ff6b8d4..0000000 --- a/app/src/main/java/com/base_android_template/api/GithubUsersApi.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.base_android_template.api - -import com.base_android_template.model.response.GithubUserResponse -import com.base_android_template.shared.network.ApiResponse -import retrofit2.http.GET - -interface GithubUsersApi { - - @GET("/users") - suspend fun getGithubUsers(): ApiResponse, Error> -} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/base/BaseActivity.kt b/app/src/main/java/com/base_android_template/base/BaseActivity.kt deleted file mode 100644 index 347ada5..0000000 --- a/app/src/main/java/com/base_android_template/base/BaseActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.base_android_template.base - -import androidx.appcompat.app.AppCompatActivity - -class BaseActivity : AppCompatActivity() { -} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/base/BaseFragment.kt b/app/src/main/java/com/base_android_template/base/BaseFragment.kt index 72f6c67..0bb9bd7 100644 --- a/app/src/main/java/com/base_android_template/base/BaseFragment.kt +++ b/app/src/main/java/com/base_android_template/base/BaseFragment.kt @@ -4,21 +4,51 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil import androidx.databinding.ViewDataBinding import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.navigation.fragment.findNavController import com.base_android_template.BR +import com.base_android_template.shared.loading.UILoading import com.base_android_template.shared.model.NavigationCommand +import org.koin.android.ext.android.inject +/** + * Represents the base class that will be extended by any Fragment in the app. + * It receives the corresponding ViewDataBinding and ViewModel generic types + * and the id of the layout to be inflated + * + * @param layoutResId Int. The id of the layout that defines the structure + * for the user interface of the fragment + */ abstract class BaseFragment(@LayoutRes private val layoutResId: Int) : Fragment() { private var binding: VB? = null protected abstract val viewModel: VM + private val loadingDialog: UILoading by inject() + /** + * Receives the value from loading LiveData variable inside BaseViewModel + * and display the loading progress bar if value is true, and hide it otherwise + */ + private val loadingObserver: Observer = Observer { showLoading -> + if (showLoading) { + loadingDialog.show() + return@Observer + } + + loadingDialog.hide() + } + + /** + * Receives the value from navigationCommand LiveData variable inside BaseViewModel + * and, depending on NavigationCommand, proceed the navigation + */ private val commandObserver = Observer { command -> if (command != null) { when (command) { @@ -29,10 +59,45 @@ abstract class BaseFragment(@LayoutRes findNavController().navigate(command.direction) } } - viewModel.clearLastNavigationCommand() } } + /** + * Receives the value from message LiveData variable inside BaseViewModel + * and displays the messages in a Toast + */ + private val messageObserver = Observer { + (activity as? AppCompatActivity)?.apply { + if (it.isNotEmpty()) { + Toast.makeText(this, it, Toast.LENGTH_LONG).show() + } + } + } + + /** + * Receives the value from messageResId LiveData variable inside BaseViewModel + * and get the corresponding string to the id and displays the messages in a Toast + */ + private val messageResIdObserver = Observer { + (activity as? AppCompatActivity)?.apply { + if (it != -1) { + if (getString(it) != "") { + Toast.makeText(this, it, Toast.LENGTH_LONG).show() + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + context?.let { loadingDialog.init(it) } + } + + /** + * Using DataBindingUtil, inflate the layout by creating the corresponding ViewDataBinding + * Set always used "viewModel" DataBinding variable + */ override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -49,14 +114,21 @@ abstract class BaseFragment(@LayoutRes viewModel.apply { navigationCommand.observe(viewLifecycleOwner, commandObserver) + loading.observe(viewLifecycleOwner, loadingObserver) + message.observe(viewLifecycleOwner, messageObserver) + messageResId.observe(viewLifecycleOwner, messageResIdObserver) } } + /** + * Access the binding object from all subclasses + */ protected fun requireBinding(): VB = binding ?: throw NullPointerException("View is in destroyed state and the Binding is null") override fun onDestroyView() { super.onDestroyView() + loadingDialog.cancel() binding = null } diff --git a/app/src/main/java/com/base_android_template/base/BaseViewModel.kt b/app/src/main/java/com/base_android_template/base/BaseViewModel.kt index 8726f74..87dc77e 100644 --- a/app/src/main/java/com/base_android_template/base/BaseViewModel.kt +++ b/app/src/main/java/com/base_android_template/base/BaseViewModel.kt @@ -5,23 +5,75 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.navigation.NavDirections import com.base_android_template.shared.model.NavigationCommand +import com.base_android_template.utils.SingleLiveEvent +/** + * Represents the base class that will be extended by any ViewModel in the app. + * It contains public methods that can be called from all ViewModels in the hierarchy + * to send commands to the BaseFragment + * + */ open class BaseViewModel : ViewModel() { - val navigationCommand: LiveData + val navigationCommand: SingleLiveEvent get() = _navigationCommand + val loading: SingleLiveEvent + get() = _loading + val message: SingleLiveEvent + get() = _message + val messageResId: SingleLiveEvent + get() = _messageResId - private val _navigationCommand = MutableLiveData() + private val _navigationCommand = SingleLiveEvent() + private val _loading = SingleLiveEvent() + private val _message = SingleLiveEvent() + private val _messageResId = SingleLiveEvent() + /** + * Call this method when want to navigate between fragments via NavDirections + * + * @param direction NavDirections. The generated method for navigation action + * that you've defined in the nav graph + */ fun postNavigationCommand(direction: NavDirections) { _navigationCommand.postValue(NavigationCommand.PerformNavAction(direction)) } + /** + * Call this method when want to navigate up in fragments + * + */ fun postNavigateUpCommand() { _navigationCommand.postValue(NavigationCommand.PerformNavUp) } - fun clearLastNavigationCommand() { - _navigationCommand.value = null + /** + * Call this method when want to display a message in a Toast + * + * @param toDisplay String. The message to be displayed + */ + fun postMessage(toDisplay: String) { + _message.postValue(toDisplay) } + + /** + * Call this method when want to display a message in a Toast + * + * @param id Int. The id of the string to be displayed + */ + fun postMessageResId(id: Int) { + _messageResId.postValue(id) + } + + /** + * Call this method when want to show/hide the loading progress bar + * + * @param loading Boolean. If: + * - true: show the loading progress bar + * - false: hide th loading progress bar + */ + fun postLoading(loading: Boolean) { + _loading.postValue(loading) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/di/ApiModule.kt b/app/src/main/java/com/base_android_template/di/ApiModule.kt index e192b32..bd04cb3 100644 --- a/app/src/main/java/com/base_android_template/di/ApiModule.kt +++ b/app/src/main/java/com/base_android_template/di/ApiModule.kt @@ -1,12 +1,19 @@ package com.base_android_template.di -import com.base_android_template.api.GithubUsersApi +import com.base_android_template.remote.GithubUsersApi +import com.base_android_template.remote.GithubUsersListRemote +import com.base_android_template.remote.GithubUsersListRemoteImpl import org.koin.dsl.module import retrofit2.Retrofit val apiModule = module { - fun provideUserApi(retrofit: Retrofit): GithubUsersApi { - return retrofit.create(GithubUsersApi::class.java) + fun provideUserApi(retrofit: Retrofit): GithubUsersApi = + retrofit.create(GithubUsersApi::class.java) + + single { + GithubUsersListRemoteImpl( + githubUsersListService = get() + ) } single { provideUserApi(get()) } diff --git a/app/src/main/java/com/base_android_template/di/AppModule.kt b/app/src/main/java/com/base_android_template/di/AppModule.kt index 145cc69..a205956 100644 --- a/app/src/main/java/com/base_android_template/di/AppModule.kt +++ b/app/src/main/java/com/base_android_template/di/AppModule.kt @@ -1,5 +1,6 @@ package com.base_android_template.di +import com.base_android_template.feature.settings.settingsModule import com.base_android_template.feature.github_users.githubUsersModule -fun getAppModules() = createCoreModules() + githubUsersModule \ No newline at end of file +fun getAppModules() = createCoreModules() + githubUsersModule + settingsModule \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/di/CoreModules.kt b/app/src/main/java/com/base_android_template/di/CoreModules.kt index 311a65e..f76d4a1 100644 --- a/app/src/main/java/com/base_android_template/di/CoreModules.kt +++ b/app/src/main/java/com/base_android_template/di/CoreModules.kt @@ -1,9 +1,10 @@ package com.base_android_template.di import com.base_android_template.shared.BASE_URL +import com.base_android_template.shared.loading.UILoading +import com.base_android_template.shared.loading.UILoadingImplementation import com.base_android_template.shared.provider.PreferencesProvider import com.base_android_template.shared.provider.PreferencesProviderImpl -import com.base_android_template.utils.network.NetworkResponseAdapterFactory import com.google.gson.FieldNamingPolicy import com.google.gson.GsonBuilder import okhttp3.OkHttpClient @@ -27,13 +28,15 @@ val coreModules = module { factory { Retrofit.Builder() .baseUrl(BASE_URL) - .addCallAdapterFactory(NetworkResponseAdapterFactory()) .addConverterFactory(GsonConverterFactory.create(get())) .client(get()) .build() } factory { PreferencesProviderImpl() } + + factory { UILoadingImplementation() } } -fun createCoreModules() = coreModules + apiModule + repositoryModule + useCaseModule \ No newline at end of file +fun createCoreModules() = + coreModules + apiModule + repositoryModule + useCaseModule + databaseModule \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/di/DatabaseModule.kt b/app/src/main/java/com/base_android_template/di/DatabaseModule.kt new file mode 100644 index 0000000..7958701 --- /dev/null +++ b/app/src/main/java/com/base_android_template/di/DatabaseModule.kt @@ -0,0 +1,14 @@ +package com.base_android_template.di + +import android.content.Context +import androidx.room.Room +import com.base_android_template.persistance.database.GithubUsersDatabase +import org.koin.dsl.module + +val databaseModule = module { + fun provideGithubUsersListDatabase(context: Context): GithubUsersDatabase = + Room.databaseBuilder(context, GithubUsersDatabase::class.java, "github_users_database") + .build() + + single { provideGithubUsersListDatabase(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/di/RepositoryModule.kt b/app/src/main/java/com/base_android_template/di/RepositoryModule.kt index 25fe265..c8cee85 100644 --- a/app/src/main/java/com/base_android_template/di/RepositoryModule.kt +++ b/app/src/main/java/com/base_android_template/di/RepositoryModule.kt @@ -5,5 +5,5 @@ import com.base_android_template.repository.GithubUsersRepositoryImpl import org.koin.dsl.module val repositoryModule = module { - factory { GithubUsersRepositoryImpl(githubUsersApi = get()) } + single { GithubUsersRepositoryImpl(githubUsersListRemote = get()) } } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/di/UseCaseModule.kt b/app/src/main/java/com/base_android_template/di/UseCaseModule.kt index bbdc795..50fc58b 100644 --- a/app/src/main/java/com/base_android_template/di/UseCaseModule.kt +++ b/app/src/main/java/com/base_android_template/di/UseCaseModule.kt @@ -1,9 +1,20 @@ package com.base_android_template.di +import com.base_android_template.persistance.database.GithubUsersDatabase +import com.base_android_template.repository.GithubUsersRepository import com.base_android_template.usecase.GetGithubUsersUseCase import com.base_android_template.usecase.GetGithubUsersUseCaseImpl import org.koin.dsl.module val useCaseModule = module { - factory { GetGithubUsersUseCaseImpl(githubUsersRepository = get()) } + + fun getGithubUsersUseCase( + githubUsersRepository: GithubUsersRepository, + githubUsersDatabase: GithubUsersDatabase, + ): GetGithubUsersUseCase = GetGithubUsersUseCaseImpl( + githubUsersRepository = githubUsersRepository, + githubUsersListDao = githubUsersDatabase.githubUsersListDao() + ) + + factory { getGithubUsersUseCase(get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersCallback.kt b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersCallback.kt index 13653f1..62ee87e 100644 --- a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersCallback.kt +++ b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersCallback.kt @@ -1,17 +1,17 @@ package com.base_android_template.feature.github_users import androidx.recyclerview.widget.DiffUtil -import com.base_android_template.model.response.GithubUserResponse +import com.base_android_template.model.entity.GithubUserEntity -class GithubUsersCallback : DiffUtil.ItemCallback() { +class GithubUsersCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: GithubUserResponse, - newItem: GithubUserResponse + oldItem: GithubUserEntity, + newItem: GithubUserEntity ): Boolean = oldItem.id == newItem.id override fun areContentsTheSame( - oldItem: GithubUserResponse, - newItem: GithubUserResponse + oldItem: GithubUserEntity, + newItem: GithubUserEntity ): Boolean = oldItem == newItem } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersFragment.kt b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersFragment.kt index 25190e4..a16c5e4 100644 --- a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersFragment.kt +++ b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersFragment.kt @@ -9,5 +9,4 @@ class GithubUsersFragment : BaseFragment(R.layout.fragment_github_users) { override val viewModel: GithubUsersViewModel by viewModel() - } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersListAdapter.kt b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersListAdapter.kt index 42b92af..c077182 100644 --- a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersListAdapter.kt +++ b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersListAdapter.kt @@ -4,10 +4,10 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.ListAdapter import com.base_android_template.databinding.ItemGithubUserBinding -import com.base_android_template.model.response.GithubUserResponse +import com.base_android_template.model.entity.GithubUserEntity class GithubUsersListAdapter : - ListAdapter(GithubUsersCallback()) { + ListAdapter(GithubUsersCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GithubUsersListViewHolder { val inflater = LayoutInflater.from(parent.context) diff --git a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersListViewHolder.kt b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersListViewHolder.kt index d378298..7db0150 100644 --- a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersListViewHolder.kt +++ b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersListViewHolder.kt @@ -2,13 +2,15 @@ package com.base_android_template.feature.github_users import androidx.recyclerview.widget.RecyclerView import com.base_android_template.databinding.ItemGithubUserBinding -import com.base_android_template.model.response.GithubUserResponse +import com.base_android_template.model.entity.GithubUserEntity class GithubUsersListViewHolder(private val binding: ItemGithubUserBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(githubUserResponse: GithubUserResponse) { - binding.item = githubUserResponse - binding.executePendingBindings() + fun bind(githubUserItem: GithubUserEntity) { + binding.apply { + item = githubUserItem + executePendingBindings() + } } } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersViewModel.kt b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersViewModel.kt index f524810..f012bf8 100644 --- a/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersViewModel.kt +++ b/app/src/main/java/com/base_android_template/feature/github_users/GithubUsersViewModel.kt @@ -1,10 +1,11 @@ package com.base_android_template.feature.github_users import androidx.lifecycle.viewModelScope +import com.base_android_template.R import com.base_android_template.base.BaseViewModel import com.base_android_template.usecase.GetGithubUsersUseCase +import com.base_android_template.usecase.GithubUsersError import kotlinx.coroutines.launch -import timber.log.Timber class GithubUsersViewModel( private val getGithubUsersUseCase: GetGithubUsersUseCase @@ -14,19 +15,52 @@ class GithubUsersViewModel( val githubUsersListAdapter = GithubUsersListAdapter() init { - getGithubUsers() + getLocalCartItems() } - private fun getGithubUsers() { + private fun getLocalCartItems() { + postLoading(true) viewModelScope.launch { - getGithubUsersUseCase.getGithubUsers().fold({ - githubUsersListAdapter.submitList(it) - }, { - Timber.d( - GithubUsersViewModel::class.simpleName, - "Error fetching Github users list" - ) - }) + getGithubUsersUseCase.getLocalGithubUsers().fold( + { + handleException(it) + }, + { + githubUsersListAdapter.submitList(it) + postLoading(false) + } + ) + } + } + + private fun getRemoteGithubUsers() { + viewModelScope.launch { + getGithubUsersUseCase.getRemoteAndSaveLocalGithubUsers().fold( + { + postLoading(false) + handleException(it) + }, + { + githubUsersListAdapter.submitList(it) + postLoading(false) + } + ) + } + } + + private fun handleException(githubUsersError: GithubUsersError) { + postLoading(false) + handleGithubUsersError(githubUsersError) + } + + private fun handleGithubUsersError(githubUsersError: GithubUsersError) { + when (githubUsersError) { + is GithubUsersError.EmptyLocalGithubUsersListException -> { + getRemoteGithubUsers() + } + else -> { + postMessageResId(R.string.error_fetching_users_list) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/feature/settings/SettingsFieldsState.kt b/app/src/main/java/com/base_android_template/feature/settings/SettingsFieldsState.kt new file mode 100644 index 0000000..b5b4827 --- /dev/null +++ b/app/src/main/java/com/base_android_template/feature/settings/SettingsFieldsState.kt @@ -0,0 +1,18 @@ +package com.base_android_template.feature.settings + +import androidx.databinding.BaseObservable +import androidx.databinding.Bindable +import com.base_android_template.BR + +data class SettingsFieldsState( + var _englishChecked: Boolean = false +) : + BaseObservable() { + + var englishChecked: Boolean + @Bindable get() = _englishChecked + set(value) { + _englishChecked = value + notifyPropertyChanged(BR.englishChecked) + } +} diff --git a/app/src/main/java/com/base_android_template/feature/settings/SettingsForm.kt b/app/src/main/java/com/base_android_template/feature/settings/SettingsForm.kt new file mode 100644 index 0000000..b3ab538 --- /dev/null +++ b/app/src/main/java/com/base_android_template/feature/settings/SettingsForm.kt @@ -0,0 +1,6 @@ +package com.base_android_template.feature.settings + +class SettingsForm { + + val fields = SettingsFieldsState() +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/feature/settings/SettingsFragment.kt b/app/src/main/java/com/base_android_template/feature/settings/SettingsFragment.kt new file mode 100644 index 0000000..76dd210 --- /dev/null +++ b/app/src/main/java/com/base_android_template/feature/settings/SettingsFragment.kt @@ -0,0 +1,24 @@ +package com.base_android_template.feature.settings + +import android.os.Bundle +import com.base_android_template.R +import com.base_android_template.base.BaseFragment +import com.base_android_template.databinding.FragmentSettingsBinding +import org.koin.androidx.viewmodel.ext.android.viewModel + +class SettingsFragment : + BaseFragment(R.layout.fragment_settings) { + + override val viewModel: SettingsViewModel by viewModel() + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + viewModel.recreateActivity.observe(viewLifecycleOwner, { + if (it) { + activity?.recreate() + } + }) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/feature/settings/SettingsModule.kt b/app/src/main/java/com/base_android_template/feature/settings/SettingsModule.kt new file mode 100644 index 0000000..e964f4c --- /dev/null +++ b/app/src/main/java/com/base_android_template/feature/settings/SettingsModule.kt @@ -0,0 +1,8 @@ +package com.base_android_template.feature.settings + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val settingsModule = module { + viewModel { SettingsViewModel(preferencesProvider = get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/base_android_template/feature/settings/SettingsViewModel.kt new file mode 100644 index 0000000..2dd27ee --- /dev/null +++ b/app/src/main/java/com/base_android_template/feature/settings/SettingsViewModel.kt @@ -0,0 +1,40 @@ +package com.base_android_template.feature.settings + +import android.widget.RadioGroup +import androidx.core.view.get +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.base_android_template.base.BaseViewModel +import com.base_android_template.shared.Locales +import com.base_android_template.shared.provider.PreferencesProvider +import com.base_android_template.utils.language.LocaleUtils +import java.util.Locale + +class SettingsViewModel(private val preferencesProvider: PreferencesProvider) : BaseViewModel() { + + val settingsForm = SettingsForm() + val recreateActivity: LiveData get() = _recreateActivity + + private val _recreateActivity = MutableLiveData() + + init { + settingsForm.fields.englishChecked = preferencesProvider.getPrefLocale() == Locales.ENGLISH + } + + val languageRadioGroupListener: RadioGroup.OnCheckedChangeListener = + RadioGroup.OnCheckedChangeListener { radioGroup, _ -> + when (radioGroup.checkedRadioButtonId) { + radioGroup[0].id -> changeLanguage(Locales.ENGLISH) + else -> changeLanguage(Locales.ROMANIAN) + } + } + + private fun changeLanguage(language: String) { + LocaleUtils.setLocale(Locale(language)) + preferencesProvider.setPrefLocale(language) + settingsForm.fields.englishChecked = language == Locales.ENGLISH + _recreateActivity.value = true + _recreateActivity.value = false + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/model/entity/GithubUserEntity.kt b/app/src/main/java/com/base_android_template/model/entity/GithubUserEntity.kt new file mode 100644 index 0000000..8db17f6 --- /dev/null +++ b/app/src/main/java/com/base_android_template/model/entity/GithubUserEntity.kt @@ -0,0 +1,65 @@ +package com.base_android_template.model.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index + +@Entity( + primaryKeys = ["id"], + indices = [Index("id")] +) +data class GithubUserEntity( + @ColumnInfo(name = "login") + val login: String, + + @ColumnInfo(name = "id") + val id: Int, + + @ColumnInfo(name = "node_id") + val nodeId: String, + + @ColumnInfo(name = "avatar_url") + val avatarUrl: String, + + @ColumnInfo(name = "gravatar_id") + val grAvatarId: String, + + @ColumnInfo(name = "url") + val url: String, + + @ColumnInfo(name = "html_url") + val htmlUrl: String, + + @ColumnInfo(name = "followers_url") + val followersUrl: String, + + @ColumnInfo(name = "following_url") + val followingUrl: String, + + @ColumnInfo(name = "gists_url") + val gistsUrl: String, + + @ColumnInfo(name = "starred_url") + val starredUrl: String, + + @ColumnInfo(name = "subscriptions_url") + val subscriptionsUrl: String, + + @ColumnInfo(name = "organizations_url") + val organizationsUrl: String, + + @ColumnInfo(name = "repos_url") + val reposUrl: String, + + @ColumnInfo(name = "events_url") + val eventsUrl: String, + + @ColumnInfo(name = "received_events_url") + val receivedEventsUrl: String, + + @ColumnInfo(name = "type") + val type: String, + + @ColumnInfo(name = "site_admin") + val siteAdmin: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/model/response/GithubUserResponse.kt b/app/src/main/java/com/base_android_template/model/response/GithubUserResponse.kt index eb04718..12aa628 100644 --- a/app/src/main/java/com/base_android_template/model/response/GithubUserResponse.kt +++ b/app/src/main/java/com/base_android_template/model/response/GithubUserResponse.kt @@ -4,55 +4,55 @@ import com.google.gson.annotations.SerializedName data class GithubUserResponse( @field:SerializedName("login") - val login: String, + val login: String?, @field:SerializedName("id") val id: Int, @field:SerializedName("node_id") - val nodeId: String, + val nodeId: String?, @field:SerializedName("avatar_url") - val avatarUrl: String, + val avatarUrl: String?, @field:SerializedName("gravatar_id") - val grAvatarId: String, + val grAvatarId: String?, @field:SerializedName("url") - val url: String, + val url: String?, @field:SerializedName("html_url") - val htmlUrl: String, + val htmlUrl: String?, @field:SerializedName("followers_url") - val followersUrl: String, + val followersUrl: String?, @field:SerializedName("following_url") - val followingUrl: String, + val followingUrl: String?, @field:SerializedName("gists_url") - val gistsUrl: String, + val gistsUrl: String?, @field:SerializedName("starred_url") - val starredUrl: String, + val starredUrl: String?, @field:SerializedName("subscriptions_url") - val subscriptionsUrl: String, + val subscriptionsUrl: String?, @field:SerializedName("organizations_url") - val organizationsUrl: String, + val organizationsUrl: String?, @field:SerializedName("repos_url") - val reposUrl: String, + val reposUrl: String?, @field:SerializedName("events_url") - val eventsUrl: String, + val eventsUrl: String?, @field:SerializedName("received_events_url") - val receivedEventsUrl: String, + val receivedEventsUrl: String?, @field:SerializedName("type") - val type: String, + val type: String?, @field:SerializedName("site_admin") val siteAdmin: String diff --git a/app/src/main/java/com/base_android_template/persistance/dao/GithubUsersListDao.kt b/app/src/main/java/com/base_android_template/persistance/dao/GithubUsersListDao.kt new file mode 100644 index 0000000..7177986 --- /dev/null +++ b/app/src/main/java/com/base_android_template/persistance/dao/GithubUsersListDao.kt @@ -0,0 +1,20 @@ +package com.base_android_template.persistance.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.base_android_template.model.entity.GithubUserEntity + +@Dao +interface GithubUsersListDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGithubUsers(usersList: List) + + @Query("SELECT * FROM GithubUserEntity") + suspend fun getGithubUsers(): List? + + @Query("DELETE FROM GithubUserEntity") + suspend fun deleteGithubUsers() +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/persistance/database/GithubUsersDatabase.kt b/app/src/main/java/com/base_android_template/persistance/database/GithubUsersDatabase.kt new file mode 100644 index 0000000..40cee2a --- /dev/null +++ b/app/src/main/java/com/base_android_template/persistance/database/GithubUsersDatabase.kt @@ -0,0 +1,12 @@ +package com.base_android_template.persistance.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.base_android_template.model.entity.GithubUserEntity +import com.base_android_template.persistance.dao.GithubUsersListDao + +@Database(entities = [GithubUserEntity::class], version = 1) +abstract class GithubUsersDatabase : RoomDatabase() { + + abstract fun githubUsersListDao(): GithubUsersListDao +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/remote/GithubUsersApi.kt b/app/src/main/java/com/base_android_template/remote/GithubUsersApi.kt new file mode 100644 index 0000000..adca36e --- /dev/null +++ b/app/src/main/java/com/base_android_template/remote/GithubUsersApi.kt @@ -0,0 +1,11 @@ +package com.base_android_template.remote + +import com.base_android_template.model.response.GithubUserResponse +import retrofit2.Response +import retrofit2.http.GET + +interface GithubUsersApi { + + @GET("/users") + suspend fun getGithubUsers(): Response> +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/remote/GithubUsersListRemote.kt b/app/src/main/java/com/base_android_template/remote/GithubUsersListRemote.kt new file mode 100644 index 0000000..96d8087 --- /dev/null +++ b/app/src/main/java/com/base_android_template/remote/GithubUsersListRemote.kt @@ -0,0 +1,11 @@ +package com.base_android_template.remote + +import com.base_android_template.model.response.GithubUserResponse +import com.base_android_template.utils.Either +import com.base_android_template.usecase.GithubUsersError + +interface GithubUsersListRemote { + + suspend fun getGithubUsersList(): Either> + +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/remote/GithubUsersListRemoteImpl.kt b/app/src/main/java/com/base_android_template/remote/GithubUsersListRemoteImpl.kt new file mode 100644 index 0000000..46d4802 --- /dev/null +++ b/app/src/main/java/com/base_android_template/remote/GithubUsersListRemoteImpl.kt @@ -0,0 +1,39 @@ +package com.base_android_template.remote + +import com.base_android_template.model.response.GithubUserResponse +import com.base_android_template.usecase.GithubUsersError +import com.base_android_template.utils.Either +import retrofit2.Response +import java.io.IOException + +class GithubUsersListRemoteImpl( + private val githubUsersListService: GithubUsersApi +) : GithubUsersListRemote { + + override suspend fun getGithubUsersList(): Either> = + makeRequest { + githubUsersListService.getGithubUsers() + } + + private suspend fun makeRequest(block: suspend () -> Response>): Either> { + return try { + val response = block.invoke() + handleResponse(response) + } catch (exception: IOException) { + Either.Failure(GithubUsersError.GeneralError) + } + } + + private fun handleResponse(response: Response>): Either> { + return if (response.isSuccessful) { + handleSuccess(response) + } else { + Either.Failure(GithubUsersError.GeneralError) + } + } + + private fun handleSuccess(response: Response>): Either.Success> { + val githubUsersList = response.body() ?: listOf() + return Either.Success(githubUsersList) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/repository/GithubUsersRepository.kt b/app/src/main/java/com/base_android_template/repository/GithubUsersRepository.kt index 87a288c..487eb58 100644 --- a/app/src/main/java/com/base_android_template/repository/GithubUsersRepository.kt +++ b/app/src/main/java/com/base_android_template/repository/GithubUsersRepository.kt @@ -1,9 +1,10 @@ package com.base_android_template.repository import com.base_android_template.model.response.GithubUserResponse -import com.base_android_template.shared.network.ApiResponse +import com.base_android_template.utils.Either +import com.base_android_template.usecase.GithubUsersError interface GithubUsersRepository { - suspend fun getGithubUsers(): ApiResponse, Error> + suspend fun getLocalGithubUsers(): Either> } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/repository/GithubUsersRepositoryImpl.kt b/app/src/main/java/com/base_android_template/repository/GithubUsersRepositoryImpl.kt index ddf8ab6..8f3b968 100644 --- a/app/src/main/java/com/base_android_template/repository/GithubUsersRepositoryImpl.kt +++ b/app/src/main/java/com/base_android_template/repository/GithubUsersRepositoryImpl.kt @@ -1,13 +1,15 @@ package com.base_android_template.repository -import com.base_android_template.api.GithubUsersApi import com.base_android_template.model.response.GithubUserResponse -import com.base_android_template.shared.network.ApiResponse +import com.base_android_template.remote.GithubUsersListRemote +import com.base_android_template.utils.Either +import com.base_android_template.usecase.GithubUsersError class GithubUsersRepositoryImpl( - private val githubUsersApi: GithubUsersApi + private val githubUsersListRemote: GithubUsersListRemote ) : GithubUsersRepository { - override suspend fun getGithubUsers(): ApiResponse, Error> = githubUsersApi.getGithubUsers() + override suspend fun getLocalGithubUsers(): Either> = + githubUsersListRemote.getGithubUsersList() } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/shared/loading/UILoading.kt b/app/src/main/java/com/base_android_template/shared/loading/UILoading.kt new file mode 100644 index 0000000..bbbb4ee --- /dev/null +++ b/app/src/main/java/com/base_android_template/shared/loading/UILoading.kt @@ -0,0 +1,10 @@ +package com.base_android_template.shared.loading + +import android.content.Context + +interface UILoading { + fun init(context: Context) + fun show() + fun hide() + fun cancel() +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/shared/loading/UILoadingImpl.kt b/app/src/main/java/com/base_android_template/shared/loading/UILoadingImpl.kt new file mode 100644 index 0000000..8cb698a --- /dev/null +++ b/app/src/main/java/com/base_android_template/shared/loading/UILoadingImpl.kt @@ -0,0 +1,57 @@ +package com.base_android_template.shared.loading + +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.view.Window +import com.base_android_template.R +import timber.log.Timber + +class UILoadingImplementation : UILoading { + + private lateinit var dialog: Dialog + + override fun init(context: Context) { + buildDialog(context) + } + + override fun show() { + try { + dialog.show() + } catch (e: Exception) { + Timber.e(e) + } + } + + private fun buildDialog(context: Context) { + dialog = Dialog(context) + + dialog.apply { + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(R.layout.dialog_loading) + setCancelable(false) + window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + + override fun hide() { + if (!::dialog.isInitialized) + return + + if (dialog.isShowing) { + try { + dialog.hide() + } catch (e: IllegalStateException) { + Timber.e(e) + } + } + } + + override fun cancel() { + if (!::dialog.isInitialized) + return + + dialog.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/shared/model/NavigationCommand.kt b/app/src/main/java/com/base_android_template/shared/model/NavigationCommand.kt index a219377..d37d393 100644 --- a/app/src/main/java/com/base_android_template/shared/model/NavigationCommand.kt +++ b/app/src/main/java/com/base_android_template/shared/model/NavigationCommand.kt @@ -3,6 +3,8 @@ package com.base_android_template.shared.model import androidx.navigation.NavDirections sealed class NavigationCommand { + object PerformNavUp : NavigationCommand() + data class PerformNavAction(val direction: NavDirections) : NavigationCommand() } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/shared/network/ApiResponse.kt b/app/src/main/java/com/base_android_template/shared/network/ApiResponse.kt deleted file mode 100644 index 01285b4..0000000 --- a/app/src/main/java/com/base_android_template/shared/network/ApiResponse.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.base_android_template.shared.network - -import java.io.IOException - -sealed class ApiResponse { - /** - * Success response with body - */ - data class Success(val body: T?) : ApiResponse() - - /** - * Failure response with body - */ - data class ServerError(val body: U, val code: Int) : ApiResponse() - - /** - * Network error - */ - data class NetworkError(val error: IOException) : ApiResponse() - - /** - * For example, json parsing error - */ - data class UnknownError(val error: Throwable?) : ApiResponse() - - fun fold( - funSuccess: (T?) -> Any, - funFailure: (Error) -> Any - ): Any = - when (this) { - is Success -> funSuccess(body) - is ServerError -> funFailure(Error.ServerError(body)) - is NetworkError -> funFailure(Error.NetworkError(error)) - is UnknownError -> funFailure(Error.UnknownError(error)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/shared/network/Error.kt b/app/src/main/java/com/base_android_template/shared/network/Error.kt deleted file mode 100644 index 86f05a7..0000000 --- a/app/src/main/java/com/base_android_template/shared/network/Error.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.base_android_template.shared.network - -import java.io.IOException - -sealed class Error { - data class ServerError(val errorResponse: U) : Error() - - data class NetworkError(val error: IOException) : Error() - - data class UnknownError(val exception: Throwable?) : Error() -} - diff --git a/app/src/main/java/com/base_android_template/usecase/GetGithubUsersUseCase.kt b/app/src/main/java/com/base_android_template/usecase/GetGithubUsersUseCase.kt index 17fc1bd..0908ea2 100644 --- a/app/src/main/java/com/base_android_template/usecase/GetGithubUsersUseCase.kt +++ b/app/src/main/java/com/base_android_template/usecase/GetGithubUsersUseCase.kt @@ -1,9 +1,11 @@ package com.base_android_template.usecase -import com.base_android_template.model.response.GithubUserResponse -import com.base_android_template.shared.network.ApiResponse +import com.base_android_template.model.entity.GithubUserEntity +import com.base_android_template.utils.Either interface GetGithubUsersUseCase { - suspend fun getGithubUsers(): ApiResponse, Error> + suspend fun getRemoteAndSaveLocalGithubUsers(): Either> + + suspend fun getLocalGithubUsers(): Either> } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/usecase/GetGithubUsersUseCaseImpl.kt b/app/src/main/java/com/base_android_template/usecase/GetGithubUsersUseCaseImpl.kt index 41b392f..071bd5d 100644 --- a/app/src/main/java/com/base_android_template/usecase/GetGithubUsersUseCaseImpl.kt +++ b/app/src/main/java/com/base_android_template/usecase/GetGithubUsersUseCaseImpl.kt @@ -1,13 +1,41 @@ package com.base_android_template.usecase -import com.base_android_template.model.response.GithubUserResponse +import com.base_android_template.model.entity.GithubUserEntity +import com.base_android_template.persistance.dao.GithubUsersListDao import com.base_android_template.repository.GithubUsersRepository -import com.base_android_template.shared.network.ApiResponse +import com.base_android_template.utils.Either +import com.base_android_template.utils.doOnSuccess +import com.base_android_template.utils.extensions.mapToGithubUserEntity +import com.base_android_template.utils.map +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch class GetGithubUsersUseCaseImpl internal constructor( - private val githubUsersRepository: GithubUsersRepository + private val githubUsersRepository: GithubUsersRepository, + private val githubUsersListDao: GithubUsersListDao ) : GetGithubUsersUseCase { - override suspend fun getGithubUsers(): ApiResponse, Error> = - githubUsersRepository.getGithubUsers() + override suspend fun getRemoteAndSaveLocalGithubUsers(): Either> = + coroutineScope { + githubUsersRepository.getLocalGithubUsers() + .map { githubUsersList -> + githubUsersList.map { it.mapToGithubUserEntity() } + } + .doOnSuccess { + launch { updateLocalGithubUsersList(it) } + } + } + + override suspend fun getLocalGithubUsers(): Either> { + val list = githubUsersListDao.getGithubUsers() + + return if (list?.isNullOrEmpty() == true) + Either.Failure(GithubUsersError.EmptyLocalGithubUsersListException) + else Either.Success(list) + } + + private suspend fun updateLocalGithubUsersList(list: List) { + githubUsersListDao.insertGithubUsers(list) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/usecase/GithubUsersError.kt b/app/src/main/java/com/base_android_template/usecase/GithubUsersError.kt new file mode 100644 index 0000000..ea53842 --- /dev/null +++ b/app/src/main/java/com/base_android_template/usecase/GithubUsersError.kt @@ -0,0 +1,6 @@ +package com.base_android_template.usecase + +sealed class GithubUsersError { + object EmptyLocalGithubUsersListException : GithubUsersError() + object GeneralError :GithubUsersError() +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/utils/Either.kt b/app/src/main/java/com/base_android_template/utils/Either.kt new file mode 100644 index 0000000..b474071 --- /dev/null +++ b/app/src/main/java/com/base_android_template/utils/Either.kt @@ -0,0 +1,101 @@ +package com.base_android_template.utils + +/** + * Represents a value of one of two possible types (a disjoint union). + * Instances of [Either] are either an instance of [Failure] or [Success]. + * FP Convention dictates that [Failure] is used for "failure" + * and [Success] is used for "success". + * + * @see Failure + * @see Success + * @see Credits to Fernando Cejas. + */ +sealed class Either { + /** * Represents the left side of [Either] class which by convention is a "Exception". */ + data class Failure(val error: L) : Either() + + /** * Represents the right side of [Either] class which by convention is a "Success". */ + data class Success(val data: R) : Either() + + /** + * Returns true if this is a Success, false otherwise. + * @see Success + */ + val isSuccess get() = this is Success + + /** + * Returns true if this is a Failure, false otherwise. + * @see Failure + */ + val isFailure get() = this is Failure + + /** + * Creates a Failure type. + * @see Failure + */ + fun failure(a: L) = Failure(a) + + /** + * Creates a Success type. + * @see Success + */ + fun success(b: R) = Success(b) + + /** + * Applies funFailure if this is a Failure or funSuccess if this is a Success. + * @see Failure + * @see Success + */ + fun fold(funFailure: (L) -> Any, funSuccess: (R) -> Any): Any = + when (this) { + is Failure -> funFailure(error) + is Success -> funSuccess(data) + } +} + +/** + * Composes 2 functions + * See Credits to Alex Hart. + */ +fun ((A) -> B).compose(function: (B) -> C): (A) -> C = { + function(this(it)) +} + +/** + * Success-biased flatMap() FP convention which means that Success is assumed to be the default case + * to operate on. If it is Failure, operations like map, flatMap, ... return the Failure value unchanged. + */ +fun Either.flatMap(function: (R) -> Either): Either = + when (this) { + is Either.Failure -> Either.Failure(error) + is Either.Success -> function(data) + } + +/** + * Success-biased map() FP convention which means that Success is assumed to be the default case + * to operate on. If it is Failure, operations like map, flatMap, ... return the Failure value unchanged. + */ +fun Either.map(function: (R) -> (T)): Either = + this.flatMap(function.compose(::success)) + +/** Returns the value from this `Success` or the given argument if this is a `Failure`. + * Right(12).getOrElse(17) RETURNS 12 and Left(12).getOrElse(17) RETURNS 17. + * Just for unit tests + */ +fun Either.getOrElse(value: R): R = + when (this) { + is Either.Failure -> value + is Either.Success -> data + } + +/** Invokes function if Either is success. + * Returns itself. + */ +fun Either.doOnSuccess(block: (R) -> Any): Either = + when (this) { + is Either.Failure -> Either.Failure(error) + is Either.Success -> { + block(data) + Either.Success(data) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/utils/SingleLiveEvent.kt b/app/src/main/java/com/base_android_template/utils/SingleLiveEvent.kt new file mode 100644 index 0000000..53f2258 --- /dev/null +++ b/app/src/main/java/com/base_android_template/utils/SingleLiveEvent.kt @@ -0,0 +1,68 @@ +package com.base_android_template.utils + +/* + * Copyright 2017 Google Inc. + * + * 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 + * + * http://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. + */ + +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 pending = 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 -> + if (pending.compareAndSet(true, false)) { + observer.onChanged(t) + } + }) + } + + @MainThread + override fun setValue(t: T?) { + pending.set(true) + super.setValue(t) + } + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + fun call() { + value = null + } + companion object { + private val TAG = "SingleLiveEvent" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/utils/binding_adapter/BindingAdapters.kt b/app/src/main/java/com/base_android_template/utils/binding_adapter/BindingAdapters.kt index 8a5e1c2..4d0ae6c 100644 --- a/app/src/main/java/com/base_android_template/utils/binding_adapter/BindingAdapters.kt +++ b/app/src/main/java/com/base_android_template/utils/binding_adapter/BindingAdapters.kt @@ -1,6 +1,7 @@ package com.base_android_template.utils.binding_adapter import android.widget.ImageView +import android.widget.RadioGroup import androidx.appcompat.content.res.AppCompatResources.getDrawable import androidx.databinding.BindingAdapter import androidx.recyclerview.widget.RecyclerView @@ -9,8 +10,10 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.request.RequestOptions -/* - * Binding adapters are responsible for making the appropriate framework calls to set values for views directly inside layout. +/** + * BindingAdapters class is responsible for making the appropriate framework calls to + * set values for views directly inside layout. + * */ object BindingAdapters { @@ -51,4 +54,21 @@ object BindingAdapters { .apply(options) .into(imageView) } + + /** + * Set the RadioGroup.OnCheckedChangeListener on RadioGroup view + * + * @param radioGroup RadioGroup. The RadioGroup to which the OnCheckedChangeListener + * will be attached + * @param listener RadioGroup.OnCheckedChangeListener. The listener that will be + * invoked when the checked radio button changed in this group + */ + @BindingAdapter("app:setRadioGroupListener") + @JvmStatic + fun setRadioGroupListener( + radioGroup: RadioGroup, + listener: RadioGroup.OnCheckedChangeListener + ) { + radioGroup.setOnCheckedChangeListener(listener) + } } \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/utils/extensions/GithubUserResponseExtesion.kt b/app/src/main/java/com/base_android_template/utils/extensions/GithubUserResponseExtesion.kt new file mode 100644 index 0000000..ce33efc --- /dev/null +++ b/app/src/main/java/com/base_android_template/utils/extensions/GithubUserResponseExtesion.kt @@ -0,0 +1,26 @@ +package com.base_android_template.utils.extensions + +import com.base_android_template.model.entity.GithubUserEntity +import com.base_android_template.model.response.GithubUserResponse + +fun GithubUserResponse.mapToGithubUserEntity(): GithubUserEntity = + GithubUserEntity( + login = this.login.orEmpty(), + id = this.id, + nodeId = this.nodeId.orEmpty(), + avatarUrl = this.avatarUrl.orEmpty(), + grAvatarId = this.grAvatarId.orEmpty(), + url = this.url.orEmpty(), + htmlUrl = this.htmlUrl.orEmpty(), + followersUrl = this.followersUrl.orEmpty(), + followingUrl = this.followingUrl.orEmpty(), + gistsUrl = this.gistsUrl.orEmpty(), + starredUrl = this.starredUrl.orEmpty(), + subscriptionsUrl = this.subscriptionsUrl.orEmpty(), + organizationsUrl = this.organizationsUrl.orEmpty(), + reposUrl = this.reposUrl.orEmpty(), + eventsUrl = this.eventsUrl.orEmpty(), + receivedEventsUrl = this.receivedEventsUrl.orEmpty(), + type = this.type.orEmpty(), + siteAdmin = this.siteAdmin + ) \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/utils/language/LocaleUtils.kt b/app/src/main/java/com/base_android_template/utils/language/LocaleUtils.kt index 8ba44a7..79d6f78 100644 --- a/app/src/main/java/com/base_android_template/utils/language/LocaleUtils.kt +++ b/app/src/main/java/com/base_android_template/utils/language/LocaleUtils.kt @@ -6,7 +6,7 @@ import android.content.res.Resources import android.view.ContextThemeWrapper import java.util.Locale -/* +/** * Utility class to change app locale settings. */ diff --git a/app/src/main/java/com/base_android_template/utils/network/NetworkResponseAdapter.kt b/app/src/main/java/com/base_android_template/utils/network/NetworkResponseAdapter.kt deleted file mode 100644 index 19f43c8..0000000 --- a/app/src/main/java/com/base_android_template/utils/network/NetworkResponseAdapter.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.base_android_template.utils.network - -import com.base_android_template.shared.network.ApiResponse -import okhttp3.ResponseBody -import retrofit2.Call -import retrofit2.CallAdapter -import retrofit2.Converter -import java.lang.reflect.Type - -class NetworkResponseAdapter( - private val successType: Type, - private val errorBodyConverter: Converter -) : CallAdapter>> { - - override fun responseType(): Type = successType - - override fun adapt(call: Call): Call> { - return NetworkResponseCall( - call, - errorBodyConverter - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/utils/network/NetworkResponseAdapterFactory.kt b/app/src/main/java/com/base_android_template/utils/network/NetworkResponseAdapterFactory.kt deleted file mode 100644 index a4480b6..0000000 --- a/app/src/main/java/com/base_android_template/utils/network/NetworkResponseAdapterFactory.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.base_android_template.utils.network - -import com.base_android_template.shared.network.ApiResponse -import retrofit2.Call -import retrofit2.CallAdapter -import retrofit2.Retrofit -import java.lang.reflect.ParameterizedType -import java.lang.reflect.Type - -class NetworkResponseAdapterFactory : CallAdapter.Factory() { - - override fun get( - returnType: Type, - annotations: Array, - retrofit: Retrofit - ): CallAdapter<*, *>? { - - // suspend functions wrap the response type in `Call` - if (Call::class.java != getRawType(returnType)) { - return null - } - - // check first that the return type is `ParameterizedType` - check(returnType is ParameterizedType) { - "return type must be parameterized as Call> or Call>" - } - - // get the response type inside the `Call` type - val responseType = getParameterUpperBound(0, returnType) - // if the response type is not ApiResponse then we can't handle this type, so we return null - if (getRawType(responseType) != ApiResponse::class.java) { - return null - } - - // the response type is ApiResponse and should be parameterized - check(responseType is ParameterizedType) { "Response must be parameterized as NetworkResponse or NetworkResponse" } - - val successBodyType = getParameterUpperBound(0, responseType) - val errorBodyType = getParameterUpperBound(1, responseType) - - val errorBodyConverter = - retrofit.nextResponseBodyConverter(null, errorBodyType, annotations) - - return NetworkResponseAdapter( - successBodyType, - errorBodyConverter - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/base_android_template/utils/network/NetworkResponseCall.kt b/app/src/main/java/com/base_android_template/utils/network/NetworkResponseCall.kt deleted file mode 100644 index b54cd49..0000000 --- a/app/src/main/java/com/base_android_template/utils/network/NetworkResponseCall.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.base_android_template.utils.network - -import com.base_android_template.shared.network.ApiResponse -import okhttp3.Request -import okhttp3.ResponseBody -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Converter -import retrofit2.Response -import java.io.EOFException -import java.io.IOException - -internal class NetworkResponseCall( - private val delegate: Call, - private val errorConverter: Converter -) : Call> { - - override fun enqueue(callback: Callback>) { - return delegate.enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - var error: E? = null - response.errorBody()?.let { - error = try { - errorConverter.convert(it) - } catch (c: EOFException) { - null - } - } - val body = response.body() - val code = response.code() - - if (response.isSuccessful) { - postSuccessfulResponse(body, callback) - } else { - postFailedResponse(error, callback, code) - } - } - - override fun onFailure(call: Call, throwable: Throwable) { - val networkResponse = when (throwable) { - is IOException -> ApiResponse.NetworkError(throwable) - else -> ApiResponse.UnknownError(throwable) - } - callback.onResponse(this@NetworkResponseCall, Response.success(networkResponse)) - } - - }) - } - - private fun postFailedResponse( - error: E?, - callback: Callback>, - code: Int - ) { - error?.let { - callback.onResponse( - this@NetworkResponseCall, - Response.success(ApiResponse.ServerError(it, code)) - ) - } ?: callback.onResponse( - this@NetworkResponseCall, - Response.success(ApiResponse.UnknownError(null)) - ) - } - - private fun postSuccessfulResponse( - body: S?, - callback: Callback> - ) { - callback.onResponse( - this@NetworkResponseCall, - Response.success(ApiResponse.Success(body)) - ) - } - - override fun isExecuted() = delegate.isExecuted - - override fun clone() = - NetworkResponseCall( - delegate.clone(), - errorConverter - ) - - override fun isCanceled() = delegate.isCanceled - - override fun cancel() = delegate.cancel() - - override fun execute(): Response> { - throw UnsupportedOperationException("NetworkResponseCall doesn't support execute") - } - - override fun request(): Request = delegate.request() -} \ No newline at end of file diff --git a/app/src/main/res/color/bottom_nav_color.xml b/app/src/main/res/color/bottom_nav_color.xml new file mode 100644 index 0000000..7eb7368 --- /dev/null +++ b/app/src/main/res/color/bottom_nav_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_shadow.xml b/app/src/main/res/drawable/bg_shadow.xml new file mode 100644 index 0000000..4049149 --- /dev/null +++ b/app/src/main/res/drawable/bg_shadow.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_account_circle_24px.xml b/app/src/main/res/drawable/ic_account_circle_24px.xml new file mode 100644 index 0000000..b845dc3 --- /dev/null +++ b/app/src/main/res/drawable/ic_account_circle_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_english.xml b/app/src/main/res/drawable/ic_english.xml new file mode 100644 index 0000000..8d7bae4 --- /dev/null +++ b/app/src/main/res/drawable/ic_english.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_romanian.xml b/app/src/main/res/drawable/ic_romanian.xml new file mode 100644 index 0000000..9a307ad --- /dev/null +++ b/app/src/main/res/drawable/ic_romanian.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_settings_24px.xml b/app/src/main/res/drawable/ic_settings_24px.xml new file mode 100644 index 0000000..282c986 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index fdd0605..095d085 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -21,11 +21,25 @@ android:layout_width="@dimen/match_constraint" android:layout_height="@dimen/match_constraint" app:defaultNavHost="true" + app:layout_constraintBottom_toTopOf="@+id/bottom_navigation" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:navGraph="@navigation/main_nav_graph" /> + + + app:menu="@menu/bottom_navigation" /> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_loading.xml b/app/src/main/res/layout/dialog_loading.xml new file mode 100644 index 0000000..c7cc744 --- /dev/null +++ b/app/src/main/res/layout/dialog_loading.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_github_users.xml b/app/src/main/res/layout/fragment_github_users.xml index 80efc03..bc57ee4 100644 --- a/app/src/main/res/layout/fragment_github_users.xml +++ b/app/src/main/res/layout/fragment_github_users.xml @@ -1,5 +1,7 @@ - + @@ -8,35 +10,80 @@ type="com.base_android_template.feature.github_users.GithubUsersViewModel" /> - + android:layout_height="match_parent" + app:layoutDescription="@xml/fragment_github_users_motion_scene"> + + + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/welcome_title" /> + + + + + + app:layout_constraintTop_toBottomOf="@+id/top_separator" + tools:listitem="@layout/item_github_user" /> - + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml new file mode 100644 index 0000000..d9cbe5f --- /dev/null +++ b/app/src/main/res/layout/fragment_settings.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_github_user.xml b/app/src/main/res/layout/item_github_user.xml index 0a4a892..73ca380 100644 --- a/app/src/main/res/layout/item_github_user.xml +++ b/app/src/main/res/layout/item_github_user.xml @@ -6,7 +6,7 @@ + type="com.base_android_template.model.entity.GithubUserEntity" /> + tools:text="Github User" /> diff --git a/app/src/main/res/menu/bottom_navigation.xml b/app/src/main/res/menu/bottom_navigation.xml new file mode 100644 index 0000000..85e9296 --- /dev/null +++ b/app/src/main/res/menu/bottom_navigation.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/app/src/main/res/menu/language_menu.xml b/app/src/main/res/menu/language_menu.xml deleted file mode 100644 index 0f8148b..0000000 --- a/app/src/main/res/menu/language_menu.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml index 8cb6ffd..df2ec3f 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -3,12 +3,21 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/nav_graph" - app:startDestination="@id/githubUsersFragment"> + app:startDestination="@id/githubUsers"> + tools:layout="@layout/fragment_github_users" > + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 685ba4b..ddd3781 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,12 +1,19 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 81b52b5..e5c75d9 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1,6 +1,11 @@ + Limba Romana Engleza - Lista cu toti utilizatorii Github, in ordinea in care s-au inregistrat pe GitHub. Aceasta lista include conturi personale de utilizator si conturi de organizatie. + Useri + Setari + 👋 Salut, bine ai venit in aplicatia Base Android Template! + Pentru a demonstra apelurile catre server si operatiile pe baza de date Room, vom afisa in acest ecran o lista cu 30 de utilizatori Github preluati din baza de date locala. Daca baza de date nu contine utilizatori, lista va fi preluata de la server si apoi va fi salvata in baza de date locala. + Ceva nu a functionat! Lista cu utilizatori Github nu a putut fi preluata. \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 5d1ad9c..cf54358 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,8 +1,10 @@ - #FF90CAF9 #FF2196F3 #FF1976D2 + #FF000000 #FFFFFFFF + #D8D8D8 + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index c033fa1..9b83971 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -6,8 +6,10 @@ 8dp 16dp + 24dp + 32dp - 14sp 16sp + 24sp \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 73143c4..45ba5c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,8 +1,13 @@ Base-Android-Template + Language Romanian English - Lists all users, in the order that they signed up on GitHub. This list includes personal user accounts and organization accounts. + Users + Settings + 👋 Hi, welcome to Base Android Template! + To prove server calls and Room database operations, we will display in this screen a list of 30 Github users fetched from the local database. If the database does not contain users, the list will be retrieved from the server and then saved in the local database. + Something went wrong! The Github users list could not be fetched. \ 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 71d2c5e..64609f5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,12 +1,18 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/xml/fragment_github_users_motion_scene.xml b/app/src/main/res/xml/fragment_github_users_motion_scene.xml new file mode 100644 index 0000000..beb27ed --- /dev/null +++ b/app/src/main/res/xml/fragment_github_users_motion_scene.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8076f01..5e77c09 100644 --- a/build.gradle +++ b/build.gradle @@ -1,8 +1,23 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext{ + ext { kotlin_version = "1.4.30" - navigationVersion = "2.3.3" + core_ktx = "1.3.2" + appcompat = "1.2.0" + material = "1.3.0" + constraint_layout = "2.0.4" + junit = "1.1.2" + espresso = "3.3.0" + navigationVersion = "2.3.4" + room_version = "2.2.6" + calligraphy = "3.1.1" + viewPump = "2.0.3" + timber = "4.7.1" + hawk = "2.0.1" + glide = "4.11.0" + okhttp = "4.9.0" + retrofit = "2.7.2" + koin = "2.2.2" } repositories {