├── app ├── .gitignore ├── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── composetemplate │ │ │ │ ├── core │ │ │ │ ├── data │ │ │ │ │ ├── Data.md │ │ │ │ │ ├── network │ │ │ │ │ │ ├── dtos │ │ │ │ │ │ │ ├── ErrorDto.kt │ │ │ │ │ │ │ ├── PostDto.kt │ │ │ │ │ │ │ ├── ResourceMapping.kt │ │ │ │ │ │ │ ├── PostMapping.kt │ │ │ │ │ │ │ ├── UserMapping.kt │ │ │ │ │ │ │ ├── ResourceDetailsMapping.kt │ │ │ │ │ │ │ ├── ResourceDto.kt │ │ │ │ │ │ │ ├── UserDto.kt │ │ │ │ │ │ │ └── ResourceDetailsDto.kt │ │ │ │ │ │ ├── responses │ │ │ │ │ │ │ ├── TokenResponse.kt │ │ │ │ │ │ │ ├── UserResponse.kt │ │ │ │ │ │ │ └── ResourcesResponse.kt │ │ │ │ │ │ └── Api.kt │ │ │ │ │ ├── storage │ │ │ │ │ │ ├── PostPreferenceStore.kt │ │ │ │ │ │ ├── UserPreferenceStore.kt │ │ │ │ │ │ └── PreferenceDataStore.kt │ │ │ │ │ └── repositories │ │ │ │ │ │ ├── RepositoryException.kt │ │ │ │ │ │ ├── PostRepository.kt │ │ │ │ │ │ ├── ResourceRepository.kt │ │ │ │ │ │ └── UserRepository.kt │ │ │ │ ├── domain │ │ │ │ │ ├── Domain.md │ │ │ │ │ ├── error │ │ │ │ │ │ ├── ErrorMessage.kt │ │ │ │ │ │ ├── ErrorModel.kt │ │ │ │ │ │ ├── ExceptionModel.kt │ │ │ │ │ │ └── ErrorMapping.kt │ │ │ │ │ └── model │ │ │ │ │ │ ├── Post.kt │ │ │ │ │ │ ├── User.kt │ │ │ │ │ │ └── ResourceDetails.kt │ │ │ │ ├── usecases │ │ │ │ │ ├── UseCases.md │ │ │ │ │ ├── blog │ │ │ │ │ │ └── GetPostsUseCase.kt │ │ │ │ │ ├── user │ │ │ │ │ │ └── LoginUseCase.kt │ │ │ │ │ ├── resourceDetails │ │ │ │ │ │ └── GetResourceDetailsUseCase.kt │ │ │ │ │ └── resources │ │ │ │ │ │ └── GetResourcesUseCase.kt │ │ │ │ ├── sharedui │ │ │ │ │ └── errorhandling │ │ │ │ │ │ ├── ViewError.kt │ │ │ │ │ │ └── ViewErrorController.kt │ │ │ │ ├── pagination │ │ │ │ │ ├── model │ │ │ │ │ │ ├── RemoteKeys.kt │ │ │ │ │ │ └── Resource.kt │ │ │ │ │ ├── dao │ │ │ │ │ │ ├── RemoteKeysDao.kt │ │ │ │ │ │ └── ResourceDao.kt │ │ │ │ │ ├── db │ │ │ │ │ │ └── AppDatabase.kt │ │ │ │ │ └── mediator │ │ │ │ │ │ └── ResourceMediator.kt │ │ │ │ ├── navigation │ │ │ │ │ ├── home │ │ │ │ │ │ └── HomeNavigation.kt │ │ │ │ │ ├── login │ │ │ │ │ │ └── LoginNavigation.kt │ │ │ │ │ ├── resource │ │ │ │ │ │ ├── ResourcesNavigation.kt │ │ │ │ │ │ └── ResourceDetailsNavigation.kt │ │ │ │ │ ├── AppNavHost.kt │ │ │ │ │ ├── Destination.kt │ │ │ │ │ └── Navigation.kt │ │ │ │ └── ui │ │ │ │ │ ├── AppBackground.kt │ │ │ │ │ ├── CircularProgressBar.kt │ │ │ │ │ ├── AppTopAppBar.kt │ │ │ │ │ ├── AppIcons.kt │ │ │ │ │ ├── theme │ │ │ │ │ ├── Type.kt │ │ │ │ │ ├── Color.kt │ │ │ │ │ └── theme.kt │ │ │ │ │ ├── AppButton.kt │ │ │ │ │ ├── AppState.kt │ │ │ │ │ ├── InputTextField.kt │ │ │ │ │ └── AndroidTemplateApp.kt │ │ │ │ ├── features │ │ │ │ ├── Views.md │ │ │ │ ├── resources │ │ │ │ │ ├── ResourceViewModel.kt │ │ │ │ │ ├── ResourceDetailsViewModel.kt │ │ │ │ │ ├── ResourceDetailsScreen.kt │ │ │ │ │ └── ResourceScreen.kt │ │ │ │ ├── login │ │ │ │ │ ├── LoginViewModel.kt │ │ │ │ │ └── LoginScreen.kt │ │ │ │ └── main │ │ │ │ │ └── MainActivity.kt │ │ │ │ ├── arch │ │ │ │ ├── extensions │ │ │ │ │ ├── ViewErrorAware.kt │ │ │ │ │ ├── GlobalViewBindingDelegate.kt │ │ │ │ │ ├── DebounceOnClickListener.kt │ │ │ │ │ ├── RepositoryExtensions.kt │ │ │ │ │ ├── ViewBindingExtensions.kt │ │ │ │ │ ├── ActivityViewBindingDelegate.kt │ │ │ │ │ ├── ComposeExtensions.kt │ │ │ │ │ ├── FlowExtensions.kt │ │ │ │ │ ├── FragmentViewBindingDelegate.kt │ │ │ │ │ ├── ViewModelStoreOwnerExtensions.kt │ │ │ │ │ ├── UseCaseResult.kt │ │ │ │ │ ├── ViewModelEx.kt │ │ │ │ │ └── ViewExtensions.kt │ │ │ │ └── data │ │ │ │ │ ├── Repository.kt │ │ │ │ │ ├── SingleDataSource.kt │ │ │ │ │ ├── DataSource.kt │ │ │ │ │ ├── SingleSharedPreferenceDataStore.kt │ │ │ │ │ └── SharedPreferenceDataStore.kt │ │ │ │ ├── App.kt │ │ │ │ └── injection │ │ │ │ ├── qualifiers │ │ │ │ └── CoroutineQualifiers.kt │ │ │ │ └── modules │ │ │ │ ├── DatabaseModule.kt │ │ │ │ ├── CoroutinesModule.kt │ │ │ │ ├── DataModule.kt │ │ │ │ └── RestModule.kt │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── themes.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ ├── drawable-v24 │ │ │ │ ├── ic_home.xml │ │ │ │ ├── ic_home_border.xml │ │ │ │ ├── ic_resources.xml │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ └── ic_resources_border.xml │ │ │ └── drawable │ │ │ │ └── ic_launcher_background.xml │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── composetemplate │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── composetemplate │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── settings.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── .github └── pull-request-template.md ├── LICENSE.md ├── README.md ├── gradle.properties ├── scripts └── conventional-pre-commit.sh ├── gradlew.bat ├── generate-project.sh └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/Data.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/features/Views.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/domain/Domain.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/usecases/UseCases.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | rootProject.name = "android-compose-template" -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafi4204/android-compose-template/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/ViewErrorAware.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | interface ViewErrorAware 4 | 5 | interface LoadingAware -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/domain/error/ErrorMessage.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.domain.error 2 | 3 | data class ErrorMessage(val id: Long, val message: String) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/data/Repository.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.data 2 | 3 | /** 4 | * Empty for now, but could allow us to add cache helpers etc 5 | */ 6 | abstract class Repository -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/sharedui/errorhandling/ViewError.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.sharedui.errorhandling 2 | 3 | data class ViewError( 4 | var title: String, 5 | var message: String, 6 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/data/SingleDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.data 2 | 3 | interface SingleDataSource { 4 | suspend fun get(): T? 5 | suspend fun add(item: T) 6 | suspend fun clear() 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/dtos/ErrorDto.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.dtos 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ErrorDto(val message: String?) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/dtos/PostDto.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.dtos 2 | 3 | data class PostDto( 4 | val id: Int, 5 | val userId: Int, 6 | val title: String, 7 | val body: String 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/responses/TokenResponse.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.responses 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class TokenResponse( 7 | val token: String 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/data/DataSource.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.data 2 | 3 | interface DataSource { 4 | suspend fun getAll(): List 5 | suspend fun add(item: T) 6 | suspend fun addAll(items: List) 7 | suspend fun clear() 8 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/domain/model/Post.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.domain.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Post( 7 | val id: Int, 8 | val title: String, 9 | val body: String, 10 | ) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Jan 04 10:11:01 CET 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/pagination/model/RemoteKeys.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.pagination.model 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity 7 | data class RemoteKeys(@PrimaryKey val repoId: String, val prevKey: Int?, val nextKey: Int?) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | 17 | .idea/ 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/responses/UserResponse.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.responses 2 | 3 | import com.composetemplate.core.data.network.dtos.UserDto 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class UserResponse( 8 | val data: UserDto 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/domain/model/User.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.domain.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class User( 7 | val email: String, 8 | val firstName: String, 9 | val lastName: String, 10 | val avatar: String 11 | ) -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/dtos/ResourceMapping.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.dtos 2 | 3 | import com.composetemplate.core.pagination.model.Resource 4 | 5 | fun ResourceDto.toEntity(): Resource { 6 | return Resource( 7 | id = id, 8 | name = name 9 | ) 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/dtos/PostMapping.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.dtos 2 | 3 | import com.composetemplate.core.domain.model.Post 4 | 5 | fun PostDto.toEntity(): Post { 6 | return Post( 7 | id = id, 8 | title = title, 9 | body = body 10 | ) 11 | } -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/responses/ResourcesResponse.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.responses 2 | 3 | import com.composetemplate.core.data.network.dtos.ResourceDto 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ResourcesResponse( 8 | val data: List 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/domain/model/ResourceDetails.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.domain.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | 7 | @Parcelize 8 | data class ResourceDetails( 9 | val id: Int, 10 | val name: String, 11 | val imageUrl: String 12 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/pagination/model/Resource.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.pagination.model 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "resources") 7 | data class Resource( 8 | @PrimaryKey(autoGenerate = true) 9 | val id: Int, 10 | val name: String 11 | 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/dtos/UserMapping.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.dtos 2 | 3 | import com.composetemplate.core.domain.model.User 4 | 5 | fun UserDto.toUser(): User { 6 | return User( 7 | email = email, 8 | firstName = firstName, 9 | lastName = lastName, 10 | avatar = avatar 11 | ) 12 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/dtos/ResourceDetailsMapping.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.dtos 2 | 3 | import com.composetemplate.core.domain.model.ResourceDetails 4 | 5 | fun ResourceDetailsDto.toEntity(): ResourceDetails { 6 | return ResourceDetails( 7 | id = id, 8 | name = name, 9 | imageUrl = imageUrl 10 | ) 11 | } -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | android-compose-template 3 | Home 4 | Resources 5 | Login 6 | Icon 7 | Label 8 | No Data Found 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/dtos/ResourceDto.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.dtos 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ResourceDto( 8 | val id: Int, 9 | val name: String, 10 | val year: Int, 11 | val color: String, 12 | @SerialName("pantone_value") 13 | val pantoneValue: String 14 | ) -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/dtos/UserDto.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.dtos 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class UserDto( 8 | val id: Int, 9 | val email: String, 10 | @SerialName("first_name") 11 | val firstName: String, 12 | @SerialName("last_name") 13 | val lastName: String, 14 | val avatar: String 15 | ) -------------------------------------------------------------------------------- /app/src/test/java/com/composetemplate/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | * 9 | * See [testing documentation](http://d.android.com/tools/testing). 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | fun addition_isCorrect() { 14 | assertEquals(4, 2 + 2) 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/dtos/ResourceDetailsDto.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network.dtos 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ResourceDetailsDto( 8 | val id: Int, 9 | val name: String, 10 | val tagline: String, 11 | @SerializedName("image_url") 12 | val imageUrl: String, 13 | @SerializedName("first_brewed") 14 | val firstBrewed: String 15 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/App.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Application 5 | import dagger.hilt.android.HiltAndroidApp 6 | import timber.log.Timber 7 | 8 | @HiltAndroidApp 9 | class App : Application() { 10 | 11 | @SuppressLint("AppOpenMissing") 12 | override fun onCreate() { 13 | super.onCreate() 14 | if (BuildConfig.DEBUG) { 15 | Timber.plant(Timber.DebugTree()) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/GlobalViewBindingDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import android.view.View 4 | import androidx.viewbinding.ViewBinding 5 | import kotlin.properties.ReadOnlyProperty 6 | import kotlin.reflect.KProperty 7 | 8 | class GlobalViewBindingDelegate(val viewBinder: (View) -> T) : 9 | ReadOnlyProperty { 10 | 11 | override fun getValue(thisRef: View, property: KProperty<*>): T { 12 | return viewBinder(thisRef) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/storage/PostPreferenceStore.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.storage 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import com.composetemplate.arch.data.SharedPreferenceDataStore 6 | import com.composetemplate.core.domain.model.Post 7 | import javax.inject.Inject 8 | 9 | class PostPreferenceStore @Inject constructor( 10 | dataStore: DataStore 11 | ) : SharedPreferenceDataStore(dataStore, Post.serializer()) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/storage/UserPreferenceStore.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.storage 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import com.composetemplate.arch.data.SharedPreferenceDataStore 6 | import com.composetemplate.core.domain.model.User 7 | import javax.inject.Inject 8 | 9 | class UserPreferenceStore @Inject constructor( 10 | dataStore: DataStore 11 | ) : SharedPreferenceDataStore(dataStore, User.serializer()) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/repositories/RepositoryException.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.repositories 2 | 3 | import retrofit2.Response 4 | 5 | data class RepositoryException( 6 | val code: Int, 7 | val errorBody: String?, 8 | val msg: String 9 | ) : RuntimeException(msg) 10 | 11 | fun Response.mapToRepositoryException(): RepositoryException { 12 | return RepositoryException( 13 | code = code(), 14 | errorBody = errorBody()?.string(), 15 | msg = message() 16 | ) 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/injection/qualifiers/CoroutineQualifiers.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.injection.qualifiers 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Retention(AnnotationRetention.BINARY) 6 | @Qualifier 7 | annotation class DefaultDispatcher 8 | 9 | @Retention(AnnotationRetention.BINARY) 10 | @Qualifier 11 | annotation class IoDispatcher 12 | 13 | @Retention(AnnotationRetention.BINARY) 14 | @Qualifier 15 | annotation class MainDispatcher 16 | 17 | @Retention(AnnotationRetention.BINARY) 18 | @Qualifier 19 | annotation class MainImmediateDispatcher -------------------------------------------------------------------------------- /.github/pull-request-template.md: -------------------------------------------------------------------------------- 1 | **Acceptance criteria:** 2 | - [ ] PR title includes Jira ticket reference 3 | - [ ] Follows UI patterns from [UI manifesto](https://github.com/monstar-lab-oss/ui-manifesto) 4 | - [ ] Loading/error/empty states taken into account 5 | - [ ] Status of Jira ticket updated 6 | - [ ] Removed commented out code 7 | - [ ] Removed unnecessary logging 8 | - [ ] Tested flows in airplane mode and bad connection 9 | - [ ] Updated documentation (please add a link if other than README 😊) 10 | 11 | **Related to other PRs:** 12 | 13 | **Notes for reviewers:** 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/DebounceOnClickListener.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import android.view.View 4 | 5 | class DebounceOnClickListener( 6 | private val interval: Long = 400L, 7 | private val listenerBlock: (View) -> Unit, 8 | ) : View.OnClickListener { 9 | private var lastClickTime = 0L 10 | override fun onClick(v: View) { 11 | val time = System.currentTimeMillis() 12 | if (time - lastClickTime >= interval) { 13 | lastClickTime = time 14 | listenerBlock(v) 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/navigation/home/HomeNavigation.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.navigation.home 2 | 3 | import androidx.navigation.NavController 4 | import androidx.navigation.NavGraphBuilder 5 | import androidx.navigation.NavOptions 6 | import androidx.navigation.compose.composable 7 | 8 | const val homeNavigationRoute = "home_route" 9 | 10 | fun NavController.navigateToHome(navOptions: NavOptions? = null) { 11 | this.navigate(homeNavigationRoute, navOptions) 12 | } 13 | 14 | fun NavGraphBuilder.homeScreen() { 15 | composable(route = homeNavigationRoute) { 16 | // HomeRoute() 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/usecases/blog/GetPostsUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.usecases.blog 2 | 3 | import com.composetemplate.arch.extensions.useCaseFlow 4 | import com.composetemplate.core.data.repositories.PostRepository 5 | import com.composetemplate.injection.qualifiers.DefaultDispatcher 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import javax.inject.Inject 8 | 9 | class GetPostsUseCase @Inject constructor( 10 | private val postRepository: PostRepository, 11 | @DefaultDispatcher private val coroutineDispatcher: CoroutineDispatcher 12 | ) { 13 | fun getPosts() = useCaseFlow(coroutineDispatcher) { 14 | postRepository.getPosts() 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/pagination/dao/RemoteKeysDao.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.pagination.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.composetemplate.core.pagination.model.RemoteKeys 8 | 9 | @Dao 10 | interface RemoteKeysDao { 11 | 12 | @Insert(onConflict = OnConflictStrategy.REPLACE) 13 | suspend fun insertAll(remoteKey: List) 14 | 15 | @Query("SELECT * FROM remotekeys WHERE repoId = :id") 16 | suspend fun remoteKeysResourceId(id: String): RemoteKeys? 17 | 18 | @Query("DELETE FROM remotekeys") 19 | suspend fun clearRemoteKeys() 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/pagination/dao/ResourceDao.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.pagination.dao 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.room.Dao 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import com.composetemplate.core.pagination.model.Resource 9 | 10 | @Dao 11 | interface ResourceDao { 12 | @Insert(onConflict = OnConflictStrategy.REPLACE) 13 | suspend fun insertAll(resource: List) 14 | 15 | @Query("SELECT * FROM resources") 16 | fun getAllResources(): PagingSource 17 | 18 | @Query("DELETE FROM resources") 19 | suspend fun clearAllResources() 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/AppBackground.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | 10 | @Composable 11 | fun AppBackground( 12 | content: @Composable() () -> Unit 13 | ) { 14 | Box( 15 | modifier = Modifier 16 | .fillMaxSize() 17 | .background( 18 | MaterialTheme.colorScheme.surfaceVariant 19 | ) 20 | ) { 21 | content.invoke() 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/navigation/login/LoginNavigation.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.navigation.login 2 | 3 | import androidx.navigation.NavController 4 | import androidx.navigation.NavGraphBuilder 5 | import androidx.navigation.NavOptions 6 | import androidx.navigation.compose.composable 7 | import com.composetemplate.features.login.LoginRoute 8 | 9 | const val loginNavigationRoute = "login_route" 10 | 11 | fun NavController.navigateToLogin(navOptions: NavOptions? = null) { 12 | this.navigate(loginNavigationRoute, navOptions) 13 | } 14 | 15 | fun NavGraphBuilder.loginScreen(navigateToHome: () -> Unit) { 16 | composable(route = loginNavigationRoute) { 17 | LoginRoute(navigateToHome = navigateToHome) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/RepositoryExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import com.composetemplate.core.domain.error.toException 4 | import retrofit2.Response 5 | 6 | inline fun repoCall( 7 | block: () -> Response 8 | ): T { 9 | val response = block() 10 | val body = response.body() 11 | return when (response.isSuccessful && body != null) { 12 | true -> body 13 | false -> throw response.toException() 14 | } 15 | } 16 | 17 | inline fun Response.mapSuccess( 18 | crossinline block: (T) -> R 19 | ): R { 20 | val safeBody = body() 21 | if (this.isSuccessful && safeBody != null) { 22 | return block(safeBody) 23 | } else { 24 | throw toException() 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/usecases/user/LoginUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.usecases.user 2 | 3 | import com.composetemplate.arch.extensions.useCaseFlow 4 | import com.composetemplate.core.data.repositories.UserRepository 5 | import com.composetemplate.injection.qualifiers.DefaultDispatcher 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import javax.inject.Inject 8 | 9 | class LoginUseCase @Inject constructor( 10 | private val userRepository: UserRepository, 11 | @DefaultDispatcher private val coroutineDispatcher: CoroutineDispatcher 12 | ) { 13 | 14 | fun login(email: String, password: String) = 15 | useCaseFlow(coroutineDispatcher) { 16 | userRepository.login(email, password) 17 | userRepository.getUser() 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/usecases/resourceDetails/GetResourceDetailsUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.usecases.resourceDetails 2 | 3 | import com.composetemplate.arch.extensions.useCaseFlow 4 | import com.composetemplate.core.data.repositories.ResourceRepository 5 | import com.composetemplate.injection.qualifiers.IoDispatcher 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import javax.inject.Inject 8 | 9 | class GetResourceDetailsUseCase @Inject constructor( 10 | private val resourceRepository: ResourceRepository, 11 | @IoDispatcher private val coroutineDispatcher: CoroutineDispatcher 12 | ) { 13 | operator fun invoke(id: Int) = useCaseFlow(coroutineDispatcher = coroutineDispatcher) { 14 | resourceRepository.getResourcesDetails(id) 15 | } 16 | 17 | 18 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/composetemplate/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("com.monstarlab", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/CircularProgressBar.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.CircularProgressIndicator 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | 12 | @Composable 13 | fun CircularProgressBar(contentAlignment: Alignment = Alignment.Center) { 14 | Box( 15 | modifier = Modifier 16 | .fillMaxSize() 17 | .padding(top = 40.dp), 18 | contentAlignment = contentAlignment 19 | ) { 20 | CircularProgressIndicator() 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/injection/modules/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.injection.modules 2 | 3 | import android.content.Context 4 | import com.composetemplate.core.pagination.db.AppDatabase 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import dagger.hilt.components.SingletonComponent 10 | import javax.inject.Singleton 11 | 12 | @InstallIn(SingletonComponent::class) 13 | @Module 14 | class DatabaseModule { 15 | 16 | @Provides 17 | @Singleton 18 | fun provideResourceDao(appDatabase: AppDatabase) = appDatabase.getResourceDao() 19 | 20 | @Provides 21 | @Singleton 22 | fun provideDatabase(@ApplicationContext appContext: Context) = 23 | AppDatabase.getInstance(appContext) 24 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/repositories/PostRepository.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.repositories 2 | 3 | import com.composetemplate.arch.data.Repository 4 | import com.composetemplate.arch.extensions.mapSuccess 5 | import com.composetemplate.core.data.network.Api 6 | import com.composetemplate.core.data.network.dtos.toEntity 7 | import com.composetemplate.core.data.storage.PostPreferenceStore 8 | import com.composetemplate.core.domain.model.Post 9 | import javax.inject.Inject 10 | 11 | class PostRepository @Inject constructor( 12 | private val api: Api, 13 | private val postPreferenceStore: PostPreferenceStore 14 | ) : Repository() { 15 | 16 | suspend fun getPosts(): List { 17 | return api.getPosts() 18 | .mapSuccess { list -> list.map { it.toEntity() } } 19 | .also { postPreferenceStore.addAll(it) } 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/features/resources/ResourceViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.features.resources 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.composetemplate.arch.extensions.LoadingAware 5 | import com.composetemplate.arch.extensions.ViewErrorAware 6 | import com.composetemplate.core.sharedui.errorhandling.ViewError 7 | import com.composetemplate.core.usecases.resources.GetResourcesUseCase 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.* 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class ResourceViewModel @Inject constructor( 14 | getResourcesUseCase: GetResourcesUseCase 15 | ) : ViewModel(), ViewErrorAware, LoadingAware { 16 | var resourceResult = getResourcesUseCase() 17 | 18 | //var resourceResult = getResourcesUseCase() 19 | val loadingFlow = MutableStateFlow(false) 20 | val errorFlow = MutableSharedFlow() 21 | 22 | 23 | 24 | 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/injection/modules/CoroutinesModule.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.injection.modules 2 | 3 | 4 | import com.composetemplate.injection.qualifiers.* 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import kotlinx.coroutines.Dispatchers 11 | 12 | @InstallIn(SingletonComponent::class) 13 | @Module 14 | object CoroutinesModule { 15 | 16 | @DefaultDispatcher 17 | @Provides 18 | fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default 19 | 20 | @IoDispatcher 21 | @Provides 22 | fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO 23 | 24 | @MainDispatcher 25 | @Provides 26 | fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main 27 | 28 | @MainImmediateDispatcher 29 | @Provides 30 | fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/repositories/ResourceRepository.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.repositories 2 | 3 | import com.composetemplate.arch.data.Repository 4 | import com.composetemplate.arch.extensions.mapSuccess 5 | import com.composetemplate.core.data.network.Api 6 | import com.composetemplate.core.data.network.dtos.toEntity 7 | import com.composetemplate.core.domain.model.ResourceDetails 8 | import com.composetemplate.core.pagination.model.Resource 9 | import javax.inject.Inject 10 | 11 | class ResourceRepository @Inject constructor( 12 | private val api: Api, 13 | ) : Repository() { 14 | 15 | suspend fun getResources(page: Int, pageSize: Int): List { 16 | return api.getResources(page, pageSize) 17 | .mapSuccess { response -> response.map { it.toEntity() } } 18 | } 19 | 20 | suspend fun getResourcesDetails(id: Int): ResourceDetails { 21 | return api.getResourcesDetails(id) 22 | .mapSuccess { 23 | it[0].toEntity() 24 | } 25 | } 26 | 27 | 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/navigation/resource/ResourcesNavigation.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.navigation.resource 2 | 3 | import androidx.navigation.* 4 | import androidx.navigation.compose.composable 5 | import com.composetemplate.features.resources.ResourceRoute 6 | 7 | const val resourcesGraphRoutePattern = "resources_graph" 8 | const val resourcesNavigationRoute = "resources_route" 9 | 10 | fun NavController.navigateToResourcesGraph(navOptions: NavOptions? = null) { 11 | this.navigate(resourcesGraphRoutePattern, navOptions) 12 | } 13 | 14 | fun NavGraphBuilder.resourcesGraph( 15 | onItemClick: (Int) -> Unit, 16 | nestedGraphs: NavGraphBuilder.() -> Unit, 17 | navController: NavController 18 | ) { 19 | navigation( 20 | route = resourcesGraphRoutePattern, 21 | startDestination = resourcesNavigationRoute 22 | ) { 23 | composable(route = resourcesNavigationRoute) { 24 | ResourceRoute(navController = navController, onItemClick = onItemClick) 25 | } 26 | nestedGraphs() 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/features/resources/ResourceDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.features.resources 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.composetemplate.arch.extensions.LoadingAware 5 | import com.composetemplate.arch.extensions.ViewErrorAware 6 | import com.composetemplate.arch.extensions.collectFlow 7 | import com.composetemplate.core.domain.model.ResourceDetails 8 | import com.composetemplate.core.usecases.resourceDetails.GetResourceDetailsUseCase 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.* 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class ResourceDetailsViewModel @Inject constructor( 15 | private val getResourceDetailsUseCase: GetResourceDetailsUseCase 16 | ) : ViewModel(), ViewErrorAware, LoadingAware { 17 | val resourceDetails = MutableStateFlow(ResourceDetails(-1, "", "")) 18 | 19 | fun getResourceDetails(id: Int) { 20 | collectFlow(getResourceDetailsUseCase(id)) { 21 | resourceDetails.value = it 22 | } 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/domain/error/ErrorModel.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.domain.error 2 | 3 | sealed class ErrorModel : Throwable() { 4 | 5 | sealed class Http : ErrorModel() { 6 | object BadRequest : Http() 7 | object Unauthorized : Http() 8 | object Forbidden : Http() 9 | object NotFound : Http() 10 | object MethodNotAllowed : Http() 11 | 12 | object ServerError : Http() 13 | 14 | data class Custom( 15 | val code: Int?, 16 | override val message: String?, 17 | val errorBody: String? 18 | ) : Http() 19 | } 20 | 21 | sealed class DataError : ErrorModel() { 22 | object NoData : DataError() 23 | data class ParseError(val throwable: Throwable) : DataError() 24 | } 25 | 26 | sealed class Connection : ErrorModel() { 27 | object Timeout : Connection() 28 | object IOError : Connection() 29 | object UnknownHost : Connection() 30 | } 31 | 32 | data class Unknown(val throwable: Throwable) : ErrorModel() 33 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/usecases/resources/GetResourcesUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.usecases.resources 2 | 3 | import androidx.paging.ExperimentalPagingApi 4 | import androidx.paging.Pager 5 | import androidx.paging.PagingConfig 6 | import com.composetemplate.core.data.repositories.ResourceRepository 7 | import com.composetemplate.core.pagination.db.AppDatabase 8 | import com.composetemplate.core.pagination.mediator.ResourceMediator 9 | import com.composetemplate.core.pagination.mediator.ResourceMediator.Companion.DEFAULT_PAGE_SIZE 10 | import javax.inject.Inject 11 | 12 | @OptIn(ExperimentalPagingApi::class) 13 | class GetResourcesUseCase @Inject constructor( 14 | private val resourceRepository: ResourceRepository, 15 | private val appDatabase: AppDatabase, 16 | ) { 17 | operator fun invoke() = 18 | Pager( 19 | config = PagingConfig(pageSize = DEFAULT_PAGE_SIZE), 20 | remoteMediator = ResourceMediator(resourceRepository, appDatabase) 21 | ) { 22 | appDatabase.getResourceDao().getAllResources() 23 | }.flow 24 | 25 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rafi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Jetpack Compose Template 🚀🚀🚀🚀 2 | 3 | Jetpack Compose starter project template with MVVM,Hilt,Paging3 with offline cache.Conditional bottom bar and top app bar added. 4 | 5 | ## Description 6 | Core classes: 7 | ### Destination(app/src/main/java/com/monstarlab/core/navigation/Destination.kt): 8 | All the destionation/screen will be added in this enum. Assingn the corresponding property value for each destination i.e `isTopBarTab`,`isBottomBarTab`,`isTopLevelDestination` etc. 9 | 10 | ### Demo: 11 | some demo screen have been added in the project to show how to create screen and navigation files related to that screen.Follwing screen have been added: 12 | #### Login screen 13 | #### Home screen 14 | #### Resource screen 15 | 16 | 17 | ## Authors 18 | 19 | Muhammad Bin Farook(Rafi) 20 | (https://www.linkedin.com/in/muhammadbinfarook/) 21 | 22 | 23 | ## Version History 24 | 25 | * 0.1 26 | * Initial Release 27 | 28 | ## License 29 | 30 | This project is licensed under the [Android-compose-template] License - see the [LICENSE.md](https://github.com/rafi4204/android-compose-template/blob/master/LICENSE.md) file for details 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/repositories/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.repositories 2 | 3 | import com.composetemplate.arch.data.Repository 4 | import com.composetemplate.arch.extensions.mapSuccess 5 | import com.composetemplate.arch.extensions.repoCall 6 | import com.composetemplate.core.data.network.Api 7 | import com.composetemplate.core.data.network.dtos.toUser 8 | import com.composetemplate.core.data.network.responses.TokenResponse 9 | import com.composetemplate.core.data.storage.UserPreferenceStore 10 | import com.composetemplate.core.domain.model.User 11 | import javax.inject.Inject 12 | 13 | class UserRepository @Inject constructor( 14 | private val api: Api, 15 | private val userPreferenceStore: UserPreferenceStore 16 | ) : Repository() { 17 | 18 | suspend fun login(email: String, password: String): TokenResponse = repoCall { 19 | api.postLogin(email, password) 20 | } 21 | 22 | suspend fun getUser(): User { 23 | return api.getUser() 24 | .mapSuccess { 25 | it.data.toUser() 26 | }.also { 27 | userPreferenceStore.add(it) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/storage/PreferenceDataStore.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.storage 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.booleanPreferencesKey 6 | import androidx.datastore.preferences.core.edit 7 | import com.composetemplate.core.data.storage.PreferenceDataStore.PreferencesKeys.PREF_LOGGED_IN 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.map 10 | import javax.inject.Inject 11 | 12 | class PreferenceDataStore @Inject constructor( 13 | private val dataStore: DataStore 14 | ) { 15 | companion object { 16 | // todo change the name accordingly 17 | const val PREFS_NAME = "app_prefs" 18 | } 19 | 20 | object PreferencesKeys { 21 | val PREF_LOGGED_IN = booleanPreferencesKey("pref_logged_in") 22 | } 23 | 24 | suspend fun completeLogin(complete: Boolean) { 25 | dataStore.edit { 26 | it[PREF_LOGGED_IN] = complete 27 | } 28 | } 29 | 30 | val isLoggedIn: Flow = 31 | dataStore.data.map { it[PREF_LOGGED_IN] ?: false } 32 | 33 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/data/network/Api.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.data.network 2 | 3 | import com.composetemplate.core.data.network.dtos.PostDto 4 | import com.composetemplate.core.data.network.dtos.ResourceDetailsDto 5 | import com.composetemplate.core.data.network.dtos.ResourceDto 6 | import com.composetemplate.core.data.network.responses.TokenResponse 7 | import com.composetemplate.core.data.network.responses.UserResponse 8 | import retrofit2.Response 9 | import retrofit2.http.Field 10 | import retrofit2.http.FormUrlEncoded 11 | import retrofit2.http.GET 12 | import retrofit2.http.POST 13 | import retrofit2.http.Path 14 | import retrofit2.http.Query 15 | 16 | interface Api { 17 | @GET("posts") 18 | suspend fun getPosts(): Response> 19 | 20 | @FormUrlEncoded 21 | @POST("login") 22 | suspend fun postLogin(@Field("email") email: String, @Field("password") password: String): Response 23 | 24 | @GET("users/2") 25 | suspend fun getUser(): Response 26 | 27 | @GET("beers") 28 | suspend fun getResources(@Query("page") page: Int, @Query("per_page") limit: Int): Response> 29 | 30 | @GET("beers/{id}") 31 | suspend fun getResourcesDetails(@Path("id") id: Int): Response> 32 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_home.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 11 | 14 | 17 | 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/pagination/db/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.pagination.db 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | import com.composetemplate.core.pagination.model.RemoteKeys 8 | import com.composetemplate.core.pagination.model.Resource 9 | import com.composetemplate.core.pagination.dao.RemoteKeysDao 10 | import com.composetemplate.core.pagination.dao.ResourceDao 11 | 12 | 13 | @Database(version = 1, entities = [Resource::class, RemoteKeys::class]) 14 | abstract class AppDatabase : RoomDatabase() { 15 | 16 | abstract fun getRepoDao(): RemoteKeysDao 17 | abstract fun getResourceDao(): ResourceDao 18 | 19 | companion object { 20 | 21 | private const val RESOURCE_DB = "resource.db" 22 | 23 | @Volatile 24 | private var INSTANCE: AppDatabase? = null 25 | 26 | fun getInstance(context: Context): AppDatabase = 27 | INSTANCE ?: synchronized(this) { 28 | INSTANCE 29 | ?: buildDatabase(context).also { INSTANCE = it } 30 | } 31 | 32 | private fun buildDatabase(context: Context) = 33 | Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, RESOURCE_DB) 34 | .build() 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/ViewBindingExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import android.app.Activity 4 | import android.os.Looper 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.fragment.app.Fragment 9 | import androidx.viewbinding.ViewBinding 10 | 11 | inline fun Activity.viewBinder(crossinline bindingInflater: (LayoutInflater) -> T) = 12 | lazy(LazyThreadSafetyMode.NONE) { 13 | bindingInflater.invoke(layoutInflater) 14 | } 15 | 16 | fun AppCompatActivity.viewBinding( 17 | bindingInflater: (LayoutInflater) -> T, 18 | beforeSetContent: () -> Unit = {} 19 | ) = 20 | ActivityViewBindingDelegate(this, bindingInflater, beforeSetContent) 21 | 22 | fun Fragment.viewBinding( 23 | viewBindingFactory: (View) -> T, 24 | disposeEvents: T.() -> Unit = {} 25 | ) = 26 | FragmentViewBindingDelegate(this, viewBindingFactory, disposeEvents) 27 | 28 | fun globalViewBinding(viewBindingFactory: (View) -> T) = 29 | GlobalViewBindingDelegate(viewBindingFactory) 30 | 31 | internal fun ensureMainThread() { 32 | if (Looper.myLooper() != Looper.getMainLooper()) { 33 | throw IllegalThreadStateException("View can be accessed only on the main thread.") 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/ActivityViewBindingDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import android.view.LayoutInflater 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.lifecycle.* 6 | import androidx.viewbinding.ViewBinding 7 | import kotlin.properties.ReadOnlyProperty 8 | import kotlin.reflect.KProperty 9 | 10 | class ActivityViewBindingDelegate( 11 | private val activity: AppCompatActivity, 12 | private val viewBinder: (LayoutInflater) -> T, 13 | private val beforeSetContent: () -> Unit = {} 14 | ) : ReadOnlyProperty, DefaultLifecycleObserver { 15 | 16 | private var activityBinding: T? = null 17 | 18 | init { 19 | activity.lifecycle.addObserver(this) 20 | } 21 | 22 | override fun onCreate(owner: LifecycleOwner) { 23 | initialize() 24 | beforeSetContent() 25 | activity.setContentView(activityBinding?.root) 26 | activity.lifecycle.removeObserver(this) 27 | super.onCreate(owner) 28 | } 29 | 30 | private fun initialize() { 31 | if (activityBinding == null) { 32 | activityBinding = viewBinder(activity.layoutInflater) 33 | } 34 | } 35 | 36 | override fun getValue(thisRef: AppCompatActivity, property: KProperty<*>): T { 37 | ensureMainThread() 38 | 39 | initialize() 40 | return activityBinding!! 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_home_border.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 11 | 14 | 17 | 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/injection/modules/DataModule.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.injection.modules 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.SharedPreferencesMigration 6 | import androidx.datastore.preferences.core.Preferences 7 | import androidx.datastore.preferences.preferencesDataStore 8 | import com.composetemplate.core.data.storage.PreferenceDataStore 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | import dagger.hilt.components.SingletonComponent 14 | import javax.inject.Singleton 15 | 16 | @InstallIn(SingletonComponent::class) 17 | @Module 18 | class DataModule { 19 | 20 | private val Context.dataStore by preferencesDataStore( 21 | name = PreferenceDataStore.PREFS_NAME, 22 | produceMigrations = { context -> 23 | listOf( 24 | SharedPreferencesMigration( 25 | context, 26 | PreferenceDataStore.PREFS_NAME 27 | ) 28 | ) 29 | } 30 | ) 31 | 32 | @Singleton 33 | @Provides 34 | fun provideDataStore(@ApplicationContext context: Context): DataStore = 35 | context.dataStore 36 | 37 | @Singleton 38 | @Provides 39 | fun providePreferenceStorage(@ApplicationContext context: Context): PreferenceDataStore = 40 | PreferenceDataStore(context.dataStore) 41 | 42 | 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/ComposeExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.platform.LocalLifecycleOwner 8 | import androidx.lifecycle.Lifecycle 9 | import androidx.lifecycle.LifecycleOwner 10 | import androidx.lifecycle.flowWithLifecycle 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlin.coroutines.CoroutineContext 14 | import kotlin.coroutines.EmptyCoroutineContext 15 | 16 | @Composable 17 | fun rememberFlow( 18 | flow: Flow, 19 | lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current 20 | ): Flow { 21 | return remember(key1 = flow, key2 = lifecycleOwner) 22 | { flow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) } 23 | } 24 | 25 | @Composable 26 | fun Flow.collectAsStateLifecycleAware( 27 | initial: R, 28 | context: CoroutineContext = EmptyCoroutineContext 29 | ): State { 30 | val lifecycleAwareFlow = rememberFlow(flow = this) 31 | return lifecycleAwareFlow.collectAsState(initial = initial, context = context) 32 | } 33 | 34 | @Suppress("StateFlowValueCalledInComposition") 35 | @Composable 36 | fun StateFlow.collectAsStateLifecycleAware( 37 | context: CoroutineContext = EmptyCoroutineContext 38 | ): State = collectAsStateLifecycleAware(value, context) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/navigation/AppNavHost.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.navigation 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import androidx.navigation.NavHostController 6 | import androidx.navigation.compose.NavHost 7 | import com.composetemplate.core.navigation.home.homeScreen 8 | import com.composetemplate.core.navigation.home.navigateToHome 9 | import com.composetemplate.core.navigation.login.loginNavigationRoute 10 | import com.composetemplate.core.navigation.login.loginScreen 11 | import com.composetemplate.core.navigation.resource.navigateToResourceDetails 12 | import com.composetemplate.core.navigation.resource.resourceDetailsScreen 13 | import com.composetemplate.core.navigation.resource.resourcesGraph 14 | 15 | 16 | @Composable 17 | fun AppNavHost( 18 | navController: NavHostController, 19 | onBackClick: () -> Unit, 20 | modifier: Modifier = Modifier, 21 | startDestination: String = loginNavigationRoute 22 | ) { 23 | NavHost( 24 | navController = navController, 25 | startDestination = startDestination, 26 | modifier = modifier, 27 | ) { 28 | homeScreen() 29 | loginScreen(navigateToHome = { navController.navigateToHome() }) 30 | resourcesGraph( 31 | navController = navController, 32 | onItemClick = { navController.navigateToResourceDetails(it) }, 33 | nestedGraphs = { 34 | resourceDetailsScreen(navController,onBackClick) 35 | } 36 | ) 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/domain/error/ExceptionModel.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.domain.error 2 | 3 | import java.lang.Exception 4 | 5 | sealed class ExceptionModel : Exception() { 6 | 7 | sealed class Http : ExceptionModel() { 8 | object BadRequest : Http() 9 | object Unauthorized : Http() 10 | object Forbidden : Http() 11 | object NotFound : Http() 12 | object MethodNotAllowed : Http() 13 | 14 | object ServerException : Http() 15 | 16 | data class Custom( 17 | val code: Int?, 18 | override val message: String?, 19 | val errorBody: String? 20 | ) : Http() 21 | } 22 | 23 | sealed class DataException : ExceptionModel() { 24 | object NoData : DataException() 25 | data class ParseException(val throwable: Throwable) : DataException() 26 | } 27 | 28 | sealed class Connection : ExceptionModel() { 29 | object Timeout : Connection() 30 | object IOError : Connection() 31 | object UnknownHost : Connection() 32 | } 33 | 34 | sealed class FirebaseAuthException : ExceptionModel() { 35 | data class SendOtpException(val errorCause: String) : FirebaseAuthException() 36 | data class VerifyOtpException(val errorCause: String) : FirebaseAuthException() 37 | data class OtpExpireException(val errorCause: String) : FirebaseAuthException() 38 | data class InvalidOtpException(val errorCause: String) : FirebaseAuthException() 39 | } 40 | 41 | data class Unknown(val throwable: Throwable) : ExceptionModel() 42 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/data/SingleSharedPreferenceDataStore.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.data 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.edit 6 | import androidx.datastore.preferences.core.stringPreferencesKey 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.serialization.KSerializer 10 | import kotlinx.serialization.SerializationException 11 | import kotlinx.serialization.json.Json 12 | import timber.log.Timber 13 | 14 | abstract class SingleSharedPreferenceDataStore constructor( 15 | private val dataStore: DataStore, 16 | private val serializer: KSerializer 17 | ) : SingleDataSource { 18 | 19 | private val key = stringPreferencesKey(this.javaClass.simpleName) 20 | 21 | override suspend fun get(): T? { 22 | return try { 23 | val json = dataStore.data.map { it[key] ?: "" }.first() 24 | val entries = Json.decodeFromString(serializer, json) 25 | entries 26 | } catch (e: SerializationException) { 27 | null 28 | } 29 | } 30 | 31 | override suspend fun add(item: T) { 32 | try { 33 | val json = Json.encodeToString(serializer, item) 34 | dataStore.edit { 35 | it[key] = json 36 | } 37 | } catch (e: SerializationException) { 38 | Timber.e(e) 39 | } 40 | } 41 | 42 | override suspend fun clear() { 43 | dataStore.edit { it[key] = "" } 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/navigation/resource/ResourceDetailsNavigation.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.navigation.resource 2 | 3 | import androidx.navigation.* 4 | import androidx.navigation.compose.composable 5 | import com.composetemplate.core.domain.model.ResourceDetails 6 | import com.composetemplate.features.resources.ResourceDetailsRoute 7 | import org.jetbrains.annotations.VisibleForTesting 8 | import timber.log.Timber 9 | 10 | @VisibleForTesting 11 | internal const val resourceIdArg = "resourceId" 12 | internal const val resourceDetailsArg = "resourceDetailsArg" 13 | internal const val resourceDetailsRoute = "resource_details_route" 14 | internal const val resourceDetailsNavigationRoute = "$resourceDetailsRoute/{$resourceIdArg}" 15 | 16 | fun NavController.navigateToResourceDetails(resourceId: Int) { 17 | this.navigate("$resourceDetailsRoute/$resourceId") 18 | } 19 | 20 | fun NavGraphBuilder.resourceDetailsScreen( 21 | navController: NavHostController, 22 | onBackClick: () -> Unit 23 | ) { 24 | composable( 25 | route = resourceDetailsNavigationRoute, 26 | arguments = listOf( 27 | navArgument(resourceIdArg) { type = NavType.StringType }, 28 | ) 29 | ) { backStackEntry -> 30 | val arguments = requireNotNull(backStackEntry.arguments) 31 | val resourceId = arguments.getString(resourceIdArg) 32 | val resourceDetails = 33 | navController.previousBackStackEntry?.savedStateHandle?.get("resourceDetails") 34 | Timber.tag("resource!!").d(resourceDetails?.name) 35 | ResourceDetailsRoute(resourceId = resourceId, onBackClick = onBackClick) 36 | } 37 | } -------------------------------------------------------------------------------- /scripts/conventional-pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # list of Conventional Commits types 4 | cc_types=("feat" "fix") 5 | default_types=("build" "chore" "ci" "docs" "${cc_types[@]}" "perf" "refactor" "revert" "style" "test") 6 | types=( "${cc_types[@]}" ) 7 | 8 | if [ $# -eq 1 ]; then 9 | types=( "${default_types[@]}" ) 10 | else 11 | # assume all args but the last are types 12 | while [ $# -gt 1 ]; do 13 | types+=( "$1" ) 14 | shift 15 | done 16 | fi 17 | 18 | # the commit message file is the last remaining arg 19 | msg_file="$1" 20 | 21 | # join types with | to form regex ORs 22 | r_types="($(IFS='|'; echo "${types[*]}"))" 23 | # optional (scope) 24 | r_scope="(\([[:alnum:] \/-]+\))?" 25 | # optional breaking change indicator and colon delimiter 26 | r_delim='!?:' 27 | # subject line, body, footer 28 | r_subject=" [[:alnum:]].+" 29 | # the full regex pattern 30 | pattern="^$r_types$r_scope$r_delim$r_subject$" 31 | 32 | # Check if commit is conventional commit 33 | if grep -Eq "$pattern" "$msg_file"; then 34 | exit 0 35 | fi 36 | 37 | echo "[Commit message] $( cat "$msg_file" )" 38 | echo " 39 | Your commit message does not follow Conventional Commits formatting 40 | https://www.conventionalcommits.org/ 41 | Conventional Commits start with one of the below types, followed by a colon, 42 | followed by the commit message: 43 | $(IFS=' '; echo "${types[*]}") 44 | Example commit message adding a feature: 45 | feat: implement new API 46 | Example commit message fixing an issue: 47 | fix: remove infinite loop 48 | Optionally, include a scope in parentheses after the type for more context: 49 | fix(account): remove infinite loop 50 | " 51 | exit 1 -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/data/SharedPreferenceDataStore.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.data 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.edit 6 | import androidx.datastore.preferences.core.stringPreferencesKey 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.serialization.KSerializer 10 | import kotlinx.serialization.SerializationException 11 | import kotlinx.serialization.builtins.ListSerializer 12 | import kotlinx.serialization.json.Json 13 | 14 | abstract class SharedPreferenceDataStore constructor( 15 | private val dataStore: DataStore, 16 | private val serializer: KSerializer 17 | ) : DataSource { 18 | 19 | private val key = stringPreferencesKey(this.javaClass.simpleName) 20 | 21 | override suspend fun getAll(): List { 22 | return try { 23 | val json = dataStore.data.map { it[key] ?: "" }.first() 24 | Json.decodeFromString(ListSerializer(serializer), json) 25 | } catch (e: SerializationException) { 26 | emptyList() 27 | } 28 | 29 | } 30 | 31 | override suspend fun add(item: T) { 32 | val list = getAll().toMutableList() 33 | list.add(item) 34 | addAll(list) 35 | } 36 | 37 | override suspend fun addAll(items: List) { 38 | val json = Json.encodeToString(ListSerializer(serializer), items) 39 | dataStore.edit { 40 | it[key] = json 41 | } 42 | } 43 | 44 | override suspend fun clear() { 45 | dataStore.edit { 46 | it[key] = "" 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/AppTopAppBar.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.material3.* 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.graphics.vector.ImageVector 8 | import androidx.compose.ui.res.stringResource 9 | 10 | @OptIn(ExperimentalMaterial3Api::class) 11 | @Composable 12 | fun AppTopAppBar( 13 | @StringRes titleRes: Int, 14 | navigationIcon: ImageVector, 15 | navigationIconContentDescription: String?, 16 | actionIcon: ImageVector, 17 | actionIconContentDescription: String?, 18 | modifier: Modifier = Modifier, 19 | colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), 20 | onNavigationClick: () -> Unit = {}, 21 | onActionClick: () -> Unit = {} 22 | ) { 23 | CenterAlignedTopAppBar( 24 | title = { Text(text = stringResource(id = titleRes)) }, 25 | navigationIcon = { 26 | IconButton(onClick = onNavigationClick) { 27 | Icon( 28 | imageVector = navigationIcon, 29 | contentDescription = navigationIconContentDescription, 30 | tint = MaterialTheme.colorScheme.onSurface 31 | ) 32 | } 33 | }, 34 | actions = { 35 | IconButton(onClick = onActionClick) { 36 | Icon( 37 | imageVector = actionIcon, 38 | contentDescription = actionIconContentDescription, 39 | tint = MaterialTheme.colorScheme.onSurface 40 | ) 41 | } 42 | }, 43 | colors = colors, 44 | modifier = modifier 45 | ) 46 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_resources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 12 | 15 | 18 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/injection/modules/RestModule.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.injection.modules 2 | 3 | import com.composetemplate.BuildConfig 4 | import com.composetemplate.core.data.network.Api 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import kotlinx.serialization.ExperimentalSerializationApi 10 | import okhttp3.OkHttpClient 11 | import okhttp3.logging.HttpLoggingInterceptor 12 | import retrofit2.Retrofit 13 | import retrofit2.converter.gson.GsonConverterFactory 14 | import java.util.concurrent.TimeUnit 15 | import javax.inject.Singleton 16 | 17 | @InstallIn(SingletonComponent::class) 18 | @Module 19 | class RestModule { 20 | 21 | @Provides 22 | @Singleton 23 | fun provideHttpClient(): OkHttpClient { 24 | val clientBuilder = OkHttpClient.Builder() 25 | .connectTimeout(45, TimeUnit.SECONDS) 26 | .readTimeout(60, TimeUnit.SECONDS) 27 | .writeTimeout(60, TimeUnit.SECONDS) 28 | 29 | if (BuildConfig.DEBUG) { 30 | val logging = HttpLoggingInterceptor() 31 | logging.level = HttpLoggingInterceptor.Level.BODY 32 | clientBuilder.addInterceptor(logging) 33 | } 34 | 35 | return clientBuilder.build() 36 | } 37 | 38 | 39 | @ExperimentalSerializationApi 40 | @Provides 41 | @Singleton 42 | fun provideRetrofit(client: OkHttpClient): Retrofit { 43 | return Retrofit.Builder() 44 | .addConverterFactory(GsonConverterFactory.create()) 45 | .client(client) 46 | .baseUrl(BuildConfig.API_URL) 47 | .build() 48 | } 49 | 50 | @Provides 51 | @Singleton 52 | fun provideApi(retrofit: Retrofit): Api { 53 | return retrofit.create(Api::class.java) 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/features/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.features.login 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.bindError 5 | import androidx.lifecycle.bindLoading 6 | import androidx.lifecycle.viewModelScope 7 | import com.composetemplate.arch.extensions.LoadingAware 8 | import com.composetemplate.arch.extensions.ViewErrorAware 9 | import com.composetemplate.arch.extensions.onSuccess 10 | import com.composetemplate.core.usecases.user.LoginUseCase 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.flow.MutableSharedFlow 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.launchIn 15 | import javax.inject.Inject 16 | 17 | @HiltViewModel 18 | class LoginViewModel @Inject constructor( 19 | private val loginUseCase: LoginUseCase 20 | ) : ViewModel(), ViewErrorAware, LoadingAware { 21 | val loginUiInfo by lazy { 22 | MutableStateFlow( 23 | LoginUiInfo("", "") 24 | ) 25 | } 26 | val loginResultFlow: MutableSharedFlow = MutableSharedFlow() 27 | 28 | fun login() { 29 | loginUseCase 30 | .login(loginUiInfo.value.userName, loginUiInfo.value.password) 31 | .bindLoading(this) 32 | .bindError(this) 33 | .onSuccess { 34 | loginResultFlow.emit(true) 35 | } 36 | .launchIn(viewModelScope) 37 | } 38 | 39 | fun onUserNameChanged(userName: String) { 40 | loginUiInfo.value = loginUiInfo.value.copy(userName = userName) 41 | } 42 | 43 | fun onPasswordChanged(password: String) { 44 | loginUiInfo.value = loginUiInfo.value.copy(password = password) 45 | } 46 | } 47 | 48 | data class LoginUiInfo( 49 | val userName: String, 50 | val password: String 51 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_resources_border.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 12 | 15 | 18 | 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/domain/error/ErrorMapping.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.domain.error 2 | 3 | 4 | import com.google.gson.Gson 5 | import com.composetemplate.core.data.network.dtos.ErrorDto 6 | import kotlinx.serialization.SerializationException 7 | import okhttp3.ResponseBody 8 | import retrofit2.Response 9 | import java.io.IOException 10 | import java.net.SocketTimeoutException 11 | import java.net.UnknownHostException 12 | 13 | @Suppress("MagicNumber") 14 | fun Response.toException(): ExceptionModel.Http { 15 | return when { 16 | code() == 400 -> ExceptionModel.Http.BadRequest 17 | code() == 401 -> ExceptionModel.Http.Unauthorized 18 | code() == 403 -> ExceptionModel.Http.Forbidden 19 | code() == 404 -> ExceptionModel.Http.NotFound 20 | code() == 405 -> ExceptionModel.Http.MethodNotAllowed 21 | code() in 500..600 -> ExceptionModel.Http.ServerException 22 | else -> ExceptionModel.Http.Custom( 23 | code(), 24 | message(), 25 | convertToErrorDto(errorBody())?.message 26 | ) 27 | } 28 | } 29 | 30 | @Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught") 31 | fun convertToErrorDto(errorBody: ResponseBody?): ErrorDto? { 32 | return try { 33 | Gson().fromJson( 34 | errorBody?.string(), 35 | ErrorDto::class.java 36 | ) 37 | } catch (e: Exception) { 38 | throw Throwable("Unknown error") 39 | } 40 | } 41 | 42 | 43 | fun Throwable.toException(): ExceptionModel { 44 | return when (this) { 45 | is SocketTimeoutException -> ExceptionModel.Connection.Timeout 46 | is UnknownHostException -> ExceptionModel.Connection.UnknownHost 47 | is IOException -> ExceptionModel.Connection.IOError 48 | is SerializationException -> ExceptionModel.DataException.ParseException(this) 49 | else -> ExceptionModel.Unknown(this) 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/AppIcons.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.MoreVert 6 | import androidx.compose.material.icons.outlined.AccountCircle 7 | import androidx.compose.material.icons.rounded.* 8 | import androidx.compose.ui.graphics.vector.ImageVector 9 | import com.composetemplate.R 10 | 11 | /** 12 | * App icons. Material icons are [ImageVector]s, custom icons are drawable resource IDs. 13 | */ 14 | object AppIcons { 15 | val AccountCircle = Icons.Outlined.AccountCircle 16 | val Add = Icons.Rounded.Add 17 | val ArrowBack = Icons.Rounded.ArrowBack 18 | val ArrowDropDown = Icons.Rounded.ArrowDropDown 19 | val ArrowDropUp = Icons.Rounded.ArrowDropUp 20 | val Home = R.drawable.ic_home 21 | val HomeBorder = R.drawable.ic_home_border 22 | val Resources = R.drawable.ic_resources 23 | val ResourcesBorder = R.drawable.ic_resources 24 | val Check = Icons.Rounded.Check 25 | val Close = Icons.Rounded.Close 26 | val ExpandLess = Icons.Rounded.ExpandLess 27 | val Fullscreen = Icons.Rounded.Fullscreen 28 | val Grid3x3 = Icons.Rounded.Grid3x3 29 | val MoreVert = Icons.Default.MoreVert 30 | val Person = Icons.Rounded.Person 31 | val PlayArrow = Icons.Rounded.PlayArrow 32 | val Search = Icons.Rounded.Search 33 | val Settings = Icons.Rounded.Settings 34 | val ShortText = Icons.Rounded.ShortText 35 | val Tag = Icons.Rounded.Tag 36 | val ViewDay = Icons.Rounded.ViewDay 37 | val VolumeOff = Icons.Rounded.VolumeOff 38 | val VolumeUp = Icons.Rounded.VolumeUp 39 | } 40 | 41 | /** 42 | * A sealed class to make dealing with [ImageVector] and [DrawableRes] icons easier. 43 | */ 44 | sealed class Icon { 45 | data class ImageVectorIcon(val imageVector: ImageVector) : Icon() 46 | data class DrawableResourceIcon(@DrawableRes val id: Int) : Icon() 47 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/FlowExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.combine 6 | import kotlinx.coroutines.flow.flow 7 | import kotlinx.coroutines.launch 8 | 9 | fun CoroutineScope.combineFlows( 10 | flow1: Flow, 11 | flow2: Flow, 12 | collectBlock: (suspend (T1, T2) -> Unit) 13 | ) { 14 | launch { 15 | flow1.combine(flow2) { v1, v2 -> 16 | collectBlock.invoke(v1, v2) 17 | }.collect { 18 | // Empty collect block to trigger ^ 19 | } 20 | } 21 | } 22 | 23 | fun CoroutineScope.combineFlows( 24 | flow1: Flow, 25 | flow2: Flow, 26 | flow3: Flow, 27 | collectBlock: (suspend (T1, T2, T3) -> Unit) 28 | ) { 29 | launch { 30 | combine(flow1, flow2, flow3) { v1, v2, v3 -> 31 | collectBlock.invoke(v1, v2, v3) 32 | }.collect { 33 | // Empty collect block to trigger ^ 34 | } 35 | } 36 | } 37 | 38 | fun CoroutineScope.combineFlows( 39 | flow1: Flow, 40 | flow2: Flow, 41 | flow3: Flow, 42 | flow4: Flow, 43 | collectBlock: (suspend (T1, T2, T3, T4) -> Unit) 44 | ) { 45 | launch { 46 | combine(flow1, flow2, flow3, flow4) { v1, v2, v3, v4 -> 47 | collectBlock.invoke(v1, v2, v3, v4) 48 | }.collect { 49 | // Empty collect block to trigger ^ 50 | } 51 | } 52 | } 53 | 54 | fun Flow.throttleFirst(windowDuration: Long): Flow = flow { 55 | var lastEmissionTime = 0L 56 | collect { upstream -> 57 | val currentTime = System.currentTimeMillis() 58 | val mayEmit = currentTime - lastEmissionTime > windowDuration 59 | if (mayEmit) { 60 | lastEmissionTime = currentTime 61 | emit(upstream) 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/features/resources/ResourceDetailsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.features.resources 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | import androidx.hilt.navigation.compose.hiltViewModel 13 | import androidx.lifecycle.loadingFlow 14 | import coil.compose.rememberAsyncImagePainter 15 | import com.composetemplate.arch.extensions.collectAsStateLifecycleAware 16 | import com.composetemplate.core.domain.model.ResourceDetails 17 | import com.composetemplate.core.ui.CircularProgressBar 18 | 19 | 20 | @Composable 21 | internal fun ResourceDetailsRoute( 22 | modifier: Modifier = Modifier, 23 | viewModel: ResourceDetailsViewModel = hiltViewModel(), 24 | onBackClick: () -> Unit, 25 | resourceId: String? 26 | ) { 27 | val resource = viewModel.resourceDetails.collectAsStateLifecycleAware().value 28 | val isLoading = viewModel.loadingFlow.collectAsStateLifecycleAware().value 29 | LaunchedEffect(key1 = Unit) { 30 | resourceId?.toInt()?.let { viewModel.getResourceDetails(it) } 31 | // Timber.tag("ResourceDetails!!").d(resourceDetails.name) 32 | } 33 | if (isLoading) { 34 | CircularProgressBar() 35 | } 36 | ResourceDetailsScreen(resource, onBackClick) 37 | } 38 | 39 | @Composable 40 | fun ResourceDetailsScreen(resource: ResourceDetails, onBackClick: () -> Unit) { 41 | Column( 42 | modifier = Modifier 43 | .fillMaxSize() 44 | .padding(20.dp), 45 | verticalArrangement = Arrangement.Center 46 | ) { 47 | Image( 48 | painter = rememberAsyncImagePainter(resource.imageUrl), 49 | contentDescription = resource.name, 50 | modifier = Modifier.fillMaxSize() 51 | ) 52 | 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/FragmentViewBindingDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import android.view.View 4 | import androidx.fragment.app.Fragment 5 | import androidx.lifecycle.* 6 | import androidx.viewbinding.ViewBinding 7 | import kotlin.properties.ReadOnlyProperty 8 | import kotlin.reflect.KProperty 9 | 10 | class FragmentViewBindingDelegate( 11 | private val fragment: Fragment, 12 | private val viewBinder: (View) -> T, 13 | private val disposeEvents: T.() -> Unit = {} 14 | ) : ReadOnlyProperty, DefaultLifecycleObserver { 15 | 16 | private inline fun Fragment.observeLifecycleOwnerThroughLifecycleCreation( 17 | crossinline viewOwner: LifecycleOwner.() -> Unit 18 | ) { 19 | lifecycle.addObserver(object : DefaultLifecycleObserver { 20 | override fun onCreate(owner: LifecycleOwner) { 21 | viewLifecycleOwnerLiveData.observe( 22 | this@observeLifecycleOwnerThroughLifecycleCreation, 23 | Observer { viewLifecycleOwner -> 24 | viewLifecycleOwner.viewOwner() 25 | } 26 | ) 27 | } 28 | }) 29 | } 30 | 31 | private var fragmentBinding: T? = null 32 | 33 | override fun onDestroy(owner: LifecycleOwner) { 34 | disposeBinding() 35 | super.onDestroy(owner) 36 | } 37 | 38 | private fun disposeBinding() { 39 | fragmentBinding?.disposeEvents() 40 | fragmentBinding = null 41 | } 42 | 43 | init { 44 | fragment.observeLifecycleOwnerThroughLifecycleCreation { 45 | lifecycle.addObserver(this@FragmentViewBindingDelegate) 46 | } 47 | } 48 | 49 | override fun getValue(thisRef: Fragment, property: KProperty<*>): T { 50 | 51 | ensureMainThread() 52 | 53 | val binding = fragmentBinding 54 | if (binding != null) { 55 | return binding 56 | } 57 | 58 | val lifecycle = fragment.viewLifecycleOwner.lifecycle 59 | if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { 60 | throw IllegalStateException("Fragment views are destroyed.") 61 | } 62 | return viewBinder(thisRef.requireView()).also { fragmentBinding = it } 63 | } 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/features/login/LoginScreen.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.features.login 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.res.stringResource 7 | import androidx.compose.ui.tooling.preview.Preview 8 | import androidx.compose.ui.unit.dp 9 | import androidx.hilt.navigation.compose.hiltViewModel 10 | import com.composetemplate.arch.extensions.collectAsStateLifecycleAware 11 | import com.composetemplate.core.ui.AppBackground 12 | import com.composetemplate.core.ui.AppButton 13 | import com.composetemplate.core.ui.InputTextField 14 | 15 | 16 | @Composable 17 | internal fun LoginRoute( 18 | modifier: Modifier = Modifier, 19 | viewModel: LoginViewModel = hiltViewModel(), 20 | navigateToHome: () -> Unit = {} 21 | ) { 22 | val loginUiInfo = viewModel.loginUiInfo.collectAsStateLifecycleAware().value 23 | LoginScreen( 24 | loginUiInfo = loginUiInfo, 25 | onUserNameChanged = viewModel::onUserNameChanged, 26 | onPasswordChanged = viewModel::onPasswordChanged, 27 | login = viewModel::login, 28 | navigateToHome = navigateToHome 29 | ) 30 | } 31 | 32 | @Composable 33 | fun LoginScreen( 34 | loginUiInfo: LoginUiInfo, 35 | onUserNameChanged: (String) -> Unit, 36 | onPasswordChanged: (String) -> Unit, 37 | login: () -> Unit, 38 | navigateToHome: () -> Unit 39 | ) { 40 | Column( 41 | modifier = Modifier 42 | .fillMaxSize() 43 | .padding(20.dp), 44 | verticalArrangement = Arrangement.Center 45 | ) { 46 | 47 | InputTextField(text = loginUiInfo.userName) { 48 | onUserNameChanged(it) 49 | } 50 | Spacer(modifier = Modifier.height(10.dp)) 51 | InputTextField(text = loginUiInfo.password) { 52 | onPasswordChanged(it) 53 | } 54 | Spacer(modifier = Modifier.height(20.dp)) 55 | AppButton(text = stringResource(id = com.composetemplate.R.string.login)) { 56 | //TODO call login() 57 | navigateToHome() 58 | } 59 | 60 | } 61 | 62 | } 63 | 64 | @Preview 65 | @Composable 66 | fun LoginPreview() { 67 | AppBackground { 68 | LoginScreen(LoginUiInfo("", ""), {}, {}, {}, {}) 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/navigation/Destination.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.navigation 2 | 3 | import com.composetemplate.R 4 | import com.composetemplate.core.navigation.home.homeNavigationRoute 5 | import com.composetemplate.core.navigation.login.loginNavigationRoute 6 | import com.composetemplate.core.navigation.resource.resourceDetailsNavigationRoute 7 | import com.composetemplate.core.navigation.resource.resourcesNavigationRoute 8 | import com.composetemplate.core.ui.AppIcons 9 | import com.composetemplate.core.ui.Icon 10 | 11 | 12 | /** 13 | * Type for the top level destinations in the application. Each of these destinations 14 | * can contain one or more screens (based on the window size). Navigation from one screen to the 15 | * next within a single destination will be handled directly in composables. 16 | */ 17 | 18 | 19 | enum class Destination( 20 | val isTopLevelDestination: Boolean, 21 | val isBottomBarTab: Boolean, 22 | val isTopBarTab: Boolean, 23 | val selectedIcon: Icon? = null, 24 | val unselectedIcon: Icon? = null, 25 | val iconTextId: Int? = null, 26 | val titleTextId: Int, 27 | val route: String 28 | ) { 29 | HOME( 30 | isTopLevelDestination = true, 31 | isBottomBarTab = true, 32 | isTopBarTab = true, 33 | selectedIcon = Icon.DrawableResourceIcon(AppIcons.Home), 34 | unselectedIcon = Icon.DrawableResourceIcon(AppIcons.HomeBorder), 35 | iconTextId = R.string.home, 36 | titleTextId = R.string.home, 37 | route = homeNavigationRoute 38 | ), 39 | RESOURCES( 40 | isTopLevelDestination = true, 41 | isBottomBarTab = true, 42 | isTopBarTab = true, 43 | selectedIcon = Icon.DrawableResourceIcon(AppIcons.Resources), 44 | unselectedIcon = Icon.DrawableResourceIcon(AppIcons.ResourcesBorder), 45 | iconTextId = R.string.resources, 46 | titleTextId = R.string.resources, 47 | route = resourcesNavigationRoute 48 | ), 49 | LOGIN( 50 | isTopLevelDestination = true, 51 | isBottomBarTab = false, 52 | isTopBarTab = false, 53 | titleTextId = R.string.login, 54 | route = loginNavigationRoute 55 | ), 56 | 57 | RESOURCE_DETAILS( 58 | isTopLevelDestination = false, 59 | isBottomBarTab = false, 60 | isTopBarTab = true, 61 | titleTextId = R.string.resources, 62 | route = resourceDetailsNavigationRoute 63 | ) 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/features/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.features.main 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.foundation.isSystemInDarkTheme 7 | import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi 8 | import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass 9 | import androidx.compose.runtime.DisposableEffect 10 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 11 | import androidx.core.view.WindowCompat 12 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 13 | import com.composetemplate.core.ui.AndroidTemplateApp 14 | import com.composetemplate.core.ui.AppBackground 15 | import com.composetemplate.core.ui.AppTheme 16 | import com.composetemplate.core.ui.rememberAppState 17 | import dagger.hilt.android.AndroidEntryPoint 18 | 19 | @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) 20 | @AndroidEntryPoint 21 | class MainActivity : ComponentActivity() { 22 | 23 | override fun onCreate(savedInstanceState: Bundle?) { 24 | val splashScreen = installSplashScreen() 25 | super.onCreate(savedInstanceState) 26 | splashScreen.setKeepOnScreenCondition { 27 | false 28 | //TODO 29 | //return true while fetching data from network 30 | // when (uiState) { 31 | // Loading -> true 32 | // is Success -> false 33 | // } 34 | 35 | } 36 | WindowCompat.setDecorFitsSystemWindows(window, false) 37 | setContent { 38 | val systemUiController = rememberSystemUiController() 39 | val darkTheme = isSystemInDarkTheme() 40 | 41 | // Update the dark content of the system bars to match the theme 42 | DisposableEffect(systemUiController, darkTheme) { 43 | systemUiController.systemBarsDarkContentEnabled = !darkTheme 44 | onDispose {} 45 | } 46 | AppTheme { 47 | AppBackground { 48 | AndroidTemplateApp( 49 | appState = rememberAppState( 50 | windowSizeClass = calculateWindowSizeClass( 51 | this 52 | ) 53 | ) 54 | ) 55 | } 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/ViewModelStoreOwnerExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.lifecycle.Lifecycle 5 | import androidx.lifecycle.LifecycleObserver 6 | import androidx.lifecycle.LifecycleOwner 7 | import androidx.lifecycle.OnLifecycleEvent 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.ViewModelProvider 10 | import androidx.lifecycle.ViewModelStoreOwner 11 | import java.io.Serializable 12 | 13 | inline fun ViewModelStoreOwner.getViewModel(factory: ViewModelProvider.Factory): VM { 14 | return ViewModelProvider(this, factory).get(VM::class.java) 15 | } 16 | 17 | inline fun Fragment.getSharedViewModel(factory: ViewModelProvider.Factory): VM { 18 | return ViewModelProvider(requireActivity(), factory).get(VM::class.java) 19 | } 20 | 21 | private object UninitializedValue 22 | 23 | /** 24 | * This was copied from SynchronizedLazyImpl but modified to automatically initialize in ON_CREATE. 25 | */ 26 | @Suppress("ClassName") 27 | class lifecycleAwareLazy(private val owner: LifecycleOwner, initializer: () -> T) : 28 | Lazy, 29 | Serializable { 30 | private var initializer: (() -> T)? = initializer 31 | @Volatile 32 | private var _value: Any? = UninitializedValue 33 | // final field is required to enable safe publication of constructed instance 34 | private val lock = this 35 | 36 | init { 37 | owner.lifecycle.addObserver(object : LifecycleObserver { 38 | @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) 39 | fun onStart() { 40 | if (!isInitialized()) value 41 | owner.lifecycle.removeObserver(this) 42 | } 43 | }) 44 | } 45 | 46 | @Suppress("LocalVariableName") 47 | override val value: T 48 | get() { 49 | val _v1 = _value 50 | if (_v1 !== UninitializedValue) { 51 | @Suppress("UNCHECKED_CAST") 52 | return _v1 as T 53 | } 54 | 55 | return synchronized(lock) { 56 | val _v2 = _value 57 | if (_v2 !== UninitializedValue) { 58 | @Suppress("UNCHECKED_CAST") (_v2 as T) 59 | } else { 60 | val typedValue = initializer!!() 61 | _value = typedValue 62 | initializer = null 63 | typedValue 64 | } 65 | } 66 | } 67 | 68 | override fun isInitialized(): Boolean = _value !== UninitializedValue 69 | 70 | override fun toString(): String = 71 | if (isInitialized()) value.toString() else "Lazy value not initialized yet." 72 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/UseCaseResult.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import com.composetemplate.core.domain.error.ExceptionModel 4 | import com.composetemplate.core.domain.error.toException 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.flow.* 7 | import timber.log.Timber 8 | 9 | sealed class UseCaseResult { 10 | data class Success(val value: T) : UseCaseResult() 11 | data class Error(val exception: ExceptionModel) : UseCaseResult() 12 | } 13 | 14 | suspend inline fun safeUseCase( 15 | crossinline block: suspend () -> T, 16 | ): UseCaseResult = try { 17 | UseCaseResult.Success(block()) 18 | } catch (e: ExceptionModel) { 19 | UseCaseResult.Error(e.toException()) 20 | } 21 | 22 | @Suppress("TooGenericExceptionCaught") 23 | inline fun useCaseFlow( 24 | coroutineDispatcher: CoroutineDispatcher, 25 | crossinline block: suspend () -> T, 26 | ): Flow> = flow { 27 | try { 28 | val repoResult = block() 29 | emit(UseCaseResult.Success(repoResult)) 30 | } catch (e: ExceptionModel) { 31 | emit(UseCaseResult.Error(e)) 32 | } catch (e: Exception) { 33 | emit(UseCaseResult.Error(e.toException())) 34 | } 35 | }.flowOn(coroutineDispatcher) 36 | 37 | @Suppress("TooGenericExceptionCaught") 38 | inline fun useCaseWithoutBodyFlow( 39 | coroutineDispatcher: CoroutineDispatcher, 40 | crossinline block: suspend () -> Unit, 41 | ): Flow> = flow { 42 | try { 43 | val repoResult = block() 44 | emit(UseCaseResult.Success(repoResult)) 45 | } catch (e: ExceptionModel) { 46 | emit(UseCaseResult.Error(e)) 47 | } catch (e: Exception) { 48 | emit(UseCaseResult.Error(e.toException())) 49 | } 50 | }.flowOn(coroutineDispatcher) 51 | 52 | 53 | 54 | fun observableFlow(block: suspend FlowCollector.() -> Unit): Flow> = 55 | flow(block) 56 | .catch { exception -> 57 | Timber.e(exception) 58 | UseCaseResult.Error(exception.toException()) 59 | } 60 | .map { 61 | UseCaseResult.Success(it) 62 | } 63 | 64 | fun Flow>.onSuccess(action: suspend (T) -> Unit): Flow> = 65 | transform { result -> 66 | if (result is UseCaseResult.Success) { 67 | action(result.value) 68 | } 69 | return@transform emit(result) 70 | } 71 | 72 | fun Flow>.onError(action: suspend (ExceptionModel) -> Unit): Flow> = 73 | transform { result -> 74 | if (result is UseCaseResult.Error) { 75 | action(result.exception) 76 | } 77 | return@transform emit(result) 78 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | /** 10 | * typography. 11 | * 12 | * TODO: Add custom font 13 | */ 14 | internal val AppTypography = Typography( 15 | displayLarge = TextStyle( 16 | fontWeight = FontWeight.W400, 17 | fontSize = 57.sp, 18 | lineHeight = 64.sp, 19 | letterSpacing = (-0.25).sp 20 | ), 21 | displayMedium = TextStyle( 22 | fontWeight = FontWeight.W400, 23 | fontSize = 45.sp, 24 | lineHeight = 52.sp 25 | ), 26 | displaySmall = TextStyle( 27 | fontWeight = FontWeight.W400, 28 | fontSize = 36.sp, 29 | lineHeight = 44.sp 30 | ), 31 | headlineLarge = TextStyle( 32 | fontWeight = FontWeight.W400, 33 | fontSize = 32.sp, 34 | lineHeight = 40.sp 35 | ), 36 | headlineMedium = TextStyle( 37 | fontWeight = FontWeight.W400, 38 | fontSize = 28.sp, 39 | lineHeight = 36.sp 40 | ), 41 | headlineSmall = TextStyle( 42 | fontWeight = FontWeight.W400, 43 | fontSize = 24.sp, 44 | lineHeight = 32.sp 45 | ), 46 | titleLarge = TextStyle( 47 | fontWeight = FontWeight.W700, 48 | fontSize = 22.sp, 49 | lineHeight = 28.sp 50 | ), 51 | titleMedium = TextStyle( 52 | fontWeight = FontWeight.W700, 53 | fontSize = 16.sp, 54 | lineHeight = 24.sp, 55 | letterSpacing = 0.1.sp 56 | ), 57 | titleSmall = TextStyle( 58 | fontWeight = FontWeight.W500, 59 | fontSize = 14.sp, 60 | lineHeight = 20.sp, 61 | letterSpacing = 0.1.sp 62 | ), 63 | bodyLarge = TextStyle( 64 | fontWeight = FontWeight.W400, 65 | fontSize = 16.sp, 66 | lineHeight = 24.sp, 67 | letterSpacing = 0.5.sp 68 | ), 69 | bodyMedium = TextStyle( 70 | fontWeight = FontWeight.W400, 71 | fontSize = 14.sp, 72 | lineHeight = 20.sp, 73 | letterSpacing = 0.25.sp 74 | ), 75 | bodySmall = TextStyle( 76 | fontWeight = FontWeight.W400, 77 | fontSize = 12.sp, 78 | lineHeight = 16.sp, 79 | letterSpacing = 0.4.sp 80 | ), 81 | labelLarge = TextStyle( 82 | fontWeight = FontWeight.W400, 83 | fontSize = 14.sp, 84 | lineHeight = 20.sp, 85 | letterSpacing = 0.1.sp 86 | ), 87 | labelMedium = TextStyle( 88 | fontWeight = FontWeight.W400, 89 | fontSize = 12.sp, 90 | lineHeight = 16.sp, 91 | letterSpacing = 0.5.sp 92 | ), 93 | labelSmall = TextStyle( 94 | fontFamily = FontFamily.Monospace, 95 | fontWeight = FontWeight.W500, 96 | fontSize = 10.sp, 97 | lineHeight = 16.sp 98 | ) 99 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/ViewModelEx.kt: -------------------------------------------------------------------------------- 1 | // Package set to androidx.lifecycle so we can have access to package private methods 2 | 3 | @file:Suppress("PackageDirectoryMismatch") 4 | 5 | package androidx.lifecycle 6 | 7 | 8 | import com.composetemplate.arch.extensions.* 9 | import com.composetemplate.core.domain.error.ErrorMessage 10 | import com.composetemplate.core.sharedui.errorhandling.ViewError 11 | import com.composetemplate.core.sharedui.errorhandling.mapToViewError 12 | 13 | import kotlinx.coroutines.flow.* 14 | import kotlinx.coroutines.launch 15 | import java.util.* 16 | 17 | private const val ERROR_FLOW_KEY = "androidx.lifecycle.ErrorFlow" 18 | private const val LOADING_FLOW_KEY = "androidx.lifecycle.LoadingFlow" 19 | val errorMessage by lazy { MutableStateFlow(ErrorMessage(-1, "")) } 20 | 21 | fun T.sendViewError(viewError: ViewError) where T : ViewErrorAware, T : ViewModel { 22 | viewModelScope.launch { 23 | getErrorMutableSharedFlow().emit(viewError) 24 | } 25 | } 26 | 27 | suspend fun T.emitViewError(viewError: ViewError) where T : ViewErrorAware, T : ViewModel { 28 | getErrorMutableSharedFlow().emit(viewError) 29 | } 30 | 31 | val T.viewErrorFlow: SharedFlow where T : ViewErrorAware, T : ViewModel 32 | get() { 33 | return getErrorMutableSharedFlow() 34 | } 35 | 36 | 37 | val T.loadingFlow: StateFlow where T : LoadingAware, T : ViewModel 38 | get() { 39 | return loadingMutableStateFlow 40 | } 41 | 42 | var T.isLoading: Boolean where T : LoadingAware, T : ViewModel 43 | get() { 44 | return loadingMutableStateFlow.value 45 | } 46 | set(value) { 47 | loadingMutableStateFlow.tryEmit(value) 48 | } 49 | 50 | private val T.loadingMutableStateFlow: MutableStateFlow where T : LoadingAware, T : ViewModel 51 | get() { 52 | val flow: MutableStateFlow? = getTag(LOADING_FLOW_KEY) 53 | return flow ?: setTagIfAbsent(LOADING_FLOW_KEY, MutableStateFlow(false)) 54 | } 55 | 56 | private fun T.getErrorMutableSharedFlow(): MutableSharedFlow where T : ViewErrorAware, T : ViewModel { 57 | val flow: MutableSharedFlow? = getTag(ERROR_FLOW_KEY) 58 | return flow ?: setTagIfAbsent(ERROR_FLOW_KEY, MutableSharedFlow()) 59 | } 60 | 61 | 62 | fun Flow.bindLoading(t: T): Flow where T : LoadingAware, T : ViewModel { 63 | return this 64 | .onStart { 65 | t.loadingMutableStateFlow.value = true 66 | } 67 | .onCompletion { 68 | t.loadingMutableStateFlow.value = false 69 | } 70 | } 71 | 72 | 73 | 74 | fun Flow>.bindError(t: T): Flow> where T : ViewErrorAware, T : ViewModel { 75 | return this 76 | .onError { 77 | t.emitViewError(it.mapToViewError()) 78 | errorMessage.value = ErrorMessage( 79 | id = UUID.randomUUID().mostSignificantBits, 80 | message = it.mapToViewError().message 81 | ) 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/sharedui/errorhandling/ViewErrorController.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.sharedui.errorhandling 2 | 3 | import android.view.View 4 | import androidx.appcompat.app.AlertDialog 5 | import androidx.fragment.app.Fragment 6 | import com.google.android.material.snackbar.Snackbar 7 | import com.composetemplate.core.domain.error.ExceptionModel 8 | import javax.inject.Inject 9 | 10 | fun Fragment.showErrorDialog( 11 | error: ViewError, 12 | cancelable: Boolean = true, 13 | dismissAction: (() -> Unit)? = null 14 | ) { 15 | val builder = AlertDialog.Builder(requireContext()) 16 | builder.setTitle(error.title) 17 | builder.setMessage(error.message) 18 | builder.setPositiveButton("Ok") { _, _ -> 19 | ViewErrorController.isShowingError = false 20 | } 21 | builder.setOnDismissListener { 22 | ViewErrorController.isShowingError = false 23 | dismissAction?.invoke() 24 | } 25 | if (!ViewErrorController.isShowingError) { 26 | ViewErrorController.isShowingError = true 27 | val dialog = builder.show() 28 | dialog.setCancelable(cancelable) 29 | dialog.setCanceledOnTouchOutside(cancelable) 30 | } 31 | } 32 | 33 | fun Fragment.showErrorSnackbar( 34 | view: View, 35 | error: ViewError, 36 | showAction: Boolean = false, 37 | dismissAction: (() -> Unit)? = null, 38 | ) { 39 | val showLength = if (showAction) Snackbar.LENGTH_INDEFINITE else Snackbar.LENGTH_LONG 40 | val snackbar = Snackbar.make(view, error.message, showLength) 41 | if (showAction) { 42 | snackbar.setAction("Ok") { 43 | ViewErrorController.isShowingError = false 44 | dismissAction?.invoke() 45 | } 46 | } 47 | if (!ViewErrorController.isShowingError) { 48 | ViewErrorController.isShowingError = true 49 | snackbar.show() 50 | } 51 | } 52 | 53 | @Suppress("LongMethod") 54 | fun ExceptionModel.mapToViewError(): ViewError { 55 | return when (this) { 56 | is ExceptionModel.Http.Forbidden, 57 | is ExceptionModel.Http.Unauthorized -> { 58 | ViewError( 59 | title = "Translation.error.errorTitle", 60 | message = "Translation.error.authenticationError", 61 | ) 62 | } 63 | 64 | is ExceptionModel.Http -> { 65 | ViewError( 66 | title = "Translation.error.errorTitle", 67 | message = "Translation.error.unknownError", 68 | ) 69 | } 70 | is ExceptionModel.Connection -> { 71 | ViewError( 72 | title = "Translation.error.errorTitle", 73 | message = "Translation.error.unknownError", 74 | ) 75 | } 76 | else -> { 77 | ViewError( 78 | title = "Translation.error.errorTitle", 79 | message = "Translation.error.unknownError", 80 | ) 81 | } 82 | } 83 | } 84 | 85 | class ViewErrorController @Inject constructor() { 86 | companion object { 87 | var isShowingError = false 88 | } 89 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | /** 6 | * App colors. 7 | */ 8 | internal val Blue10 = Color(0xFF001F29) 9 | internal val Blue20 = Color(0xFF003544) 10 | internal val Blue30 = Color(0xFF004D61) 11 | internal val Blue40 = Color(0xFF006781) 12 | internal val Blue80 = Color(0xFF5DD4FB) 13 | internal val Blue90 = Color(0xFFB5EAFF) 14 | internal val Blue95 = Color(0xFFDCF5FF) 15 | internal val DarkGreen10 = Color(0xFF0D1F12) 16 | internal val DarkGreen20 = Color(0xFF223526) 17 | internal val DarkGreen30 = Color(0xFF394B3C) 18 | internal val DarkGreen40 = Color(0xFF4F6352) 19 | internal val DarkGreen80 = Color(0xFFB7CCB8) 20 | internal val DarkGreen90 = Color(0xFFD3E8D3) 21 | internal val DarkGreenGray10 = Color(0xFF1A1C1A) 22 | internal val DarkGreenGray90 = Color(0xFFE2E3DE) 23 | internal val DarkGreenGray95 = Color(0xFFF0F1EC) 24 | internal val DarkGreenGray99 = Color(0xFFFBFDF7) 25 | internal val DarkPurpleGray10 = Color(0xFF201A1B) 26 | internal val DarkPurpleGray90 = Color(0xFFECDFE0) 27 | internal val DarkPurpleGray95 = Color(0xFFFAEEEF) 28 | internal val DarkPurpleGray99 = Color(0xFFFCFCFC) 29 | internal val Green10 = Color(0xFF00210B) 30 | internal val Green20 = Color(0xFF003919) 31 | internal val Green30 = Color(0xFF005227) 32 | internal val Green40 = Color(0xFF006D36) 33 | internal val Green80 = Color(0xFF0EE37C) 34 | internal val Green90 = Color(0xFF5AFF9D) 35 | internal val GreenGray30 = Color(0xFF414941) 36 | internal val GreenGray50 = Color(0xFF727971) 37 | internal val GreenGray60 = Color(0xFF8B938A) 38 | internal val GreenGray80 = Color(0xFFC1C9BF) 39 | internal val GreenGray90 = Color(0xFFDDE5DB) 40 | internal val Orange10 = Color(0xFF390C00) 41 | internal val Orange20 = Color(0xFF5D1900) 42 | internal val Orange30 = Color(0xFF812800) 43 | internal val Orange40 = Color(0xFFA23F16) 44 | internal val Orange80 = Color(0xFFFFB599) 45 | internal val Orange90 = Color(0xFFFFDBCE) 46 | internal val Orange95 = Color(0xFFFFEDE6) 47 | internal val Purple10 = Color(0xFF36003D) 48 | internal val Purple20 = Color(0xFF560A5E) 49 | internal val Purple30 = Color(0xFF702776) 50 | internal val Purple40 = Color(0xFF8C4190) 51 | internal val Purple80 = Color(0xFFFFA8FF) 52 | internal val Purple90 = Color(0xFFFFD5FC) 53 | internal val Purple95 = Color(0xFFFFEBFB) 54 | internal val PurpleGray30 = Color(0xFF4E444C) 55 | internal val PurpleGray50 = Color(0xFF7F747C) 56 | internal val PurpleGray60 = Color(0xFF998D96) 57 | internal val PurpleGray80 = Color(0xFFD0C2CC) 58 | internal val PurpleGray90 = Color(0xFFEDDEE8) 59 | internal val Red10 = Color(0xFF410001) 60 | internal val Red20 = Color(0xFF680003) 61 | internal val Red30 = Color(0xFF930006) 62 | internal val Red40 = Color(0xFFBA1B1B) 63 | internal val Red80 = Color(0xFFFFB4A9) 64 | internal val Red90 = Color(0xFFFFDAD4) 65 | internal val Teal10 = Color(0xFF001F26) 66 | internal val Teal20 = Color(0xFF02363F) 67 | internal val Teal30 = Color(0xFF214D56) 68 | internal val Teal40 = Color(0xFF3A656F) 69 | internal val Teal80 = Color(0xFFA2CED9) 70 | internal val Teal90 = Color(0xFFBEEAF6) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/AppButton.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material3.* 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.tooling.preview.Preview 9 | import androidx.compose.ui.unit.dp 10 | 11 | @Composable 12 | fun AppButton( 13 | text: String, 14 | modifier: Modifier = Modifier, 15 | enabled: Boolean = true, 16 | type: AppButtonType = AppButtonType.Filled, 17 | onClick: () -> Unit 18 | ) { 19 | when (type) { 20 | AppButtonType.Filled -> Button( 21 | onClick = onClick, 22 | modifier = modifier.fillMaxWidth(), 23 | enabled = enabled, 24 | colors = ButtonDefaults.buttonColors( 25 | disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer, 26 | disabledContentColor = MaterialTheme.colorScheme.onSurface, 27 | ), 28 | shape = MaterialTheme.shapes.small 29 | 30 | ) { 31 | ButtonContent(text = text) 32 | } 33 | AppButtonType.Outlined -> OutlinedButton( 34 | onClick = onClick, 35 | modifier = modifier.fillMaxWidth(), 36 | enabled = enabled, 37 | shape = MaterialTheme.shapes.small, 38 | colors = ButtonDefaults.buttonColors( 39 | containerColor = MaterialTheme.colorScheme.onBackground, 40 | contentColor = MaterialTheme.colorScheme.onSurface, 41 | ), 42 | 43 | ) { 44 | ButtonContent(text = text) 45 | } 46 | AppButtonType.Text -> { 47 | TextButton( 48 | onClick = onClick, 49 | modifier = modifier.fillMaxWidth(), 50 | enabled = enabled, 51 | shape = MaterialTheme.shapes.small 52 | ) { 53 | ButtonContent(text = text) 54 | } 55 | } 56 | } 57 | } 58 | 59 | @Composable 60 | private fun ButtonContent(text: String) { 61 | Text( 62 | text = text, 63 | style = MaterialTheme.typography.titleSmall, 64 | modifier = Modifier.padding(8.dp), 65 | ) 66 | } 67 | 68 | @Preview 69 | @Composable 70 | fun PreviewOutlinedButton() { 71 | AppTheme { 72 | AppButton(text = "Outlined", type = AppButtonType.Outlined) { 73 | } 74 | } 75 | } 76 | 77 | @Preview 78 | @Composable 79 | fun PreviewTextButton() { 80 | AppTheme { 81 | AppButton(text = "Text Button", type = AppButtonType.Text) { 82 | } 83 | } 84 | } 85 | 86 | @Preview(name = "Primary Button") 87 | @Composable 88 | fun PreviewAppButton() { 89 | AppTheme { 90 | AppButton(text = "Text") {} 91 | } 92 | } 93 | 94 | @Preview(name = "Disabled Button") 95 | @Composable 96 | fun PreviewDisabledAppButton() { 97 | AppTheme { 98 | AppButton(text = "Text", enabled = false) {} 99 | } 100 | } 101 | 102 | enum class AppButtonType { 103 | Filled, Outlined, Text 104 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/AppState.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import android.content.res.Resources 4 | import androidx.compose.material3.windowsizeclass.WindowSizeClass 5 | import androidx.compose.runtime.* 6 | import androidx.compose.ui.platform.LocalConfiguration 7 | import androidx.compose.ui.platform.LocalContext 8 | import androidx.navigation.NavDestination 9 | import androidx.navigation.NavGraph.Companion.findStartDestination 10 | import androidx.navigation.NavHostController 11 | import androidx.navigation.compose.currentBackStackEntryAsState 12 | import androidx.navigation.compose.rememberNavController 13 | import androidx.navigation.navOptions 14 | import com.composetemplate.core.navigation.* 15 | import com.composetemplate.core.navigation.home.navigateToHome 16 | import com.composetemplate.core.navigation.login.navigateToLogin 17 | import com.composetemplate.core.navigation.resource.navigateToResourcesGraph 18 | import kotlinx.coroutines.CoroutineScope 19 | 20 | 21 | @Composable 22 | fun rememberAppState( 23 | windowSizeClass: WindowSizeClass, 24 | navController: NavHostController = rememberNavController(), 25 | coroutineScope: CoroutineScope = rememberCoroutineScope() 26 | ): AppState { 27 | return remember( 28 | navController, 29 | windowSizeClass, 30 | coroutineScope 31 | ) { 32 | AppState( 33 | navController = navController, 34 | windowSizeClass = windowSizeClass, 35 | coroutineScope = coroutineScope 36 | ) 37 | } 38 | } 39 | 40 | @Stable 41 | class AppState( 42 | val navController: NavHostController, 43 | val windowSizeClass: WindowSizeClass, 44 | val coroutineScope: CoroutineScope 45 | ) { 46 | val currentDestinationAsState: NavDestination? 47 | @Composable get() = navController.currentBackStackEntryAsState().value?.destination 48 | 49 | val currentDestination: Destination? 50 | @Composable get() = Destination.values().asList() 51 | .filter { it.route == currentDestinationAsState?.route }.firstOrNull() 52 | 53 | val shouldShowBottomBar: Boolean 54 | @Composable get() = Destination.values().asList() 55 | .filter { it.isBottomBarTab }.map { it.route } 56 | .contains(currentDestinationAsState?.route) 57 | 58 | val shouldShowTopAppBar: Boolean 59 | @Composable get() = Destination.values().asList() 60 | .filter { it.isTopBarTab }.map { it.route }.contains(currentDestinationAsState?.route) 61 | 62 | val destinationWithBottomBars: List 63 | get() = Destination.values().asList() 64 | .filter { it.isBottomBarTab && it.isTopLevelDestination } 65 | 66 | val destinationWithTopBar: List 67 | get() = Destination.values().asList() 68 | .filter { it.isTopBarTab } 69 | 70 | 71 | fun navigateToTopLevelDestination(destination: Destination) { 72 | val topLevelNavOptions = navOptions { 73 | popUpTo(navController.graph.findStartDestination().id) { 74 | saveState = true 75 | } 76 | launchSingleTop = true 77 | restoreState = true 78 | } 79 | when (destination) { 80 | Destination.HOME -> navController.navigateToHome(topLevelNavOptions) 81 | Destination.RESOURCES -> navController.navigateToResourcesGraph( 82 | topLevelNavOptions 83 | ) 84 | Destination.LOGIN -> navController.navigateToLogin(topLevelNavOptions) 85 | } 86 | 87 | } 88 | 89 | fun onBackClick() { 90 | navController.popBackStack() 91 | } 92 | 93 | } 94 | 95 | @Composable 96 | @ReadOnlyComposable 97 | private fun resources(): Resources { 98 | LocalConfiguration.current 99 | return LocalContext.current.resources 100 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/features/resources/ResourceScreen.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.features.resources 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.interaction.MutableInteractionSource 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.material.Card 9 | import androidx.compose.material.ripple.rememberRipple 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.unit.dp 17 | import androidx.hilt.navigation.compose.hiltViewModel 18 | import androidx.navigation.NavController 19 | import androidx.paging.LoadState 20 | import androidx.paging.compose.LazyPagingItems 21 | import androidx.paging.compose.collectAsLazyPagingItems 22 | import androidx.paging.compose.items 23 | import com.composetemplate.core.domain.model.ResourceDetails 24 | import com.composetemplate.core.pagination.model.Resource 25 | import com.composetemplate.core.ui.CircularProgressBar 26 | 27 | 28 | @Composable 29 | internal fun ResourceRoute( 30 | modifier: Modifier = Modifier, 31 | viewModel: ResourceViewModel = hiltViewModel(), 32 | onItemClick: (Int) -> Unit, 33 | navController: NavController 34 | ) { 35 | val resourceResult = viewModel.resourceResult.collectAsLazyPagingItems() 36 | ResourceScreen(resourceResult, onItemClick, navController) 37 | } 38 | 39 | @Composable 40 | fun ResourceScreen( 41 | resourceResult: LazyPagingItems, 42 | onItemClick: (Int) -> Unit, 43 | navController: NavController 44 | ) { 45 | Column( 46 | modifier = Modifier 47 | .fillMaxSize() 48 | .padding(20.dp), 49 | verticalArrangement = Arrangement.Center 50 | ) { 51 | LazyColumn( 52 | modifier = Modifier 53 | .padding(start = 16.dp, end = 16.dp, top = 10.dp) 54 | .systemBarsPadding(), 55 | verticalArrangement = Arrangement.SpaceEvenly 56 | ) { 57 | items(resourceResult) { item -> 58 | ResourceItemView(item, onItemClick, navController) 59 | } 60 | resourceResult.apply { 61 | when { 62 | loadState.refresh is LoadState.Loading -> item { 63 | CircularProgressBar() 64 | } 65 | 66 | loadState.append is LoadState.Loading -> item { 67 | CircularProgressBar() 68 | } 69 | 70 | itemSnapshotList.isEmpty() -> item { Text(text = stringResource(com.composetemplate.R.string.no_data_found)) } 71 | } 72 | } 73 | } 74 | 75 | } 76 | 77 | } 78 | 79 | @Composable 80 | fun ResourceItemView(item: Resource?, onItemClick: (Int) -> Unit, navController: NavController) { 81 | Card( 82 | modifier = Modifier 83 | .fillMaxWidth() 84 | .padding(bottom = 10.dp) 85 | .clickable( 86 | interactionSource = remember { MutableInteractionSource() }, 87 | indication = rememberRipple( 88 | color = MaterialTheme.colorScheme.secondary, 89 | bounded = true 90 | ), 91 | onClick = { 92 | navController.currentBackStackEntry?.savedStateHandle?.set( 93 | key = "resourceDetails", 94 | value = item?.id?.let { ResourceDetails(it, item.name, "") } 95 | ) 96 | item?.id?.let { onItemClick(it) } 97 | }, 98 | ), 99 | backgroundColor = MaterialTheme.colorScheme.onPrimary, 100 | shape = RoundedCornerShape(20.dp), 101 | elevation = 8.dp, 102 | ) { 103 | Column(modifier = Modifier.padding(10.dp)) { 104 | if (item != null) { 105 | Text( 106 | item.name, 107 | modifier = Modifier.padding(10.dp), 108 | ) 109 | } 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/theme/theme.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.lightColorScheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.graphics.Color 10 | import com.composetemplate.core.ui.theme.Blue10 11 | import com.composetemplate.core.ui.theme.Blue20 12 | import com.composetemplate.core.ui.theme.Blue30 13 | import com.composetemplate.core.ui.theme.Blue40 14 | import com.composetemplate.core.ui.theme.Blue80 15 | import com.composetemplate.core.ui.theme.Blue90 16 | import com.composetemplate.core.ui.theme.DarkPurpleGray10 17 | import com.composetemplate.core.ui.theme.DarkPurpleGray90 18 | import com.composetemplate.core.ui.theme.DarkPurpleGray99 19 | import com.composetemplate.core.ui.theme.Orange10 20 | import com.composetemplate.core.ui.theme.Orange20 21 | import com.composetemplate.core.ui.theme.Orange30 22 | import com.composetemplate.core.ui.theme.Orange40 23 | import com.composetemplate.core.ui.theme.Orange80 24 | import com.composetemplate.core.ui.theme.Orange90 25 | import com.composetemplate.core.ui.theme.Purple10 26 | import com.composetemplate.core.ui.theme.Purple20 27 | import com.composetemplate.core.ui.theme.Purple30 28 | import com.composetemplate.core.ui.theme.Purple40 29 | import com.composetemplate.core.ui.theme.Purple80 30 | import com.composetemplate.core.ui.theme.Purple90 31 | import com.composetemplate.core.ui.theme.PurpleGray30 32 | import com.composetemplate.core.ui.theme.PurpleGray50 33 | import com.composetemplate.core.ui.theme.PurpleGray60 34 | import com.composetemplate.core.ui.theme.PurpleGray80 35 | import com.composetemplate.core.ui.theme.PurpleGray90 36 | import com.composetemplate.core.ui.theme.Red10 37 | import com.composetemplate.core.ui.theme.Red20 38 | import com.composetemplate.core.ui.theme.Red30 39 | import com.composetemplate.core.ui.theme.Red40 40 | import com.composetemplate.core.ui.theme.Red80 41 | import com.composetemplate.core.ui.theme.Red90 42 | 43 | /** 44 | * Light default theme color scheme 45 | */ 46 | @VisibleForTesting 47 | val LightColors = lightColorScheme( 48 | primary = Purple40, 49 | onPrimary = Color.White, 50 | primaryContainer = Purple90, 51 | onPrimaryContainer = Purple10, 52 | secondary = Orange40, 53 | onSecondary = Color.White, 54 | secondaryContainer = Orange90, 55 | onSecondaryContainer = Orange10, 56 | tertiary = Blue40, 57 | onTertiary = Color.White, 58 | tertiaryContainer = Blue90, 59 | onTertiaryContainer = Blue10, 60 | error = Red40, 61 | onError = Color.White, 62 | errorContainer = Red90, 63 | onErrorContainer = Red10, 64 | background = DarkPurpleGray99, 65 | onBackground = DarkPurpleGray10, 66 | surface = DarkPurpleGray99, 67 | onSurface = DarkPurpleGray10, 68 | surfaceVariant = PurpleGray90, 69 | onSurfaceVariant = PurpleGray30, 70 | outline = PurpleGray50 71 | ) 72 | 73 | /** 74 | * Dark default theme color scheme 75 | */ 76 | @VisibleForTesting 77 | val DarkColors = darkColorScheme( 78 | primary = Purple80, 79 | onPrimary = Purple20, 80 | primaryContainer = Purple30, 81 | onPrimaryContainer = Purple90, 82 | secondary = Orange80, 83 | onSecondary = Orange20, 84 | secondaryContainer = Orange30, 85 | onSecondaryContainer = Orange90, 86 | tertiary = Blue80, 87 | onTertiary = Blue20, 88 | tertiaryContainer = Blue30, 89 | onTertiaryContainer = Blue90, 90 | error = Red80, 91 | onError = Red20, 92 | errorContainer = Red30, 93 | onErrorContainer = Red90, 94 | background = DarkPurpleGray10, 95 | onBackground = DarkPurpleGray90, 96 | surface = DarkPurpleGray10, 97 | onSurface = DarkPurpleGray90, 98 | surfaceVariant = PurpleGray30, 99 | onSurfaceVariant = PurpleGray80, 100 | outline = PurpleGray60 101 | ) 102 | 103 | @Composable 104 | fun AppTheme( 105 | useDarkTheme: Boolean = isSystemInDarkTheme(), 106 | content: @Composable() () -> Unit 107 | ) { 108 | val colors = if (!useDarkTheme) { 109 | LightColors 110 | } else { 111 | DarkColors 112 | } 113 | 114 | MaterialTheme( 115 | colorScheme = colors, 116 | typography = AppTypography, 117 | content = content 118 | ) 119 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/arch/extensions/ViewExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.arch.extensions 2 | 3 | import android.view.View 4 | import androidx.core.view.isVisible 5 | import androidx.lifecycle.* 6 | import com.google.android.material.snackbar.Snackbar 7 | import com.composetemplate.core.sharedui.errorhandling.ViewError 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.channels.awaitClose 11 | import kotlinx.coroutines.flow.* 12 | import kotlinx.coroutines.joinAll 13 | import kotlinx.coroutines.launch 14 | 15 | fun LifecycleOwner.snackErrorFlow( 16 | targetFlow: SharedFlow, 17 | root: View, 18 | length: Int = Snackbar.LENGTH_SHORT 19 | ) { 20 | collectFlow(targetFlow) { viewError -> 21 | Snackbar.make(root, viewError.message, length).show() 22 | } 23 | } 24 | 25 | 26 | fun LifecycleOwner.visibilityFlow(targetFlow: Flow, vararg view: View) { 27 | collectFlow(targetFlow) { loading -> 28 | view.forEach { it.isVisible = loading } 29 | } 30 | } 31 | 32 | /** 33 | * Launches a new coroutine and repeats `collectBlock` every time the Fragment's viewLifecycleOwner 34 | * is in and out of `minActiveState` lifecycle state. 35 | */ 36 | inline fun LifecycleOwner.collectFlow( 37 | targetFlow: Flow, 38 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED, 39 | crossinline collectBlock: (T) -> Unit 40 | ) { 41 | this.lifecycleScope.launchWhenStarted { 42 | targetFlow.flowWithLifecycle(this@collectFlow.lifecycle, minActiveState) 43 | .collect { 44 | collectBlock(it) 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * Launches a new coroutine and repeats `block` every time the Fragment's viewLifecycleOwner 51 | * is in and out of `minActiveState` lifecycle state. 52 | * ``` 53 | * repeatWithViewLifecycle { 54 | * launch { 55 | * // collect 56 | * } 57 | * launch { 58 | * // collect 59 | * } 60 | * } 61 | * ``` 62 | * 63 | */ 64 | 65 | inline fun LifecycleOwner.repeatWithViewLifecycle( 66 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED, 67 | crossinline block: suspend CoroutineScope.() -> Unit 68 | ) { 69 | this.lifecycleScope.launch { 70 | this@repeatWithViewLifecycle.lifecycle.repeatOnLifecycle(minActiveState) { 71 | block() 72 | } 73 | } 74 | } 75 | 76 | fun LifecycleOwner.launchAndRepeatWithViewLifecycle( 77 | vararg blocks: suspend CoroutineScope.() -> Unit, 78 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED 79 | ) { 80 | this.lifecycleScope.launch { 81 | this@launchAndRepeatWithViewLifecycle.lifecycle.repeatOnLifecycle(minActiveState) { 82 | blocks.map { 83 | launch { 84 | it() 85 | } 86 | }.joinAll() 87 | } 88 | } 89 | } 90 | 91 | 92 | fun LifecycleOwner.combineFlows( 93 | flow1: Flow, 94 | flow2: Flow, 95 | collectBlock: ((T1, T2) -> Unit) 96 | ) { 97 | collectFlow(flow1.combine(flow2) { v1, v2 -> 98 | collectBlock.invoke(v1, v2) 99 | }) {} 100 | } 101 | 102 | fun LifecycleOwner.combineFlows( 103 | flow1: Flow, 104 | flow2: Flow, 105 | flow3: Flow, 106 | collectBlock: ((T1, T2, T3) -> Unit) 107 | ) { 108 | collectFlow(combine(flow1, flow2, flow3) { v1, v2, v3 -> 109 | collectBlock.invoke(v1, v2, v3) 110 | }) {} 111 | } 112 | 113 | fun LifecycleOwner.combineFlows( 114 | flow1: Flow, 115 | flow2: Flow, 116 | flow3: Flow, 117 | flow4: Flow, 118 | collectBlock: ((T1, T2, T3, T4) -> Unit) 119 | ) { 120 | collectFlow(combine(flow1, flow2, flow3, flow4) { v1, v2, v3, v4 -> 121 | collectBlock.invoke(v1, v2, v3, v4) 122 | }) {} 123 | } 124 | 125 | fun LifecycleOwner.zipFlows( 126 | flow1: Flow, 127 | flow2: Flow, 128 | collectBlock: ((T1, T2) -> Unit) 129 | ) { 130 | collectFlow(flow1.zip(flow2) { v1, v2 -> 131 | collectBlock.invoke(v1, v2) 132 | }) {} 133 | } 134 | 135 | inline fun V.collectFlow( 136 | targetFlow: Flow>, 137 | crossinline action: suspend (T) -> Unit 138 | ) where V : ViewModel, V : ViewErrorAware, V : LoadingAware { 139 | targetFlow 140 | .bindLoading(this) 141 | .bindError(this) 142 | .onSuccess { 143 | action(it) 144 | } 145 | .launchIn(viewModelScope) 146 | } 147 | 148 | @ExperimentalCoroutinesApi 149 | fun View.clicks(throttleTime: Long = 400): Flow = callbackFlow { 150 | this@clicks.setOnClickListener { 151 | trySend(Unit) 152 | } 153 | awaitClose { this@clicks.setOnClickListener(null) } 154 | }.throttleFirst(throttleTime) 155 | 156 | fun View.onClick(interval: Long = 400L, listenerBlock: (View) -> Unit) = 157 | setOnClickListener(DebounceOnClickListener(interval, listenerBlock)) -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/pagination/mediator/ResourceMediator.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.pagination.mediator 2 | 3 | import androidx.paging.ExperimentalPagingApi 4 | import androidx.paging.LoadType 5 | import androidx.paging.PagingState 6 | import androidx.paging.RemoteMediator 7 | import androidx.room.withTransaction 8 | import com.composetemplate.core.data.repositories.ResourceRepository 9 | import com.composetemplate.core.pagination.model.RemoteKeys 10 | import com.composetemplate.core.pagination.model.Resource 11 | import com.composetemplate.core.pagination.db.AppDatabase 12 | import retrofit2.HttpException 13 | import java.io.IOException 14 | import java.io.InvalidObjectException 15 | 16 | @ExperimentalPagingApi 17 | class ResourceMediator( 18 | private val resourceRepository: ResourceRepository, 19 | private val appDatabase: AppDatabase 20 | ) : 21 | RemoteMediator() { 22 | companion object { 23 | const val DEFAULT_PAGE_INDEX = 2 24 | const val DEFAULT_PAGE_SIZE = 10 25 | } 26 | 27 | override suspend fun load( 28 | loadType: LoadType, state: PagingState 29 | ): MediatorResult { 30 | 31 | val pageKeyData = getKeyPageData(loadType, state) 32 | val page = when (pageKeyData) { 33 | is MediatorResult.Success -> { 34 | return pageKeyData 35 | } 36 | else -> { 37 | pageKeyData as Int 38 | } 39 | } 40 | 41 | try { 42 | val response = resourceRepository.getResources(page, state.config.pageSize) 43 | val isEndOfList = response.isEmpty() 44 | appDatabase.withTransaction { 45 | // clear all tables in the database 46 | if (loadType == LoadType.REFRESH) { 47 | appDatabase.getRepoDao().clearRemoteKeys() 48 | appDatabase.getResourceDao().clearAllResources() 49 | } 50 | val prevKey = if (page == DEFAULT_PAGE_INDEX) null else page - 1 51 | val nextKey = if (isEndOfList) null else page + 1 52 | val keys = response.map { 53 | RemoteKeys(repoId = it.id.toString(), prevKey = prevKey, nextKey = nextKey) 54 | } 55 | appDatabase.getRepoDao().insertAll(keys) 56 | appDatabase.getResourceDao().insertAll(response) 57 | } 58 | return MediatorResult.Success(endOfPaginationReached = isEndOfList) 59 | } catch (exception: IOException) { 60 | return MediatorResult.Error(exception) 61 | } catch (exception: HttpException) { 62 | return MediatorResult.Error(exception) 63 | } 64 | } 65 | 66 | /** 67 | * this returns the page key or the final end of list success result 68 | */ 69 | suspend fun getKeyPageData(loadType: LoadType, state: PagingState): Any? { 70 | return when (loadType) { 71 | LoadType.REFRESH -> { 72 | val remoteKeys = getClosestRemoteKey(state) 73 | remoteKeys?.nextKey?.minus(1) ?: DEFAULT_PAGE_INDEX 74 | } 75 | LoadType.APPEND -> { 76 | val remoteKeys = getLastRemoteKey(state) 77 | if (remoteKeys == null) 78 | DEFAULT_PAGE_INDEX 79 | else if (remoteKeys.nextKey == null) 80 | throw InvalidObjectException("Remote key should not be null for $loadType") 81 | else remoteKeys.nextKey 82 | } 83 | LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) 84 | } 85 | } 86 | 87 | /** 88 | * get the last remote key inserted which had the data 89 | */ 90 | private suspend fun getLastRemoteKey(state: PagingState): RemoteKeys? { 91 | return state.pages 92 | .lastOrNull { it.data.isNotEmpty() } 93 | ?.data?.lastOrNull() 94 | ?.let { resource -> 95 | appDatabase.getRepoDao().remoteKeysResourceId(resource.id.toString()) 96 | } 97 | } 98 | 99 | /** 100 | * get the first remote key inserted which had the data 101 | */ 102 | private suspend fun getFirstRemoteKey(state: PagingState): RemoteKeys? { 103 | return state.pages 104 | .firstOrNull() { 105 | it.data.isNotEmpty() 106 | } 107 | ?.data?.firstOrNull() 108 | ?.let { doggo -> appDatabase.getRepoDao().remoteKeysResourceId(doggo.id.toString()) } 109 | } 110 | 111 | /** 112 | * get the closest remote key inserted which had the data 113 | */ 114 | private suspend fun getClosestRemoteKey(state: PagingState): RemoteKeys? { 115 | return state.anchorPosition?.let { position -> 116 | state.closestItemToPosition(position)?.id?.let { repoId -> 117 | appDatabase.getRepoDao().remoteKeysResourceId(repoId.toString()) 118 | } 119 | } 120 | } 121 | 122 | } -------------------------------------------------------------------------------- /generate-project.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Script inspired by https://gist.github.com/szeidner/613fe4652fc86f083cefa21879d5522b 5 | 6 | # Enable logging 7 | # set -x 8 | 9 | PROGNAME=$(basename $0) 10 | WORKING_DIR=$(cd .. && pwd -P) 11 | 12 | cd $WORKING_DIR 13 | 14 | die() { 15 | echo "$PROGNAME: $*" >&2 16 | exit 1 17 | } 18 | 19 | usage() { 20 | if [ "$*" != "" ] ; then 21 | echo "Error: $*" 22 | fi 23 | 24 | cat << EOF 25 | Usage: $PROGNAME --package-name [PACKAGE_NAME] --app-name [APP_NAME] 26 | Set up an Android app and package name. 27 | Options: 28 | -h, --help display this usage message and exit 29 | -p, --package-name [PACKAGE_NAME] new package name (i.e. com.example.package) 30 | -n, --app-name [APP_NAME] new app name (i.e. MyApp, "My App") 31 | EOF 32 | 33 | exit 1 34 | } 35 | 36 | packagename="" 37 | appname="" 38 | while [ $# -gt 0 ] ; do 39 | case "$1" in 40 | -h|--help) 41 | usage 42 | ;; 43 | -p|--package-name) 44 | packagename="$2" 45 | shift 46 | ;; 47 | -n|--app-name) 48 | appname="$2" 49 | shift 50 | ;; 51 | -*) 52 | usage "Unknown option '$1'" 53 | ;; 54 | *) 55 | usage "Too many arguments" 56 | ;; 57 | esac 58 | shift 59 | done 60 | 61 | OLD_APPNAME="" 62 | OLD_NAME="" 63 | OLD_PACKAGE="" 64 | 65 | # Path segments 66 | FIRST_PACKAGE_SEGMENT="com" 67 | SECOND_PACKAGE_SEGMENT="monstarlab" 68 | 69 | OLD_APPNAME="android-template" 70 | OLD_NAME="android-template" 71 | OLD_PACKAGE="com.monstarlab" 72 | 73 | if [ -z "$packagename" ] ; then 74 | usage "No new package provided" 75 | fi 76 | 77 | if [ -z "$appname" ] ; then 78 | usage "No new app name provided" 79 | fi 80 | 81 | # Enforce package name 82 | regex='^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+[0-9a-z_]$' 83 | if ! [[ "$packagename" =~ $regex ]]; then 84 | die "Invalid Package Name: $packagename (needs to follow standard pattern {com.example.package})" 85 | fi 86 | 87 | echo "=> 🐢 Staring init $appname with $OLD_APPNAME..." 88 | 89 | # Trim spaces in APP_NAME 90 | NAME_NO_SPACES=$(echo "$appname" | sed "s/ //g") 91 | 92 | # Copy main folder 93 | cp -R $OLD_NAME $NAME_NO_SPACES 94 | 95 | # Clean the old build 96 | ./$NAME_NO_SPACES/gradlew -p ./$NAME_NO_SPACES clean 97 | # Get rid of idea settings 98 | rm -rf $NAME_NO_SPACES/.idea 99 | # Get rid of gradle cache 100 | rm -rf $NAME_NO_SPACES/.gradle 101 | # Get rid of the git history 102 | rm -rf $NAME_NO_SPACES/.git 103 | 104 | # Rename folder structure 105 | renameFolderStructure() { 106 | DIR="" 107 | if [ "$*" != "" ] ; then 108 | DIR="$*" 109 | fi 110 | ORIG_DIR=$DIR 111 | 112 | mkdir $NAME_NO_SPACES/$DIR/backup 113 | 114 | mv $NAME_NO_SPACES/$DIR/$FIRST_PACKAGE_SEGMENT/$SECOND_PACKAGE_SEGMENT/* $NAME_NO_SPACES/$DIR/backup 115 | grep -l '.*' $NAME_NO_SPACES/$DIR/* 116 | rm -rf $NAME_NO_SPACES/$DIR/$FIRST_PACKAGE_SEGMENT 117 | cd $NAME_NO_SPACES/$DIR 118 | IFS='.' read -ra packages <<< "$packagename" 119 | for i in "${packages[@]}"; do 120 | DIR="$DIR/$i" 121 | mkdir $i 122 | cd $i 123 | done 124 | mv $WORKING_DIR/$NAME_NO_SPACES/$ORIG_DIR/backup/* ./ 125 | rmdir $WORKING_DIR/$NAME_NO_SPACES/$ORIG_DIR/backup 126 | cd $WORKING_DIR 127 | echo $DIR 128 | } 129 | 130 | # Rename files structure 131 | echo "=> 🔎 Replacing files structure..." 132 | 133 | # Rename project folder structure 134 | APP_PACKAGE_DIR="app/src/main/java" 135 | APP_PACKAGE_DIR=$( renameFolderStructure $APP_PACKAGE_DIR ) 136 | 137 | # Rename android test folder structure 138 | APP_ANDROIDTEST_DIR="app/src/androidTest/java" 139 | APP_ANDROIDTEST_DIR=$( renameFolderStructure $APP_ANDROIDTEST_DIR ) 140 | 141 | # Rename test folder structure 142 | APP_TEST_DIR="app/src/test/java" 143 | APP_TEST_DIR=$( renameFolderStructure $APP_TEST_DIR ) 144 | 145 | echo "✅ Completed" 146 | 147 | # Search and replace in files 148 | echo "=> 🔎 Replacing package and package name within files..." 149 | PACKAGE_NAME_ESCAPED="${packagename//./\.}" 150 | OLD_PACKAGE_NAME_ESCAPED="${OLD_PACKAGE//./\.}" 151 | if [[ "$OSTYPE" == "darwin"* ]] # Mac OSX 152 | then 153 | LC_ALL=C find $WORKING_DIR/$NAME_NO_SPACES -type f -exec sed -i "" -e "s/$OLD_PACKAGE_NAME_ESCAPED/$PACKAGE_NAME_ESCAPED/g" {} + 154 | LC_ALL=C find $WORKING_DIR/$NAME_NO_SPACES -type f -exec sed -i "" -e "s/$OLD_NAME/$NAME_NO_SPACES/g" {} + 155 | else 156 | LC_ALL=C find $WORKING_DIR/$NAME_NO_SPACES -type f -exec sed -i -e "s/$OLD_PACKAGE_NAME_ESCAPED/$PACKAGE_NAME_ESCAPED/g" {} + 157 | LC_ALL=C find $WORKING_DIR/$NAME_NO_SPACES -type f -exec sed -i -e "s/$OLD_NAME/$NAME_NO_SPACES/g" {} + 158 | fi 159 | echo "✅ Completed" 160 | 161 | # Search and replace files <...> 162 | echo "=> 🔎 Replacing app name in strings.xml and build.gradle ..." 163 | if [[ "$OSTYPE" == "darwin"* ]] # Mac OSX 164 | then 165 | sed -i "" -e "s/$OLD_APPNAME/$appname/" "$WORKING_DIR/$NAME_NO_SPACES/app/src/main/res/values/strings.xml" 166 | sed -i "" -e "s/Monstarlab/$appname/" "$WORKING_DIR/$NAME_NO_SPACES/app/build.gradle" 167 | else 168 | sed -i -e "s/$OLD_APPNAME/$appname/" "$WORKING_DIR/$NAME_NO_SPACES/app/src/main/res/values/strings.xml" 169 | sed -i -e "s/Monstarlab/$appname/" "$WORKING_DIR/$NAME_NO_SPACES/app/build.gradle" 170 | fi 171 | echo "✅ Completed" 172 | 173 | echo "=> 🛠️ Building generated project..." 174 | ./$NAME_NO_SPACES/gradlew -p ./$NAME_NO_SPACES assembleDebug 175 | echo "✅ Build success" 176 | 177 | # Done! 178 | echo "=> 🚀 Done! The project is ready for development 🙌" 179 | 180 | # Disable logging 181 | # set +x 182 | -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/navigation/Navigation.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.navigation 2 | 3 | import androidx.compose.foundation.layout.RowScope 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.NavigationBar 6 | import androidx.compose.material3.NavigationBarItem 7 | import androidx.compose.material3.NavigationBarItemDefaults 8 | import androidx.compose.material3.NavigationRailItem 9 | import androidx.compose.material3.NavigationRailItemDefaults 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.unit.dp 13 | 14 | /** 15 | * navigation bar item with icon and label content slots. Wraps Material 3 16 | * [NavigationBarItem]. 17 | * 18 | * @param selected Whether this item is selected. 19 | * @param onClick The callback to be invoked when this item is selected. 20 | * @param icon The item icon content. 21 | * @param modifier Modifier to be applied to this item. 22 | * @param selectedIcon The item icon content when selected. 23 | * @param enabled controls the enabled state of this item. When `false`, this item will not be 24 | * clickable and will appear disabled to accessibility services. 25 | * @param label The item text label content. 26 | * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will 27 | * only be shown when this item is selected. 28 | */ 29 | @Composable 30 | fun RowScope.AppNavigationBarItem( 31 | selected: Boolean, 32 | onClick: () -> Unit, 33 | icon: @Composable () -> Unit, 34 | modifier: Modifier = Modifier, 35 | selectedIcon: @Composable () -> Unit = icon, 36 | enabled: Boolean = true, 37 | label: @Composable (() -> Unit)? = null, 38 | alwaysShowLabel: Boolean = true 39 | ) { 40 | NavigationBarItem( 41 | selected = selected, 42 | onClick = onClick, 43 | icon = if (selected) selectedIcon else icon, 44 | modifier = modifier, 45 | enabled = enabled, 46 | label = label, 47 | alwaysShowLabel = alwaysShowLabel, 48 | colors = NavigationBarItemDefaults.colors( 49 | selectedIconColor = AppNavigationDefaults.navigationSelectedItemColor(), 50 | unselectedIconColor = AppNavigationDefaults.navigationContentColor(), 51 | selectedTextColor = AppNavigationDefaults.navigationSelectedItemColor(), 52 | unselectedTextColor = AppNavigationDefaults.navigationContentColor(), 53 | indicatorColor = AppNavigationDefaults.navigationIndicatorColor() 54 | ) 55 | ) 56 | } 57 | 58 | /** 59 | * navigation bar with content slot. Wraps Material 3 [NavigationBar]. 60 | * 61 | * @param modifier Modifier to be applied to the navigation bar. 62 | * @param content Destinations inside the navigation bar. This should contain multiple 63 | * [NavigationBarItem]s. 64 | */ 65 | @Composable 66 | fun AppNavigationBar( 67 | modifier: Modifier = Modifier, 68 | content: @Composable RowScope.() -> Unit 69 | ) { 70 | NavigationBar( 71 | modifier = modifier, 72 | contentColor = AppNavigationDefaults.navigationContentColor(), 73 | tonalElevation = 0.dp, 74 | content = content 75 | ) 76 | } 77 | 78 | /** 79 | * navigation rail item with icon and label content slots. Wraps Material 3 80 | * [NavigationRailItem]. 81 | * 82 | * @param selected Whether this item is selected. 83 | * @param onClick The callback to be invoked when this item is selected. 84 | * @param icon The item icon content. 85 | * @param modifier Modifier to be applied to this item. 86 | * @param selectedIcon The item icon content when selected. 87 | * @param enabled controls the enabled state of this item. When `false`, this item will not be 88 | * clickable and will appear disabled to accessibility services. 89 | * @param label The item text label content. 90 | * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will 91 | * only be shown when this item is selected. 92 | */ 93 | @Composable 94 | fun AppNavigationRailItem( 95 | selected: Boolean, 96 | onClick: () -> Unit, 97 | icon: @Composable () -> Unit, 98 | modifier: Modifier = Modifier, 99 | selectedIcon: @Composable () -> Unit = icon, 100 | enabled: Boolean = true, 101 | label: @Composable (() -> Unit)? = null, 102 | alwaysShowLabel: Boolean = true 103 | ) { 104 | NavigationRailItem( 105 | selected = selected, 106 | onClick = onClick, 107 | icon = if (selected) selectedIcon else icon, 108 | modifier = modifier, 109 | enabled = enabled, 110 | label = label, 111 | alwaysShowLabel = alwaysShowLabel, 112 | colors = NavigationRailItemDefaults.colors( 113 | selectedIconColor = AppNavigationDefaults.navigationSelectedItemColor(), 114 | unselectedIconColor = AppNavigationDefaults.navigationContentColor(), 115 | selectedTextColor = AppNavigationDefaults.navigationSelectedItemColor(), 116 | unselectedTextColor = AppNavigationDefaults.navigationContentColor(), 117 | indicatorColor = AppNavigationDefaults.navigationIndicatorColor() 118 | ) 119 | ) 120 | } 121 | 122 | 123 | /** 124 | * navigation default values. 125 | */ 126 | object AppNavigationDefaults { 127 | @Composable 128 | fun navigationContentColor() = MaterialTheme.colorScheme.onSurfaceVariant 129 | 130 | @Composable 131 | fun navigationSelectedItemColor() = MaterialTheme.colorScheme.onPrimaryContainer 132 | 133 | @Composable 134 | fun navigationIndicatorColor() = MaterialTheme.colorScheme.primaryContainer 135 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/InputTextField.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.text.KeyboardActions 5 | import androidx.compose.foundation.text.KeyboardOptions 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Email 8 | import androidx.compose.material.icons.filled.SendToMobile 9 | import androidx.compose.material3.* 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.vector.ImageVector 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.text.input.ImeAction 15 | import androidx.compose.ui.text.input.KeyboardType 16 | import androidx.compose.ui.tooling.preview.Preview 17 | 18 | 19 | @OptIn(ExperimentalMaterial3Api::class) 20 | @Composable 21 | fun InputTextField( 22 | modifier: Modifier = Modifier, 23 | text: String, 24 | searchQuery: () -> Unit = {}, 25 | label: String = stringResource(com.composetemplate.R.string.label), 26 | icon: ImageVector = Icons.Default.Email, 27 | keyboardType: KeyboardType = KeyboardType.Text, 28 | keyboardActions: KeyboardActions = KeyboardActions.Default, 29 | imeAction: ImeAction = ImeAction.Done, 30 | enabled: Boolean = true, 31 | maxLine: Int = 3, 32 | type: InputTextFieldType = InputTextFieldType.WithIcon, 33 | onValueChange: (String) -> Unit 34 | ) { 35 | when (type) { 36 | InputTextFieldType.Classic -> TextField( 37 | value = text, 38 | label = { Text(text = label) }, 39 | enabled = enabled, 40 | modifier = modifier.fillMaxWidth(), 41 | keyboardOptions = KeyboardOptions( 42 | keyboardType = keyboardType, 43 | imeAction = imeAction 44 | ), 45 | colors = TextFieldDefaults.outlinedTextFieldColors( 46 | focusedLabelColor = MaterialTheme.colorScheme.onSurface 47 | ), 48 | onValueChange = onValueChange, 49 | shape = MaterialTheme.shapes.extraSmall, 50 | placeholder = { Text(text = label) }, 51 | maxLines = maxLine 52 | ) 53 | InputTextFieldType.Outlined -> OutlinedTextField( 54 | value = text, 55 | onValueChange = onValueChange, 56 | modifier = modifier.fillMaxWidth(), 57 | label = { Text(label) }, 58 | keyboardActions = keyboardActions, 59 | keyboardOptions = KeyboardOptions.Default.copy( 60 | keyboardType = keyboardType, 61 | imeAction = imeAction 62 | ), 63 | enabled = enabled, 64 | shape = MaterialTheme.shapes.small, 65 | maxLines = maxLine 66 | ) 67 | InputTextFieldType.WithIcon -> OutlinedTextField( 68 | value = text, 69 | onValueChange = onValueChange, 70 | modifier = modifier.fillMaxWidth(), 71 | leadingIcon = { 72 | Icon( 73 | imageVector = icon, 74 | contentDescription = "Icon", 75 | ) 76 | }, 77 | label = { Text(label) }, 78 | keyboardActions = keyboardActions, 79 | keyboardOptions = KeyboardOptions.Default.copy( 80 | keyboardType = keyboardType, 81 | imeAction = imeAction 82 | ), 83 | colors = TextFieldDefaults.outlinedTextFieldColors( 84 | focusedBorderColor = MaterialTheme.colorScheme.onSurface, 85 | focusedLabelColor = MaterialTheme.colorScheme.onSurface 86 | ), 87 | enabled = enabled, 88 | shape = MaterialTheme.shapes.small, 89 | maxLines = maxLine 90 | ) 91 | InputTextFieldType.IconClickable -> OutlinedTextField( 92 | value = text, 93 | onValueChange = onValueChange, 94 | modifier = modifier.fillMaxWidth(), 95 | leadingIcon = { 96 | androidx.compose.material.IconButton(onClick = searchQuery) { 97 | Icon( 98 | imageVector = icon, 99 | contentDescription = "Icon", 100 | ) 101 | } 102 | }, 103 | label = { Text(label) }, 104 | keyboardActions = keyboardActions, 105 | keyboardOptions = KeyboardOptions.Default.copy( 106 | keyboardType = keyboardType, 107 | imeAction = imeAction 108 | ), 109 | colors = TextFieldDefaults.outlinedTextFieldColors( 110 | focusedBorderColor = MaterialTheme.colorScheme.onSurface, 111 | focusedLabelColor = MaterialTheme.colorScheme.onSurface 112 | ), 113 | enabled = enabled, 114 | shape = MaterialTheme.shapes.small, 115 | maxLines = maxLine 116 | ) 117 | } 118 | } 119 | 120 | @Preview 121 | @Composable 122 | fun PreviewOutlinedTextField() { 123 | AppTheme { 124 | InputTextField(text = "Outlined", type = InputTextFieldType.Outlined) {} 125 | } 126 | } 127 | 128 | @Preview 129 | @Composable 130 | fun PreviewClassicTextField() { 131 | AppTheme { 132 | InputTextField(text = "Classic", type = InputTextFieldType.Classic) {} 133 | } 134 | } 135 | 136 | @Preview 137 | @Composable 138 | fun PreviewWithIconTextField() { 139 | AppTheme { 140 | InputTextField( 141 | text = "With Icon", 142 | type = InputTextFieldType.WithIcon, 143 | icon = Icons.Default.SendToMobile 144 | ) {} 145 | } 146 | } 147 | 148 | enum class InputTextFieldType { 149 | Classic, Outlined, WithIcon, IconClickable 150 | } -------------------------------------------------------------------------------- /app/src/main/java/com/composetemplate/core/ui/AndroidTemplateApp.kt: -------------------------------------------------------------------------------- 1 | package com.composetemplate.core.ui 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.ArrowBack 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.LaunchedEffect 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.ExperimentalComposeUiApi 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.res.painterResource 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.semantics.semantics 16 | import androidx.compose.ui.semantics.testTagsAsResourceId 17 | import androidx.compose.ui.zIndex 18 | import androidx.lifecycle.errorMessage 19 | import androidx.navigation.NavDestination 20 | import androidx.navigation.NavDestination.Companion.hierarchy 21 | import com.composetemplate.arch.extensions.collectAsStateLifecycleAware 22 | import com.composetemplate.core.navigation.AppNavHost 23 | import com.composetemplate.core.navigation.AppNavigationBar 24 | import com.composetemplate.core.navigation.AppNavigationBarItem 25 | import com.composetemplate.core.navigation.Destination 26 | 27 | @OptIn( 28 | ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class, 29 | ExperimentalComposeUiApi::class 30 | ) 31 | @Composable 32 | fun AndroidTemplateApp( 33 | appState: AppState 34 | ) { 35 | val snackbarHostState = remember { SnackbarHostState() } 36 | Scaffold( 37 | modifier = Modifier.semantics { 38 | testTagsAsResourceId = true 39 | }, 40 | containerColor = Color.Transparent, 41 | contentColor = MaterialTheme.colorScheme.onBackground, 42 | contentWindowInsets = WindowInsets(0, 0, 0, 0), 43 | snackbarHost = { 44 | SnackbarHost( 45 | snackbarHostState, 46 | modifier = Modifier 47 | .systemBarsPadding() 48 | .navigationBarsPadding() 49 | ) 50 | }, 51 | 52 | topBar = { 53 | val destination = appState.currentDestination 54 | if (appState.shouldShowTopAppBar) { 55 | AppTopAppBar( 56 | modifier = Modifier.zIndex(-1F), 57 | titleRes = destination?.titleTextId ?: -1, 58 | actionIcon = AppIcons.Settings, 59 | actionIconContentDescription = stringResource( 60 | id = com.composetemplate.R.string.icon 61 | ), 62 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors( 63 | containerColor = Color.Transparent 64 | ), 65 | onActionClick = { }, 66 | navigationIcon = Icons.Default.ArrowBack, 67 | navigationIconContentDescription = "", 68 | onNavigationClick = { appState.onBackClick() } 69 | 70 | ) 71 | } 72 | }, 73 | bottomBar = { 74 | if (appState.shouldShowBottomBar) { 75 | AppBottomBar( 76 | destinations = appState.destinationWithBottomBars, 77 | onNavigateToDestination = appState::navigateToTopLevelDestination, 78 | currentDestination = appState.currentDestinationAsState 79 | ) 80 | } 81 | } 82 | ) { padding -> 83 | val message = errorMessage.collectAsStateLifecycleAware().value 84 | LaunchedEffect(key1 = message.id) { 85 | if (message.message.isNotEmpty()) 86 | snackbarHostState.showSnackbar(message = message.message) 87 | } 88 | AppNavHost( 89 | navController = appState.navController, 90 | onBackClick = appState::onBackClick, 91 | modifier = Modifier 92 | .padding(padding) 93 | .consumedWindowInsets(padding) 94 | .systemBarsPadding() 95 | .statusBarsPadding() 96 | .navigationBarsPadding() 97 | ) 98 | } 99 | } 100 | 101 | @Composable 102 | private fun AppBottomBar( 103 | destinations: List, 104 | onNavigateToDestination: (Destination) -> Unit, 105 | currentDestination: NavDestination? 106 | ) { 107 | AppNavigationBar { 108 | destinations.forEach { destination -> 109 | val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) 110 | AppNavigationBarItem( 111 | selected = selected, 112 | onClick = { onNavigateToDestination(destination) }, 113 | icon = { 114 | val icon = if (selected) { 115 | destination.selectedIcon 116 | } else { 117 | destination.unselectedIcon 118 | } 119 | when (icon) { 120 | is Icon.ImageVectorIcon -> Icon( 121 | imageVector = icon.imageVector, 122 | contentDescription = null 123 | ) 124 | 125 | is Icon.DrawableResourceIcon -> Icon( 126 | painter = painterResource(id = icon.id), 127 | contentDescription = null 128 | ) 129 | } 130 | }, 131 | label = { Text(stringResource(destination.iconTextId ?: -1)) } 132 | ) 133 | } 134 | } 135 | } 136 | 137 | private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: Destination) = 138 | this?.hierarchy?.any { 139 | it.route?.contains(destination.name, true) ?: false 140 | } ?: false -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-kapt' 5 | id 'org.jetbrains.kotlin.plugin.serialization' version '1.4.30' 6 | id 'dagger.hilt.android.plugin' 7 | id 'kotlin-parcelize' 8 | } 9 | 10 | 11 | android { 12 | compileSdkVersion 33 13 | flavorDimensions "default" 14 | 15 | defaultConfig { 16 | applicationId "com.monstarlab" 17 | minSdkVersion 23 18 | targetSdkVersion 33 19 | versionCode 1 20 | versionName "1.0" 21 | 22 | manifestPlaceholders = [ 23 | appId : keys.appId, 24 | apiKey: keys.apiKey 25 | ] 26 | 27 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 28 | } 29 | buildFeatures { 30 | viewBinding true 31 | compose true 32 | } 33 | buildTypes { 34 | release { 35 | minifyEnabled false 36 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 37 | } 38 | } 39 | 40 | productFlavors { 41 | dev { 42 | dimension "default" 43 | applicationIdSuffix ".dev" 44 | manifestPlaceholders = [ 45 | APP_NAME: "MonstarlabDev", 46 | env : "staging" 47 | ] 48 | buildConfigField "String", "API_URL", "\"https://api.punkapi.com/v2/\"" 49 | } 50 | staging { 51 | dimension "default" 52 | applicationIdSuffix ".staging" 53 | //signingConfig signingConfigs.staging 54 | manifestPlaceholders = [ 55 | APP_NAME: "MonstarlabStaging", 56 | env : "staging" 57 | ] 58 | buildConfigField "String", "API_URL", "\"https://reqres.in/api/\"" 59 | } 60 | production { 61 | dimension "default" 62 | //signingConfig signingConfigs.production 63 | manifestPlaceholders = [ 64 | APP_NAME: "Monstarlab", 65 | env : "production" 66 | ] 67 | buildConfigField "String", "API_URL", "\"https://reqres.in/api/\"" 68 | } 69 | } 70 | 71 | compileOptions { 72 | sourceCompatibility JavaVersion.VERSION_1_8 73 | targetCompatibility JavaVersion.VERSION_1_8 74 | } 75 | composeOptions { 76 | kotlinCompilerVersion = versions.kotlin 77 | kotlinCompilerExtensionVersion = versions.compose 78 | } 79 | kotlinOptions { 80 | jvmTarget = '1.8' 81 | } 82 | } 83 | 84 | 85 | dependencies { 86 | 87 | implementation("org.jetbrains.kotlin:kotlin-stdlib:${versions.kotlin}") 88 | 89 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutines}" 90 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:${versions.coroutines}" 91 | implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:${versions.json}" 92 | implementation "androidx.core:core-ktx:${versions.ktx_core}" 93 | implementation "androidx.appcompat:appcompat:${versions.appcompat}" 94 | implementation "com.google.android.material:material:${versions.material}" 95 | implementation "androidx.constraintlayout:constraintlayout:${versions.constraint_layout}" 96 | testImplementation "junit:junit:${versions.junit}" 97 | androidTestImplementation "androidx.test.ext:junit:${versions.junit_ext}" 98 | androidTestImplementation "androidx.test.espresso:espresso-core:${versions.espresso}" 99 | //compose 100 | implementation "androidx.compose.ui:ui:${versions.compose}" 101 | implementation "androidx.compose.foundation:foundation:${versions.compose}" 102 | implementation "androidx.compose.runtime:runtime-livedata:${versions.compose}" 103 | implementation "androidx.compose.runtime:runtime-rxjava2:${versions.compose}" 104 | implementation "androidx.compose.material:material:${versions.compose}" 105 | implementation "androidx.compose.material:material-icons-core:${versions.compose}" 106 | implementation "androidx.compose.material:material-icons-extended:${versions.compose}" 107 | implementation "androidx.compose.ui:ui-tooling:${versions.compose}" 108 | implementation "androidx.compose.runtime:runtime:${versions.compose}" 109 | implementation "androidx.compose.material3:material3:${versions.material3}" 110 | implementation "androidx.compose.material3:material3-window-size-class:${versions.material3}" 111 | implementation "androidx.activity:activity-compose:${versions.compose}" 112 | implementation "com.google.accompanist:accompanist-systemuicontroller:0.27.0" 113 | implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" 114 | implementation("androidx.navigation:navigation-compose:2.6.0-alpha04") 115 | implementation "androidx.core:core-splashscreen:${versions.splash_screen}" 116 | implementation "androidx.compose.runtime:runtime:1.3.1" 117 | implementation "com.google.dagger:hilt-android:${versions.hilt}" 118 | implementation "androidx.hilt:hilt-navigation-compose:${versions.hilt_navigation_compose}" 119 | kapt "com.google.dagger:hilt-compiler:${versions.hilt}" 120 | 121 | 122 | implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:${versions.retrofit_converter}") 123 | implementation("com.squareup.retrofit2:retrofit:${versions.retrofit}") 124 | implementation("com.squareup.okhttp3:logging-interceptor:${versions.okhttp}") 125 | 126 | implementation("androidx.navigation:navigation-fragment-ktx:${versions.navigation}") 127 | implementation("androidx.navigation:navigation-ui-ktx:${versions.navigation}") 128 | 129 | implementation("androidx.lifecycle:lifecycle-livedata-core-ktx:${versions.lifecycle}") 130 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:${versions.lifecycle}") 131 | implementation("androidx.lifecycle:lifecycle-common-java8:${versions.lifecycle}") 132 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.lifecycle}") 133 | implementation("androidx.lifecycle:lifecycle-livedata-ktx:${versions.lifecycle}") 134 | 135 | implementation("androidx.datastore:datastore-preferences:${versions.datastore}") 136 | 137 | implementation "com.jakewharton.timber:timber:${versions.timber}" 138 | implementation 'androidx.room:room-paging:2.4.3' 139 | 140 | implementation "androidx.paging:paging-runtime-ktx:3.1.1" 141 | implementation "androidx.room:room-ktx:${versions.room}" 142 | kapt "androidx.room:room-compiler:${versions.room}" 143 | implementation "com.squareup.retrofit2:converter-gson:2.9.0" 144 | implementation "androidx.paging:paging-compose:${versions.paging_compose}" 145 | implementation "com.google.accompanist:accompanist-flowlayout:${versions.accompanist}" 146 | implementation("io.coil-kt:coil-compose:2.2.2") 147 | 148 | } --------------------------------------------------------------------------------