├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── techyourchance │ │ └── architecture │ │ ├── CustomApplication.kt │ │ ├── common │ │ ├── Constants.kt │ │ ├── base64 │ │ │ └── Base64EncodeDecode.kt │ │ ├── database │ │ │ ├── FavoriteQuestionDao.kt │ │ │ └── MyRoomDatabase.kt │ │ └── di │ │ │ └── ApplicationModule.kt │ │ ├── networking │ │ ├── StackoverflowApi.kt │ │ ├── question │ │ │ ├── QuestionDetailsSchema.kt │ │ │ ├── QuestionSchema.kt │ │ │ ├── QuestionWithBodySchema.kt │ │ │ └── QuestionsListSchema.kt │ │ └── user │ │ │ └── UserSchema.kt │ │ ├── question │ │ ├── FavoriteQuestion.kt │ │ ├── Question.kt │ │ ├── QuestionWithBody.kt │ │ ├── QuestionsCache.kt │ │ └── usecases │ │ │ ├── FetchQuestionsListUseCase.kt │ │ │ ├── ObserveFavoriteQuestionUseCase.kt │ │ │ ├── ObserveFavoriteQuestionsUseCase.kt │ │ │ ├── ObserveQuestionDetailsUseCase.kt │ │ │ └── ToggleFavoriteQuestionUseCase.kt │ │ └── screens │ │ ├── BottomTab.kt │ │ ├── MainActivity.kt │ │ ├── Route.kt │ │ ├── ScreensNavigator.kt │ │ ├── Theme.kt │ │ ├── common │ │ └── composables │ │ │ └── QuestionItem.kt │ │ ├── favoritequestions │ │ ├── FavoriteQuestionsScreen.kt │ │ └── FavoriteQuestionsViewModel.kt │ │ ├── main │ │ ├── MainScreen.kt │ │ ├── MainViewModel.kt │ │ ├── MyBottomTabsBar.kt │ │ └── MyTopBar.kt │ │ ├── questiondetails │ │ ├── QuestionDetailsScreen.kt │ │ └── QuestionDetailsViewModel.kt │ │ └── questionslist │ │ ├── QuestionsListScreen.kt │ │ └── QuestionsListViewModel.kt │ └── res │ ├── drawable │ ├── ic_launcher_background.xml │ └── ic_launcher_foreground.xml │ ├── mipmap-anydpi │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | # release signing config files 2 | release.properties 3 | 4 | # API keys file 5 | api.properties 6 | 7 | #built application files 8 | *.apk 9 | *.ap_ 10 | 11 | # files for the dex VM 12 | *.dex 13 | 14 | # Java class files 15 | *.class 16 | 17 | # generated files 18 | bin/ 19 | gen/ 20 | 21 | # Local configuration file (sdk path, etc) 22 | local.properties 23 | 24 | # Windows thumbnail db 25 | Thumbs.db 26 | 27 | # OSX files 28 | .DS_Store 29 | 30 | # Android Studio 31 | .idea 32 | .gradle 33 | build/ 34 | captures/ 35 | qa/ 36 | release/ 37 | debug/ 38 | *.iml 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Architecture Masterclass 2 | 3 | Tutorial application for [my course about architecture of Android applications](https://www.techyourchance.com/android-architecture-course) that teaches the most advanced topics in Android development using modern tools: Jetpack Compose, MVVM, Hilt and more. 4 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | 3 | plugins { 4 | alias(libs.plugins.androidApplication) 5 | alias(libs.plugins.kotlinAndroid) 6 | alias(libs.plugins.ksp) 7 | alias(libs.plugins.hilt) 8 | } 9 | 10 | android { 11 | namespace = "com.techyourchance.architecture" 12 | compileSdk = 34 13 | 14 | defaultConfig { 15 | applicationId = "com.techyourchance.architecture" 16 | minSdk = 26 17 | targetSdk = 34 18 | versionCode = 1 19 | versionName = "1.0" 20 | 21 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 22 | 23 | vectorDrawables { 24 | useSupportLibrary = true 25 | } 26 | } 27 | 28 | buildTypes { 29 | release { 30 | isMinifyEnabled = false 31 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 32 | } 33 | } 34 | compileOptions { 35 | sourceCompatibility = JavaVersion.VERSION_1_8 36 | targetCompatibility = JavaVersion.VERSION_1_8 37 | } 38 | kotlinOptions { 39 | jvmTarget = "1.8" 40 | } 41 | buildFeatures { 42 | compose = true 43 | buildConfig = true 44 | } 45 | composeOptions { 46 | kotlinCompilerExtensionVersion = "1.5.1" 47 | } 48 | packaging { 49 | resources { 50 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 51 | } 52 | } 53 | } 54 | 55 | dependencies { 56 | 57 | implementation(libs.core.ktx) 58 | implementation(libs.lifecycle.runtime.ktx) 59 | implementation(libs.activity.compose) 60 | implementation(platform(libs.compose.bom)) 61 | implementation(libs.ui) 62 | implementation(libs.ui.graphics) 63 | implementation(libs.ui.tooling.preview) 64 | implementation(libs.material3) 65 | 66 | // Dependency Injection 67 | implementation(libs.hilt) 68 | implementation(libs.hilt.navigation.compose) 69 | ksp(libs.hilt.compiler) 70 | 71 | // Navigation 72 | implementation(libs.navigation.compose) 73 | 74 | // Networking 75 | implementation(libs.retrofit) 76 | implementation(libs.retrofit.moshi.converter) 77 | implementation(libs.retrofit.logging.interceptor) 78 | 79 | // Json 80 | implementation(libs.moshi) 81 | ksp(libs.moshi.codegen) 82 | 83 | // Image loading 84 | implementation(libs.coil) 85 | 86 | // ORM 87 | implementation(libs.room) 88 | implementation(libs.room.ktx) 89 | ksp(libs.room.compiler) 90 | 91 | testImplementation(libs.junit) 92 | 93 | androidTestImplementation(libs.androidx.test.ext.junit) 94 | androidTestImplementation(libs.espresso.core) 95 | androidTestImplementation(platform(libs.compose.bom)) 96 | androidTestImplementation(libs.ui.test.junit4) 97 | 98 | debugImplementation(libs.ui.tooling) 99 | debugImplementation(libs.ui.test.manifest) 100 | } -------------------------------------------------------------------------------- /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/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/CustomApplication.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class CustomApplication: Application() { 8 | 9 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/common/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.common 2 | 3 | object Constants { 4 | const val DB_NAME = "MyDatabase" 5 | const val BASE_URL = "http://api.stackexchange.com/2.3/" 6 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/common/base64/Base64EncodeDecode.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.common.base64 2 | 3 | import java.util.Base64 4 | 5 | object Base64EncodeDecode { 6 | 7 | fun String.encodeToBase64(): String { 8 | return Base64.getUrlEncoder().encodeToString(this.toByteArray()) 9 | } 10 | 11 | fun String.decodeFromBase64(): String { 12 | return String(Base64.getUrlDecoder().decode(this)) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/common/database/FavoriteQuestionDao.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.common.database 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.techyourchance.architecture.question.FavoriteQuestion 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | @Dao 11 | interface FavoriteQuestionDao { 12 | 13 | @Insert(onConflict = OnConflictStrategy.REPLACE) 14 | suspend fun upsert(favoriteQuestion: FavoriteQuestion) 15 | 16 | 17 | @Query("SELECT * FROM favorite") 18 | fun observe(): Flow> 19 | 20 | @Query("SELECT * FROM favorite WHERE id = :id") 21 | suspend fun getById(id: String): FavoriteQuestion? 22 | 23 | @Query("SELECT * FROM favorite WHERE id = :id") 24 | fun observeById(id: String): Flow 25 | 26 | @Query("DELETE FROM favorite WHERE id = :id") 27 | suspend fun delete(id: String) 28 | 29 | 30 | 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/common/database/MyRoomDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.common.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.techyourchance.architecture.question.FavoriteQuestion 6 | 7 | @Database( 8 | entities = [ 9 | FavoriteQuestion::class 10 | ], 11 | version = 1 12 | ) 13 | abstract class MyRoomDatabase : RoomDatabase() { 14 | 15 | abstract val favoriteQuestionDao: FavoriteQuestionDao 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/common/di/ApplicationModule.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.common.di 2 | 3 | import android.app.Application 4 | import androidx.room.Room 5 | import com.techyourchance.architecture.BuildConfig 6 | import com.techyourchance.architecture.common.Constants 7 | import com.techyourchance.architecture.common.database.FavoriteQuestionDao 8 | import com.techyourchance.architecture.common.database.MyRoomDatabase 9 | import com.techyourchance.architecture.networking.StackoverflowApi 10 | import dagger.Module 11 | import dagger.Provides 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.components.SingletonComponent 14 | import okhttp3.OkHttpClient 15 | import okhttp3.logging.HttpLoggingInterceptor 16 | import retrofit2.Retrofit 17 | import retrofit2.converter.moshi.MoshiConverterFactory 18 | import javax.inject.Singleton 19 | 20 | @Module 21 | @InstallIn(SingletonComponent::class) 22 | class ApplicationModule { 23 | 24 | @Provides 25 | @Singleton 26 | fun retrofit(): Retrofit { 27 | val httpClient = OkHttpClient.Builder().run { 28 | addInterceptor(HttpLoggingInterceptor().apply { 29 | if (BuildConfig.DEBUG) { 30 | level = HttpLoggingInterceptor.Level.BODY 31 | } 32 | }) 33 | build() 34 | } 35 | 36 | return Retrofit.Builder() 37 | .baseUrl(Constants.BASE_URL) 38 | .addConverterFactory(MoshiConverterFactory.create()) 39 | .client(httpClient) 40 | .build() 41 | } 42 | 43 | @Provides 44 | fun stackOverflowApi(retrofit: Retrofit): StackoverflowApi { 45 | return retrofit.create(StackoverflowApi::class.java) 46 | } 47 | 48 | @Provides 49 | @Singleton 50 | fun myRoomDatabase(application: Application): MyRoomDatabase { 51 | return Room.databaseBuilder( 52 | application, 53 | MyRoomDatabase::class.java, 54 | Constants.DB_NAME 55 | ).build() 56 | } 57 | 58 | @Provides 59 | fun favoriteQuestionDao(database: MyRoomDatabase): FavoriteQuestionDao { 60 | return database.favoriteQuestionDao 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/networking/StackoverflowApi.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.networking 2 | 3 | import com.techyourchance.architecture.networking.question.QuestionDetailsSchema 4 | import com.techyourchance.architecture.networking.question.QuestionsListSchema 5 | import retrofit2.http.GET 6 | import retrofit2.http.Path 7 | import retrofit2.http.Query 8 | 9 | interface StackoverflowApi { 10 | 11 | @GET("/questions?key=$STACKOVERFLOW_API_KEY&sort=activity&order=desc&site=stackoverflow") 12 | suspend fun fetchLastActiveQuestions(@Query("pagesize") pageSize: Int?): QuestionsListSchema? 13 | 14 | @GET("/questions/{questionId}?key=$STACKOVERFLOW_API_KEY&site=stackoverflow&filter=withbody") 15 | suspend fun fetchQuestionDetails(@Path("questionId") questionId: String?): QuestionDetailsSchema? 16 | 17 | companion object { 18 | const val STACKOVERFLOW_API_KEY = "f)yov8mEGrYZa1dJDb2gpg((" 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/networking/question/QuestionDetailsSchema.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.networking.question 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class QuestionDetailsSchema ( 8 | @Json(name = "items") val questions: List, 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/networking/question/QuestionSchema.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.networking.question 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | import com.techyourchance.architecture.networking.user.UserSchema 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class QuestionSchema( 9 | @Json(name = "title") val title: String, 10 | @Json(name = "question_id") val id: String, 11 | @Json(name = "owner") val owner: UserSchema, 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/networking/question/QuestionWithBodySchema.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.networking.question 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | import com.techyourchance.architecture.networking.user.UserSchema 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class QuestionWithBodySchema( 9 | @Json(name = "title") val title: String, 10 | @Json(name = "question_id") val id: String, 11 | @Json(name = "body") val body: String, 12 | @Json(name = "owner") val owner: UserSchema, 13 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/networking/question/QuestionsListSchema.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.networking.question 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class QuestionsListSchema ( 8 | @Json(name = "items") val questions: List, 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/networking/user/UserSchema.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.networking.user 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class UserSchema( 8 | @Json(name = "display_name") val name: String, 9 | @Json(name = "profile_image") val imageUrl: String?, 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/question/FavoriteQuestion.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.question 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "favorite") 8 | data class FavoriteQuestion( 9 | @ColumnInfo(name = "id") @PrimaryKey val id: String, 10 | @ColumnInfo(name = "title") val title: String, 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/question/Question.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.question 2 | 3 | data class Question( 4 | val id: String, 5 | val title: String, 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/question/QuestionWithBody.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.question 2 | 3 | data class QuestionWithBody( 4 | val id: String, 5 | val title: String, 6 | val body: String, 7 | val isFavorite: Boolean, 8 | ) 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/question/QuestionsCache.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.question 2 | 3 | import okio.withLock 4 | import java.util.concurrent.locks.ReentrantLock 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class QuestionsCache @Inject constructor() { 10 | 11 | private val lock = ReentrantLock() 12 | 13 | private val questions = mutableListOf() 14 | 15 | fun replaceInCache(question: QuestionWithBody) { 16 | lock.withLock { 17 | val existingQuestion = questions.firstOrNull { it.id == question.id } 18 | if (existingQuestion != null) { 19 | questions.remove(existingQuestion) 20 | } 21 | questions.add(question) 22 | } 23 | } 24 | 25 | fun get(questionId: String): QuestionWithBody? { 26 | return lock.withLock { 27 | questions.firstOrNull { it.id == questionId } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/question/usecases/FetchQuestionsListUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.question.usecases 2 | 3 | import com.techyourchance.architecture.networking.StackoverflowApi 4 | import com.techyourchance.architecture.question.Question 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.withContext 7 | import javax.inject.Inject 8 | 9 | class FetchQuestionsListUseCase @Inject constructor( 10 | private val stackoverflowApi: StackoverflowApi 11 | ) { 12 | 13 | private var lastNetworkRequestNano = 0L 14 | 15 | private var questions: List = emptyList() 16 | 17 | suspend fun fetchLastActiveQuestions(): List { 18 | return withContext(Dispatchers.IO) { 19 | if (hasEnoughTimePassed()) { 20 | lastNetworkRequestNano = System.nanoTime() 21 | questions = stackoverflowApi.fetchLastActiveQuestions(20)!!.questions.map { questionSchema -> 22 | Question( 23 | questionSchema.id, 24 | questionSchema.title 25 | ) 26 | } 27 | questions 28 | } else { 29 | questions 30 | } 31 | } 32 | } 33 | 34 | private fun hasEnoughTimePassed(): Boolean { 35 | return System.nanoTime() - lastNetworkRequestNano > THROTTLE_TIMEOUT_MS * 1_000_000 36 | } 37 | 38 | companion object { 39 | private const val THROTTLE_TIMEOUT_MS = 5000L 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/question/usecases/ObserveFavoriteQuestionUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.question.usecases 2 | 3 | import com.techyourchance.architecture.common.database.FavoriteQuestionDao 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.map 6 | import javax.inject.Inject 7 | 8 | class ObserveFavoriteQuestionUseCase @Inject constructor( 9 | private val favoriteQuestionDao: FavoriteQuestionDao, 10 | ) { 11 | 12 | fun isQuestionFavorite(questionId: String): Flow { 13 | return favoriteQuestionDao.observeById(questionId).map { favoriteQuestion -> 14 | favoriteQuestion != null 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/question/usecases/ObserveFavoriteQuestionsUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.question.usecases 2 | 3 | import com.techyourchance.architecture.common.database.FavoriteQuestionDao 4 | import com.techyourchance.architecture.question.FavoriteQuestion 5 | import kotlinx.coroutines.flow.Flow 6 | import javax.inject.Inject 7 | 8 | class ObserveFavoriteQuestionsUseCase @Inject constructor( 9 | private val favoriteQuestionDao: FavoriteQuestionDao, 10 | ) { 11 | 12 | fun observeFavorites(): Flow> { 13 | return favoriteQuestionDao.observe() 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/question/usecases/ObserveQuestionDetailsUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.question.usecases 2 | 3 | import com.techyourchance.architecture.common.database.FavoriteQuestionDao 4 | import com.techyourchance.architecture.networking.StackoverflowApi 5 | import com.techyourchance.architecture.question.QuestionWithBody 6 | import com.techyourchance.architecture.question.QuestionsCache 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.catch 10 | import kotlinx.coroutines.flow.combine 11 | import kotlinx.coroutines.flow.flow 12 | import kotlinx.coroutines.withContext 13 | import javax.inject.Inject 14 | 15 | class ObserveQuestionDetailsUseCase @Inject constructor( 16 | private val stackoverflowApi: StackoverflowApi, 17 | private val favoriteQuestionDao: FavoriteQuestionDao, 18 | private val questionsCache: QuestionsCache, 19 | ) { 20 | 21 | sealed class QuestionDetailsResult { 22 | data class Success(val questionDetails: QuestionWithBody): QuestionDetailsResult() 23 | data object Error: QuestionDetailsResult() 24 | } 25 | 26 | suspend fun observeQuestionDetails(questionId: String): Flow { 27 | return withContext(Dispatchers.IO) { 28 | combine( 29 | flow = flow { 30 | emit(questionsCache.get(questionId) ?: fetchFromNetwork(questionId)) 31 | }, 32 | flow2 = favoriteQuestionDao.observeById(questionId), 33 | ) { questionDetails, favoriteQuestion -> 34 | if (questionDetails != null) { 35 | val questionWithBody = questionDetails.copy(isFavorite = favoriteQuestion != null) 36 | questionsCache.replaceInCache(questionWithBody) 37 | QuestionDetailsResult.Success(questionWithBody) 38 | } else { 39 | QuestionDetailsResult.Error 40 | } 41 | }.catch { 42 | QuestionDetailsResult.Error 43 | } 44 | } 45 | } 46 | 47 | private suspend fun fetchFromNetwork(questionId: String): QuestionWithBody? { 48 | val schema = stackoverflowApi.fetchQuestionDetails(questionId) 49 | return if (schema != null && schema.questions.isNotEmpty()) { 50 | schema.questions[0].run { 51 | QuestionWithBody(id, title, body, false) 52 | } 53 | } else { 54 | null 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/question/usecases/ToggleFavoriteQuestionUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.question.usecases 2 | 3 | import com.techyourchance.architecture.common.database.FavoriteQuestionDao 4 | import com.techyourchance.architecture.question.FavoriteQuestion 5 | import kotlinx.coroutines.GlobalScope 6 | import kotlinx.coroutines.launch 7 | import javax.inject.Inject 8 | 9 | class ToggleFavoriteQuestionUseCase @Inject constructor( 10 | private val favoriteQuestionDao: FavoriteQuestionDao, 11 | ) { 12 | 13 | fun toggleFavoriteQuestion(questionId: String, questionTitle: String) { 14 | GlobalScope.launch { 15 | if (favoriteQuestionDao.getById(questionId) != null) { 16 | favoriteQuestionDao.delete(questionId) 17 | } else { 18 | favoriteQuestionDao.upsert(FavoriteQuestion(questionId, questionTitle)) 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/BottomTab.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.rounded.Favorite 5 | import androidx.compose.material.icons.rounded.Home 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | 8 | sealed class BottomTab(val icon: ImageVector?, var title: String) { 9 | data object Main : BottomTab(Icons.Rounded.Home, "Home") 10 | data object Favorites : BottomTab(Icons.Rounded.Favorite, "Favorites") 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import com.techyourchance.architecture.common.database.MyRoomDatabase 7 | import com.techyourchance.architecture.networking.StackoverflowApi 8 | import com.techyourchance.architecture.screens.main.MainScreen 9 | import dagger.hilt.android.AndroidEntryPoint 10 | import retrofit2.Retrofit 11 | import javax.inject.Inject 12 | 13 | @AndroidEntryPoint 14 | class MainActivity : ComponentActivity() { 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | setContent { 19 | MyTheme { 20 | MainScreen() 21 | } 22 | } 23 | } 24 | 25 | override fun onStart() { 26 | super.onStart() 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/Route.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens 2 | 3 | import com.techyourchance.architecture.common.base64.Base64EncodeDecode.encodeToBase64 4 | 5 | sealed class Route(val routeName: String) { 6 | data object MainTab: Route("mainTab") 7 | data object FavoritesTab: Route("favoritesTab") 8 | data object QuestionsListScreen: Route("questionsList") 9 | 10 | data class QuestionDetailsScreen( 11 | val questionId: String = "", 12 | val questionTitle: String = "" 13 | ): Route("questionDetails/{questionId}/{questionTitle}") { 14 | override val navCommand: String 15 | get() = routeName 16 | .replace("{questionId}", questionId) 17 | .replace("{questionTitle}", questionTitle.encodeToBase64()) 18 | } 19 | 20 | data object FavoriteQuestionsScreen: Route("favorites") 21 | 22 | 23 | open val navCommand = routeName 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/ScreensNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens 2 | 3 | import android.os.Bundle 4 | import androidx.compose.runtime.remember 5 | import androidx.navigation.NavController 6 | import androidx.navigation.NavHostController 7 | import com.techyourchance.architecture.common.base64.Base64EncodeDecode.decodeFromBase64 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.Job 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.collect 13 | import kotlinx.coroutines.flow.map 14 | import kotlinx.coroutines.launch 15 | 16 | class ScreensNavigator { 17 | 18 | private val scope = CoroutineScope(Dispatchers.Main.immediate) 19 | 20 | private lateinit var parentNavController: NavHostController 21 | private lateinit var nestedNavController: NavHostController 22 | 23 | private var nestedNavControllerObserveJob: Job? = null 24 | private var parentNavControllerObserveJob: Job? = null 25 | 26 | val currentBottomTab = MutableStateFlow(null) 27 | val currentRoute = MutableStateFlow(null) 28 | val isRootRoute = MutableStateFlow(false) 29 | 30 | fun setParentNavController(navController: NavHostController) { 31 | parentNavController = navController 32 | 33 | parentNavControllerObserveJob?.cancel() 34 | parentNavControllerObserveJob = scope.launch { 35 | navController.currentBackStackEntryFlow.map { backStackEntry -> 36 | val bottomTab = when (val routeName = backStackEntry.destination.route) { 37 | Route.MainTab.routeName -> BottomTab.Main 38 | Route.FavoritesTab.routeName -> BottomTab.Favorites 39 | null -> null 40 | else -> throw RuntimeException("unsupported bottom tab: $routeName") 41 | } 42 | currentBottomTab.value = bottomTab 43 | }.collect() 44 | } 45 | } 46 | 47 | fun setNestedNavController(navController: NavHostController) { 48 | nestedNavController = navController 49 | 50 | nestedNavControllerObserveJob?.cancel() 51 | nestedNavControllerObserveJob = scope.launch { 52 | navController.currentBackStackEntryFlow.map { backStackEntry -> 53 | val route = when (val routeName = backStackEntry.destination.route) { 54 | Route.MainTab.routeName -> Route.MainTab 55 | Route.FavoritesTab.routeName -> Route.FavoritesTab 56 | Route.QuestionsListScreen.routeName -> Route.QuestionsListScreen 57 | Route.QuestionDetailsScreen().routeName -> { 58 | val args = backStackEntry.arguments 59 | Route.QuestionDetailsScreen( 60 | args?.getString("questionId")!!, 61 | args?.getString("questionTitle")!!.decodeFromBase64() 62 | ) 63 | } 64 | Route.FavoriteQuestionsScreen.routeName -> Route.FavoriteQuestionsScreen 65 | null -> null 66 | else -> throw RuntimeException("unsupported route: $routeName") 67 | } 68 | currentRoute.value = route 69 | isRootRoute.value = route == Route.QuestionsListScreen 70 | }.collect() 71 | } 72 | 73 | } 74 | 75 | fun navigateBack() { 76 | if (!nestedNavController.popBackStack()) { 77 | parentNavController.popBackStack() 78 | } 79 | } 80 | 81 | fun toTab(bottomTab: BottomTab) { 82 | val route = when(bottomTab) { 83 | BottomTab.Favorites -> Route.FavoritesTab 84 | BottomTab.Main -> Route.MainTab 85 | } 86 | parentNavController.navigate(route.routeName) { 87 | parentNavController.graph.startDestinationRoute?.let { startRoute -> 88 | popUpTo(startRoute) { 89 | saveState = true 90 | } 91 | } 92 | launchSingleTop = true 93 | restoreState = true 94 | } 95 | } 96 | 97 | fun toRoute(route: Route) { 98 | nestedNavController.navigate(route.navCommand) 99 | } 100 | 101 | 102 | companion object { 103 | val BOTTOM_TABS = listOf(BottomTab.Main, BottomTab.Favorites) 104 | } 105 | 106 | 107 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens 2 | 3 | import android.app.Activity 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.Typography 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.lightColorScheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.SideEffect 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.graphics.toArgb 13 | import androidx.compose.ui.platform.LocalView 14 | import androidx.compose.ui.text.TextStyle 15 | import androidx.compose.ui.text.font.FontFamily 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.unit.sp 18 | import androidx.core.view.WindowCompat 19 | 20 | val Primary = Color(0xFF3F51B5) 21 | val Secondary = Color(0xFF6C78B6) 22 | val Tertiary = Color(0xFFFF4081) 23 | 24 | private val DarkColorScheme = darkColorScheme( 25 | primary = Primary, 26 | secondary = Secondary, 27 | tertiary = Tertiary 28 | ) 29 | 30 | private val LightColorScheme = lightColorScheme( 31 | primary = Primary, 32 | secondary = Secondary, 33 | tertiary = Tertiary 34 | 35 | /* Other default colors to override 36 | background = Color(0xFFFFFBFE), 37 | surface = Color(0xFFFFFBFE), 38 | onPrimary = Color.White, 39 | onSecondary = Color.White, 40 | onTertiary = Color.White, 41 | onBackground = Color(0xFF1C1B1F), 42 | onSurface = Color(0xFF1C1B1F), 43 | */ 44 | ) 45 | val Typography = Typography( 46 | bodyLarge = TextStyle( 47 | fontFamily = FontFamily.Default, 48 | fontWeight = FontWeight.Normal, 49 | fontSize = 25.sp, 50 | lineHeight = 28.sp, 51 | ) 52 | /* Other default text styles to override 53 | titleLarge = TextStyle( 54 | fontFamily = FontFamily.Default, 55 | fontWeight = FontWeight.Normal, 56 | fontSize = 22.sp, 57 | lineHeight = 28.sp, 58 | letterSpacing = 0.sp 59 | ), 60 | labelSmall = TextStyle( 61 | fontFamily = FontFamily.Default, 62 | fontWeight = FontWeight.Medium, 63 | fontSize = 11.sp, 64 | lineHeight = 16.sp, 65 | letterSpacing = 0.5.sp 66 | ) 67 | */ 68 | ) 69 | 70 | @Composable 71 | fun MyTheme( 72 | darkTheme: Boolean = isSystemInDarkTheme(), 73 | content: @Composable () -> Unit 74 | ) { 75 | val colorScheme = when { 76 | 77 | darkTheme -> DarkColorScheme 78 | else -> LightColorScheme 79 | } 80 | val view = LocalView.current 81 | if (!view.isInEditMode) { 82 | SideEffect { 83 | val window = (view.context as Activity).window 84 | window.statusBarColor = colorScheme.primary.toArgb() 85 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme 86 | } 87 | } 88 | 89 | MaterialTheme( 90 | colorScheme = colorScheme, 91 | typography = Typography, 92 | content = content 93 | ) 94 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/common/composables/QuestionItem.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.common.composables 2 | 3 | import android.text.Html 4 | import android.text.Spanned 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.wrapContentHeight 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | 16 | 17 | @Composable 18 | fun QuestionItem( 19 | questionId: String, 20 | questionTitle: String, 21 | onQuestionClicked: () -> Unit, 22 | ) { 23 | Box( 24 | modifier = Modifier 25 | .wrapContentHeight() 26 | .fillMaxWidth() 27 | .clickable { 28 | onQuestionClicked() 29 | } 30 | ) { 31 | Row( 32 | verticalAlignment = Alignment.CenterVertically, 33 | modifier = Modifier 34 | ) { 35 | val spannedTitle: Spanned = Html.fromHtml(questionTitle, Html.FROM_HTML_MODE_LEGACY) 36 | Text( 37 | modifier = Modifier 38 | .weight(1.8f), 39 | text = spannedTitle.toString(), 40 | style = MaterialTheme.typography.bodyLarge 41 | ) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/favoritequestions/FavoriteQuestionsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.favoritequestions 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.material3.HorizontalDivider 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.collectAsState 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.unit.dp 17 | import androidx.hilt.navigation.compose.hiltViewModel 18 | import com.techyourchance.architecture.screens.common.composables.QuestionItem 19 | 20 | 21 | @Composable 22 | fun FavoriteQuestionsScreen( 23 | viewModel: FavoriteQuestionsViewModel = hiltViewModel(), 24 | onQuestionClicked: (String, String) -> Unit, 25 | ) { 26 | 27 | val favorites = viewModel.favoriteQuestions.collectAsState(initial = listOf()) 28 | 29 | if (favorites.value.isNotEmpty()) { 30 | LazyColumn( 31 | modifier = Modifier 32 | .fillMaxSize() 33 | .padding(vertical = 5.dp), 34 | verticalArrangement = Arrangement.spacedBy(20.dp), 35 | contentPadding = PaddingValues(top = 10.dp, bottom = 10.dp) 36 | ) { 37 | items(favorites.value.size) { index -> 38 | val favoriteQuestion = favorites.value[index] 39 | QuestionItem( 40 | questionId = favoriteQuestion.id, 41 | questionTitle = favoriteQuestion.title, 42 | onQuestionClicked = { 43 | onQuestionClicked(favoriteQuestion.id, favoriteQuestion.title) 44 | }, 45 | ) 46 | if (index < favorites.value.size - 1) { 47 | HorizontalDivider( 48 | modifier = Modifier 49 | .padding(top = 20.dp), 50 | thickness = 2.dp 51 | ) 52 | } 53 | } 54 | } 55 | } else { 56 | Box( 57 | modifier = Modifier.fillMaxSize() 58 | ) { 59 | Text( 60 | modifier = Modifier.align(Alignment.Center), 61 | textAlign = TextAlign.Center, 62 | text = "No favorites", 63 | ) 64 | } 65 | 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/favoritequestions/FavoriteQuestionsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.favoritequestions 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.techyourchance.architecture.question.usecases.ObserveFavoriteQuestionsUseCase 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import javax.inject.Inject 7 | 8 | @HiltViewModel 9 | class FavoriteQuestionsViewModel @Inject constructor( 10 | private val observeFavoriteQuestionsUseCase: ObserveFavoriteQuestionsUseCase, 11 | ): ViewModel() { 12 | 13 | val favoriteQuestions = observeFavoriteQuestionsUseCase.observeFavorites() 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/main/MainScreen.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.main 2 | 3 | import androidx.compose.animation.core.tween 4 | import androidx.compose.animation.fadeIn 5 | import androidx.compose.animation.fadeOut 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material3.BottomAppBar 10 | import androidx.compose.material3.Scaffold 11 | import androidx.compose.material3.Surface 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.LaunchedEffect 14 | import androidx.compose.runtime.collectAsState 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.runtime.mutableStateOf 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.runtime.setValue 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.unit.dp 21 | import androidx.hilt.navigation.compose.hiltViewModel 22 | import androidx.navigation.compose.NavHost 23 | import androidx.navigation.compose.composable 24 | import androidx.navigation.compose.rememberNavController 25 | import com.techyourchance.architecture.screens.Route 26 | import com.techyourchance.architecture.screens.ScreensNavigator 27 | import com.techyourchance.architecture.screens.favoritequestions.FavoriteQuestionsScreen 28 | import com.techyourchance.architecture.screens.questiondetails.QuestionDetailsScreen 29 | import com.techyourchance.architecture.screens.questionslist.QuestionsListScreen 30 | import kotlinx.coroutines.flow.map 31 | 32 | 33 | @Composable 34 | fun MainScreen( 35 | mainViewModel: MainViewModel = hiltViewModel(), 36 | ) { 37 | 38 | val screensNavigator = remember() { 39 | ScreensNavigator() 40 | } 41 | 42 | val currentBottomTab = screensNavigator.currentBottomTab.collectAsState() 43 | 44 | val currentRoute = screensNavigator.currentRoute.collectAsState() 45 | 46 | val isRootRoute = screensNavigator.isRootRoute.collectAsState() 47 | 48 | val isShowFavoriteButton = screensNavigator.currentRoute.map { route -> 49 | route is Route.QuestionDetailsScreen 50 | }.collectAsState(initial = false) 51 | 52 | val questionIdAndTitle = remember(currentRoute.value) { 53 | if (currentRoute.value is Route.QuestionDetailsScreen) { 54 | Pair( 55 | (currentRoute.value as Route.QuestionDetailsScreen).questionId, 56 | (currentRoute.value as Route.QuestionDetailsScreen).questionTitle, 57 | ) 58 | } else { 59 | Pair("", "") 60 | } 61 | } 62 | 63 | var isFavoriteQuestion by remember { mutableStateOf(false) } 64 | 65 | if (isShowFavoriteButton.value && questionIdAndTitle.first.isNotEmpty()) { 66 | LaunchedEffect(questionIdAndTitle) { 67 | mainViewModel.isQuestionFavorite(questionIdAndTitle.first).collect { 68 | isFavoriteQuestion = it 69 | } 70 | } 71 | } 72 | 73 | Scaffold( 74 | topBar = { 75 | MyTopAppBar( 76 | isRootRoute = isRootRoute.value, 77 | isShowFavoriteButton = isShowFavoriteButton.value, 78 | isFavoriteQuestion = isFavoriteQuestion, 79 | questionIdAndTitle = questionIdAndTitle, 80 | onToggleFavoriteClicked = { 81 | mainViewModel.toggleFavoriteQuestion(questionIdAndTitle.first, questionIdAndTitle.second) 82 | }, 83 | onBackClicked = { 84 | screensNavigator.navigateBack() 85 | } 86 | ) 87 | }, 88 | bottomBar = { 89 | BottomAppBar(modifier = Modifier) { 90 | MyBottomTabsBar( 91 | bottomTabs = ScreensNavigator.BOTTOM_TABS, 92 | currentBottomTab = currentBottomTab.value, 93 | onTabClicked = { bottomTab -> 94 | screensNavigator.toTab(bottomTab) 95 | } 96 | ) 97 | } 98 | }, 99 | content = { padding -> 100 | MainScreenContent( 101 | padding = padding, 102 | screensNavigator = screensNavigator, 103 | ) 104 | } 105 | ) 106 | 107 | } 108 | 109 | @Composable 110 | private fun MainScreenContent( 111 | padding: PaddingValues, 112 | screensNavigator: ScreensNavigator, 113 | ) { 114 | val parentNavController = rememberNavController() 115 | screensNavigator.setParentNavController(parentNavController) 116 | 117 | Surface( 118 | modifier = Modifier 119 | .padding(padding) 120 | .padding(horizontal = 12.dp), 121 | ) { 122 | NavHost( 123 | modifier = Modifier.fillMaxSize(), 124 | navController = parentNavController, 125 | enterTransition = { fadeIn(animationSpec = tween(200)) }, 126 | exitTransition = { fadeOut(animationSpec = tween(200)) }, 127 | startDestination = Route.MainTab.routeName, 128 | ) { 129 | composable(route = Route.MainTab.routeName) { 130 | val mainNestedNavController = rememberNavController() 131 | screensNavigator.setNestedNavController(mainNestedNavController) 132 | NavHost(navController = mainNestedNavController, startDestination = Route.QuestionsListScreen.routeName) { 133 | composable(route = Route.QuestionsListScreen.routeName) { 134 | QuestionsListScreen( 135 | onQuestionClicked = { clickedQuestionId, clickedQuestionTitle -> 136 | screensNavigator.toRoute(Route.QuestionDetailsScreen(clickedQuestionId, clickedQuestionTitle)) 137 | }, 138 | ) 139 | } 140 | composable(route = Route.QuestionDetailsScreen().routeName) { 141 | val questionId = remember { 142 | (screensNavigator.currentRoute.value as Route.QuestionDetailsScreen).questionId 143 | } 144 | QuestionDetailsScreen( 145 | questionId = questionId, 146 | onError = { 147 | screensNavigator.navigateBack() 148 | } 149 | ) 150 | } 151 | } 152 | } 153 | 154 | composable(route = Route.FavoritesTab.routeName) { 155 | val favoritesNestedNavController = rememberNavController() 156 | screensNavigator.setNestedNavController(favoritesNestedNavController) 157 | NavHost(navController = favoritesNestedNavController, startDestination = Route.FavoriteQuestionsScreen.routeName) { 158 | composable(route = Route.FavoriteQuestionsScreen.routeName) { 159 | FavoriteQuestionsScreen( 160 | onQuestionClicked = { favoriteQuestionId, favoriteQuestionTitle -> 161 | screensNavigator.toRoute(Route.QuestionDetailsScreen(favoriteQuestionId, favoriteQuestionTitle)) 162 | } 163 | ) 164 | } 165 | composable(route = Route.QuestionDetailsScreen().routeName) { 166 | val questionId = remember { 167 | (screensNavigator.currentRoute.value as Route.QuestionDetailsScreen).questionId 168 | } 169 | QuestionDetailsScreen( 170 | questionId = questionId, 171 | onError = { 172 | screensNavigator.navigateBack() 173 | } 174 | ) 175 | } 176 | } 177 | } 178 | } 179 | } 180 | } 181 | 182 | -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.main 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.techyourchance.architecture.question.usecases.ObserveFavoriteQuestionUseCase 5 | import com.techyourchance.architecture.question.usecases.ToggleFavoriteQuestionUseCase 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.Flow 8 | import javax.inject.Inject 9 | 10 | @HiltViewModel 11 | class MainViewModel @Inject constructor( 12 | private val observeFavoriteQuestionUseCase: ObserveFavoriteQuestionUseCase, 13 | private val toggleFavoriteQuestionUseCase: ToggleFavoriteQuestionUseCase, 14 | ): ViewModel() { 15 | 16 | fun isQuestionFavorite(questionId: String): Flow { 17 | return observeFavoriteQuestionUseCase.isQuestionFavorite(questionId) 18 | } 19 | 20 | fun toggleFavoriteQuestion(questionId: String, questionTitle: String) { 21 | toggleFavoriteQuestionUseCase.toggleFavoriteQuestion(questionId, questionTitle) 22 | } 23 | 24 | 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/main/MyBottomTabsBar.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.main 2 | 3 | import androidx.compose.material3.Icon 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.Text 9 | import androidx.compose.runtime.Composable 10 | import com.techyourchance.architecture.screens.BottomTab 11 | 12 | 13 | @Composable 14 | fun MyBottomTabsBar( 15 | bottomTabs: List, 16 | currentBottomTab: BottomTab?, 17 | onTabClicked: (BottomTab) -> Unit, 18 | ) { 19 | NavigationBar { 20 | bottomTabs.forEachIndexed { _, bottomTab -> 21 | NavigationBarItem( 22 | alwaysShowLabel = true, 23 | icon = { Icon(bottomTab.icon!!, contentDescription = bottomTab.title) }, 24 | label = { Text(bottomTab.title) }, 25 | selected = currentBottomTab == bottomTab, 26 | onClick = { onTabClicked(bottomTab) }, 27 | colors = NavigationBarItemDefaults.colors( 28 | indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) 29 | ) 30 | ) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/main/MyTopBar.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.main 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 6 | import androidx.compose.material.icons.filled.Favorite 7 | import androidx.compose.material.icons.filled.FavoriteBorder 8 | import androidx.compose.material3.CenterAlignedTopAppBar 9 | import androidx.compose.material3.ExperimentalMaterial3Api 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.material3.IconButton 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Text 14 | import androidx.compose.material3.TopAppBarDefaults 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.res.stringResource 19 | import com.techyourchance.architecture.R 20 | 21 | 22 | @OptIn(ExperimentalMaterial3Api::class) 23 | @Composable 24 | fun MyTopAppBar( 25 | isRootRoute: Boolean, 26 | isFavoriteQuestion: Boolean, 27 | isShowFavoriteButton: Boolean, 28 | questionIdAndTitle: Pair, 29 | onToggleFavoriteClicked: () -> Unit, 30 | onBackClicked: () -> Unit, 31 | ) { 32 | CenterAlignedTopAppBar( 33 | title = { 34 | Row ( 35 | verticalAlignment = Alignment.CenterVertically 36 | ){ 37 | 38 | Text( 39 | text = stringResource(id = R.string.app_name), 40 | color = Color.White 41 | ) 42 | } 43 | }, 44 | 45 | navigationIcon = { 46 | if (!isRootRoute) { 47 | IconButton( 48 | onClick = onBackClicked 49 | ) { 50 | Icon( 51 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 52 | tint = Color.White, 53 | contentDescription = "menu items" 54 | ) 55 | } 56 | } 57 | }, 58 | 59 | actions = { 60 | if (isShowFavoriteButton) { 61 | IconButton( 62 | onClick = onToggleFavoriteClicked 63 | ) { 64 | Icon( 65 | imageVector = if (isFavoriteQuestion) { 66 | Icons.Filled.Favorite 67 | } else { 68 | Icons.Filled.FavoriteBorder 69 | }, 70 | contentDescription = "Favorite", 71 | tint = Color.White 72 | ) 73 | } 74 | } 75 | }, 76 | colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.primary), 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/questiondetails/QuestionDetailsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.questiondetails 2 | 3 | import android.text.Html 4 | import android.text.Spanned 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.verticalScroll 11 | import androidx.compose.material3.AlertDialog 12 | import androidx.compose.material3.Button 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.LaunchedEffect 17 | import androidx.compose.runtime.collectAsState 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.text.font.FontWeight 21 | import androidx.compose.ui.unit.dp 22 | import androidx.hilt.navigation.compose.hiltViewModel 23 | 24 | 25 | @Composable 26 | fun QuestionDetailsScreen( 27 | questionId: String, 28 | viewModel: QuestionDetailsViewModel = hiltViewModel(), 29 | onError: () -> Unit, 30 | ) { 31 | 32 | val questionDetailsResult = viewModel.questionDetails.collectAsState().value 33 | 34 | LaunchedEffect(questionId) { 35 | viewModel.observeQuestionDetails(questionId) 36 | } 37 | 38 | val scrollState = rememberScrollState() 39 | 40 | if (questionDetailsResult is QuestionDetailsViewModel.QuestionDetailsResult.Success) { 41 | Column( 42 | modifier = Modifier 43 | .verticalScroll(scrollState), 44 | horizontalAlignment = Alignment.CenterHorizontally, 45 | verticalArrangement = Arrangement.Center, 46 | ) { 47 | 48 | Spacer(modifier = Modifier.height(20.dp)) 49 | 50 | val spannedTitle: Spanned = Html.fromHtml(questionDetailsResult.questionDetails.title, Html.FROM_HTML_MODE_LEGACY) 51 | Text( 52 | text = spannedTitle.toString(), 53 | style = MaterialTheme.typography.headlineMedium.copy( 54 | fontWeight = FontWeight.Bold 55 | ) 56 | ) 57 | 58 | Spacer(modifier = Modifier.height(20.dp)) 59 | 60 | val spannedBody: Spanned = Html.fromHtml(questionDetailsResult.questionDetails.body, Html.FROM_HTML_MODE_LEGACY) 61 | Text( 62 | text = spannedBody.toString(), 63 | style = MaterialTheme.typography.bodyLarge 64 | ) 65 | } 66 | } 67 | 68 | if (questionDetailsResult is QuestionDetailsViewModel.QuestionDetailsResult.Error) { 69 | AlertDialog( 70 | text = { 71 | Text("Ooops, something went wrong") 72 | }, 73 | onDismissRequest = onError, 74 | confirmButton = { 75 | Button( 76 | onClick = onError 77 | 78 | ) { 79 | Text("OK") 80 | } 81 | }, 82 | ) 83 | } 84 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/questiondetails/QuestionDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.questiondetails 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.ViewModel 5 | import com.techyourchance.architecture.question.usecases.ObserveQuestionDetailsUseCase 6 | import com.techyourchance.architecture.question.QuestionWithBody 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.withContext 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class QuestionDetailsViewModel @Inject constructor( 15 | private val observeQuestionDetailsUseCase: ObserveQuestionDetailsUseCase, 16 | ): ViewModel() { 17 | 18 | sealed class QuestionDetailsResult { 19 | data object None: QuestionDetailsResult() 20 | data class Success(val questionDetails: QuestionWithBody): QuestionDetailsResult() 21 | data object Error: QuestionDetailsResult() 22 | } 23 | 24 | val questionDetails = MutableStateFlow(QuestionDetailsResult.None) 25 | 26 | suspend fun observeQuestionDetails(questionId: String) { 27 | withContext(Dispatchers.Main.immediate) { 28 | observeQuestionDetailsUseCase.observeQuestionDetails(questionId).collect { useCaseResult -> 29 | val result = when (useCaseResult) { 30 | is ObserveQuestionDetailsUseCase.QuestionDetailsResult.Success -> { 31 | QuestionDetailsResult.Success(useCaseResult.questionDetails) 32 | } 33 | is ObserveQuestionDetailsUseCase.QuestionDetailsResult.Error -> { 34 | QuestionDetailsResult.Error 35 | } 36 | else -> { 37 | QuestionDetailsResult.None 38 | } 39 | } 40 | questionDetails.value = result 41 | } 42 | } 43 | } 44 | 45 | override fun onCleared() { 46 | super.onCleared() 47 | Log.i("QuestionDetailsViewModel", "onCleared()") 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/questionslist/QuestionsListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.questionslist 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.material3.ExperimentalMaterial3Api 10 | import androidx.compose.material3.HorizontalDivider 11 | import androidx.compose.material3.pulltorefresh.PullToRefreshContainer 12 | import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.runtime.collectAsState 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.input.nestedscroll.nestedScroll 19 | import androidx.compose.ui.unit.dp 20 | import androidx.hilt.navigation.compose.hiltViewModel 21 | import com.techyourchance.architecture.screens.common.composables.QuestionItem 22 | 23 | 24 | @OptIn(ExperimentalMaterial3Api::class) 25 | @Composable 26 | fun QuestionsListScreen( 27 | viewModel: QuestionsListViewModel = hiltViewModel(), 28 | onQuestionClicked: (String, String) -> Unit, 29 | ) { 30 | 31 | val questions = viewModel.lastActiveQuestions.collectAsState() 32 | 33 | LaunchedEffect(Unit) { 34 | viewModel.fetchLastActiveQuestions() 35 | } 36 | 37 | val state = rememberPullToRefreshState() 38 | 39 | if (state.isRefreshing) { 40 | LaunchedEffect(Unit) { 41 | viewModel.fetchLastActiveQuestions(forceUpdate = true) 42 | state.endRefresh() 43 | } 44 | } 45 | 46 | Box(Modifier.nestedScroll(state.nestedScrollConnection)) { 47 | 48 | LazyColumn( 49 | modifier = Modifier 50 | .fillMaxSize() 51 | .padding(vertical = 5.dp), 52 | verticalArrangement = Arrangement.spacedBy(20.dp), 53 | contentPadding = PaddingValues(top = 10.dp, bottom = 10.dp) 54 | ) { 55 | items(questions.value.size) { index -> 56 | val question = questions.value[index] 57 | QuestionItem( 58 | questionId = question.id, 59 | questionTitle = question.title, 60 | onQuestionClicked = { onQuestionClicked(question.id, question.title) }, 61 | ) 62 | if (index < questions.value.size - 1) { 63 | HorizontalDivider( 64 | modifier = Modifier 65 | .padding(top = 20.dp), 66 | thickness = 2.dp 67 | ) 68 | } 69 | } 70 | } 71 | 72 | PullToRefreshContainer( 73 | modifier = Modifier.align(Alignment.TopCenter), 74 | state = state, 75 | ) 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /app/src/main/java/com/techyourchance/architecture/screens/questionslist/QuestionsListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.techyourchance.architecture.screens.questionslist 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.ViewModel 5 | import com.techyourchance.architecture.question.usecases.FetchQuestionsListUseCase 6 | import com.techyourchance.architecture.question.Question 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.withContext 11 | import javax.inject.Inject 12 | 13 | 14 | @HiltViewModel 15 | class QuestionsListViewModel @Inject constructor( 16 | private val fetchQuestionsListUseCase: FetchQuestionsListUseCase 17 | ): ViewModel() { 18 | 19 | val lastActiveQuestions = MutableStateFlow>(emptyList()) 20 | 21 | suspend fun fetchLastActiveQuestions(forceUpdate: Boolean = false) { 22 | withContext(Dispatchers.Main.immediate) { 23 | if (forceUpdate || lastActiveQuestions.value.isEmpty()) { 24 | lastActiveQuestions.value = fetchQuestionsListUseCase.fetchLastActiveQuestions() 25 | } 26 | } 27 | } 28 | 29 | override fun onCleared() { 30 | super.onCleared() 31 | Log.i("QuestionsListViewModel", "onCleared()") 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /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/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /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/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Android Architecture 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |