├── .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 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
3 | plugins {
4 | alias(libs.plugins.androidApplication) apply false
5 | alias(libs.plugins.kotlinAndroid) apply false
6 | alias(libs.plugins.ksp) apply false
7 | alias(libs.plugins.hilt) apply false
8 |
9 |
10 | }
11 | true // Needed to make the Suppress annotation work for the plugins block
--------------------------------------------------------------------------------
/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 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.2.2"
3 | kotlin = "1.9.0"
4 | ksp = "1.9.0-1.0.13"
5 | core-ktx = "1.12.0"
6 | junit = "4.13.2"
7 | androidx-test-ext-junit = "1.1.5"
8 | espresso-core = "3.5.1"
9 | lifecycle-runtime-ktx = "2.7.0"
10 | navigation-compose = "2.7.7"
11 | activity-compose = "1.8.2"
12 | compose-bom = "2024.02.02"
13 | retrofit = "2.9.0"
14 | retrofit-logging-interceptor = "4.12.0"
15 | moshi = "1.14.0"
16 | coil = "2.5.0"
17 | room = "2.6.1"
18 | hilt = "2.51.1"
19 | hilt-navigation-compose = "1.2.0"
20 |
21 | [libraries]
22 | core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
23 | junit = { group = "junit", name = "junit", version.ref = "junit" }
24 | androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
25 | espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
26 | lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
27 | navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
28 | activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
29 | compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
30 | ui = { group = "androidx.compose.ui", name = "ui" }
31 | ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
32 | ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
33 | ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
34 | ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
35 | ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
36 | material3 = { group = "androidx.compose.material3", name = "material3" }
37 | moshi = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
38 | moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
39 | retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
40 | retrofit-moshi-converter = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
41 | retrofit-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "retrofit-logging-interceptor" }
42 | coil = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
43 | room = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
44 | room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
45 | room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
46 | hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
47 | hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
48 | hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
49 |
50 | [plugins]
51 | androidApplication = { id = "com.android.application", version.ref = "agp" }
52 | kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
53 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
54 | hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
55 |
56 |
57 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/techyourchance/android-architecture-course/8857dc746ea3ba5c096ed8f8035c3c0c5406ee76/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Mar 30 12:23:42 IDT 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 |
16 | rootProject.name = "android-architecture"
17 | include(":app")
18 |
--------------------------------------------------------------------------------