diff --git a/.gitignore b/.gitignore
index 3283160f..4c2a3692 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,10 @@ out/
# Release artifacts (uncomment or adjust as needed)
app/release/*
release/
+signing.properties
+github.properties
+app/google-services.json
+/app/src/main/play/keys/*
###############################################################################
# LOCAL CONFIG / ENV FILES
@@ -97,14 +101,4 @@ lint/tmp/
###############################################################################
# KOTLIN
###############################################################################
-.kotlin
-
-###############################################################################
-# API KEYS
-###############################################################################
-/apikeys.properties
-
-###############################################################################
-# OTHER DEV FILES
-###############################################################################
-/local/api_lesson.json
\ No newline at end of file
+.kotlin
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index 6af3b540..3f3a58e4 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -13,6 +13,9 @@
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 7b3006b6..02c4aa5e 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -6,14 +6,12 @@
-
-
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 8ff57d29..0a3c95f5 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-
@@ -172,6 +171,7 @@
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3d3cdf8f..59216037 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,81 +1,146 @@
-import org.jetbrains.kotlin.konan.properties.Properties
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
+import java.util.Properties
+import kotlin.toString
plugins {
- alias(notation = libs.plugins.androidApplication)
- alias(notation = libs.plugins.jetbrainsKotlinAndroid)
- alias(notation = libs.plugins.jetbrainsKotlinParcelize)
+ alias(notation = libs.plugins.android.application)
+ alias(notation = libs.plugins.kotlin.compose)
alias(notation = libs.plugins.kotlin.serialization)
- alias(notation = libs.plugins.googlePlayServices)
- alias(notation = libs.plugins.googleFirebase)
- alias(notation = libs.plugins.compose.compiler)
- alias(notation = libs.plugins.devToolsKsp)
+ alias(notation = libs.plugins.kotlin.parcelize)
+ alias(notation = libs.plugins.google.devtools.ksp)
+ alias(notation = libs.plugins.google.mobile.services) apply false
+ alias(notation = libs.plugins.firebase.crashlytics) apply false
+ alias(notation = libs.plugins.firebase.performance) apply false
alias(notation = libs.plugins.about.libraries)
+ alias(notation = libs.plugins.mannodermaus.android.junit5)
+}
+
+val hasGoogleServicesConfig: Boolean = listOf(
+ "google-services.json",
+ "src/debug/google-services.json",
+ "src/release/google-services.json",
+).any { path -> file(path).exists() }
+
+if (hasGoogleServicesConfig) {
+ apply(plugin = libs.plugins.google.mobile.services.get().pluginId)
+ apply(plugin = libs.plugins.firebase.crashlytics.get().pluginId)
+ apply(plugin = libs.plugins.firebase.performance.get().pluginId)
}
android {
- compileSdk = 35
namespace = "com.d4rk.androidtutorials"
+ compileSdk {
+ version = release(36) {
+ minorApiLevel = 1
+ }
+ }
defaultConfig {
applicationId = "com.d4rk.androidtutorials"
- minSdk = 23
- targetSdk = 35
- versionCode = 118
- versionName = "1.2.3"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 122
+ versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@Suppress("UnstableApiUsage")
androidResources.localeFilters += listOf(
- "en" ,
- "bg-rBG" ,
- "de-rDE" ,
- "es-rGQ" ,
- "fr-rFR" ,
- "hi-rIN" ,
- "hu-rHU" ,
- "in-rID" ,
- "it-rIT" ,
- "ja-rJP" ,
- "pl-rPL" ,
- "pt-rBR" ,
- "ro-rRO" ,
- "ru-rRU" ,
- "sv-rSE" ,
- "th-rTH" ,
- "tr-rTR" ,
- "uk-rUA" ,
- "zh-rTW" ,
+ "ar-rEG",
+ "bg-rBG",
+ "bn-rBD",
+ "de-rDE",
+ "en",
+ "es-rGQ",
+ "es-rMX",
+ "fil-rPH",
+ "fr-rFR",
+ "hi-rIN",
+ "hu-rHU",
+ "in-rID",
+ "it-rIT",
+ "ja-rJP",
+ "ko-rKR",
+ "pl-rPL",
+ "pt-rBR",
+ "ro-rRO",
+ "ru-rRU",
+ "sv-rSE",
+ "th-rTH",
+ "tr-rTR",
+ "uk-rUA",
+ "ur-rPK",
+ "vi-rVN",
+ "zh-rTW"
)
-
vectorDrawables {
useSupportLibrary = true
}
+ multiDexEnabled = true
+
+ val githubProps = Properties()
+ val githubFile = rootProject.file("github.properties")
+ val githubToken = if (githubFile.exists()) {
+ githubProps.load(githubFile.inputStream())
+ githubProps["GITHUB_TOKEN"].toString()
+ } else {
+ ""
+ }
+ buildConfigField("String", "GITHUB_TOKEN", "\"$githubToken\"")
+ buildConfigField("String", "FAQ_PRODUCT_ID", "\"com.d4rk.androidtutorials\"")
}
- buildTypes {
- release {
- isDebuggable = false
- }
+ signingConfigs {
+ create("release")
- debug {
- isDebuggable = true
+ val signingProps = Properties()
+ val signingFile = rootProject.file("signing.properties")
+
+ if (signingFile.exists()) {
+ signingProps.load(signingFile.inputStream())
+
+ signingConfigs.getByName("release").apply {
+ storeFile = file(signingProps["STORE_FILE"].toString())
+ storePassword = signingProps["STORE_PASSWORD"].toString()
+ keyAlias = signingProps["KEY_ALIAS"].toString()
+ keyPassword = signingProps["KEY_PASSWORD"].toString()
+ }
+ } else {
+ android.buildTypes.getByName("release").signingConfig = null
}
}
- buildTypes.forEach { buildType ->
- val keystoreFile : File = project.rootProject.file("apikeys.properties")
- val properties : Properties = Properties()
- properties.load(keystoreFile.inputStream())
- val apiKey : String = properties.getProperty("API_KEY") ?: ""
-
- with(buildType) {
- multiDexEnabled = true
- isMinifyEnabled = false
- isShrinkResources = false
- buildConfigField(type = "String" , name = "API_KEY" , value = apiKey)
- proguardFiles(
- getDefaultProguardFile(name = "proguard-android-optimize.txt") ,
- "proguard-rules.pro"
- )
+ buildTypes {
+ release {
+ val signingFile = rootProject.file("signing.properties")
+ signingConfig = if (signingFile.exists()) {
+ signingConfigs.getByName("release")
+ } else {
+ null
+ }
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile(name = "proguard-android-optimize.txt"), "proguard-rules.pro")
+ if (hasGoogleServicesConfig) {
+ configure {
+ mappingFileUploadEnabled = true
+ }
+ }
}
}
@@ -84,10 +149,6 @@ android {
targetCompatibility = JavaVersion.VERSION_21
}
- kotlinOptions {
- jvmTarget = "21"
- }
-
buildFeatures {
buildConfig = true
compose = true
@@ -109,21 +170,26 @@ android {
dependencies {
// App Core
- implementation(dependencyNotation = "com.github.D4rK7355608:AppToolkit:0.0.55") {
+ implementation(dependencyNotation = "com.github.MihaiCristianCondrea:App-Toolkit-for-Android:2.0.8") {
isTransitive = true
}
// Code view
implementation(dependencyNotation = libs.compose.code.editor)
- // Coil
- implementation(dependencyNotation = libs.coil.svg)
-
- // Google
- implementation(dependencyNotation = libs.generativeai)
+ // Firebase AI Logic
+ implementation(dependencyNotation = libs.firebase.ai)
// KSP
ksp(dependencyNotation = libs.androidx.room.compiler)
implementation(dependencyNotation = libs.androidx.room.ktx)
implementation(dependencyNotation = libs.androidx.room.runtime)
+
+ // Unit Tests
+ testImplementation(dependencyNotation = libs.bundles.unitTest)
+ testRuntimeOnly(dependencyNotation = libs.bundles.unitTestRuntime)
+
+ // Instrumentation Tests
+ androidTestImplementation(dependencyNotation = libs.bundles.instrumentationTest)
+ debugImplementation(dependencyNotation = libs.androidx.compose.ui.test.manifest)
}
\ No newline at end of file
diff --git a/app/google-services.json b/app/google-services.json
deleted file mode 100644
index 30cf503c..00000000
--- a/app/google-services.json
+++ /dev/null
@@ -1,305 +0,0 @@
-{
- "project_info": {
- "project_number": "289497233341",
- "project_id": "d4rk-apps",
- "storage_bucket": "d4rk-apps.firebasestorage.app"
- },
- "client": [
- {
- "client_info": {
- "mobilesdk_app_id": "1:289497233341:android:1ff5f02afe515f082c84d4",
- "android_client_info": {
- "package_name": "com.d4rk.android.apps.doodle"
- }
- },
- "oauth_client": [
- {
- "client_id": "289497233341-odrjgj092f59d4sfgvtvnu41834f92sg.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyB4pDjHrxVmwSpSjuYc6Qi2MOxb6IsCtIQ"
- }
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "289497233341-33ic66r4d80qg7f793jgve1gvrlanrkk.apps.googleusercontent.com",
- "client_type": 3
- }
- ]
- }
- }
- },
- {
- "client_info": {
- "mobilesdk_app_id": "1:289497233341:android:9bf03f715f3f32962c84d4",
- "android_client_info": {
- "package_name": "com.d4rk.android.apps.weddix"
- }
- },
- "oauth_client": [
- {
- "client_id": "289497233341-ghv2ahfgajpdho0v40ttf81pplkn3omm.apps.googleusercontent.com",
- "client_type": 1,
- "android_info": {
- "package_name": "com.d4rk.android.apps.weddix",
- "certificate_hash": "7075c6db7f8d071af62759636e040b9feca3cb60"
- }
- },
- {
- "client_id": "289497233341-odrjgj092f59d4sfgvtvnu41834f92sg.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyB4pDjHrxVmwSpSjuYc6Qi2MOxb6IsCtIQ"
- }
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "289497233341-33ic66r4d80qg7f793jgve1gvrlanrkk.apps.googleusercontent.com",
- "client_type": 3
- }
- ]
- }
- },
- "admob_app_id": "ca-app-pub-5294151573817700~5131199608"
- },
- {
- "client_info": {
- "mobilesdk_app_id": "1:289497233341:android:22b03d8c9ef12bd02c84d4",
- "android_client_info": {
- "package_name": "com.d4rk.androidtutorials"
- }
- },
- "oauth_client": [
- {
- "client_id": "289497233341-mok0cv2g375380sid3f5vi5kdi5kpvq8.apps.googleusercontent.com",
- "client_type": 1,
- "android_info": {
- "package_name": "com.d4rk.androidtutorials",
- "certificate_hash": "1cbe138311e1e9bad0c6a6cf5c48cbf68f6552f4"
- }
- },
- {
- "client_id": "289497233341-odrjgj092f59d4sfgvtvnu41834f92sg.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyB4pDjHrxVmwSpSjuYc6Qi2MOxb6IsCtIQ"
- }
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "289497233341-33ic66r4d80qg7f793jgve1gvrlanrkk.apps.googleusercontent.com",
- "client_type": 3
- }
- ]
- }
- },
- "admob_app_id": "ca-app-pub-5294151573817700~4228267194"
- },
- {
- "client_info": {
- "mobilesdk_app_id": "1:289497233341:android:75b687bb4a06c7a82c84d4",
- "android_client_info": {
- "package_name": "com.d4rk.androidtutorials.java"
- }
- },
- "oauth_client": [
- {
- "client_id": "289497233341-scnhsgudafpiqohjs3bstgnj87q83i6m.apps.googleusercontent.com",
- "client_type": 1,
- "android_info": {
- "package_name": "com.d4rk.androidtutorials.java",
- "certificate_hash": "ea62af86b5902cb2614d3166298af03d5a8eeb95"
- }
- },
- {
- "client_id": "289497233341-odrjgj092f59d4sfgvtvnu41834f92sg.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyB4pDjHrxVmwSpSjuYc6Qi2MOxb6IsCtIQ"
- }
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "289497233341-33ic66r4d80qg7f793jgve1gvrlanrkk.apps.googleusercontent.com",
- "client_type": 3
- }
- ]
- }
- },
- "admob_app_id": "ca-app-pub-5294151573817700~1436412543"
- },
- {
- "client_info": {
- "mobilesdk_app_id": "1:289497233341:android:b6cda4b7325bf96e2c84d4",
- "android_client_info": {
- "package_name": "com.d4rk.cartcalculator"
- }
- },
- "oauth_client": [
- {
- "client_id": "289497233341-i18r09q42filcjjgbap2f9dq7j1amk7g.apps.googleusercontent.com",
- "client_type": 1,
- "android_info": {
- "package_name": "com.d4rk.cartcalculator",
- "certificate_hash": "2897a51ae3adc79e67a100ddbf4f56809248e76a"
- }
- },
- {
- "client_id": "289497233341-odrjgj092f59d4sfgvtvnu41834f92sg.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyB4pDjHrxVmwSpSjuYc6Qi2MOxb6IsCtIQ"
- }
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "289497233341-33ic66r4d80qg7f793jgve1gvrlanrkk.apps.googleusercontent.com",
- "client_type": 3
- }
- ]
- }
- },
- "admob_app_id": "ca-app-pub-5294151573817700~1050558256"
- },
- {
- "client_info": {
- "mobilesdk_app_id": "1:289497233341:android:2f1f51f2f7f61cd42c84d4",
- "android_client_info": {
- "package_name": "com.d4rk.cleaner"
- }
- },
- "oauth_client": [
- {
- "client_id": "289497233341-kb98k2na83ts8v5o2h91l5tr9lk9eja9.apps.googleusercontent.com",
- "client_type": 1,
- "android_info": {
- "package_name": "com.d4rk.cleaner",
- "certificate_hash": "f48a35a67ae2034d77c8eb2042c1d80710e78120"
- }
- },
- {
- "client_id": "289497233341-odrjgj092f59d4sfgvtvnu41834f92sg.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyB4pDjHrxVmwSpSjuYc6Qi2MOxb6IsCtIQ"
- }
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "289497233341-33ic66r4d80qg7f793jgve1gvrlanrkk.apps.googleusercontent.com",
- "client_type": 3
- }
- ]
- }
- },
- "admob_app_id": "ca-app-pub-5294151573817700~3549716864"
- },
- {
- "client_info": {
- "mobilesdk_app_id": "1:289497233341:android:4d85e23dca60f0192c84d4",
- "android_client_info": {
- "package_name": "com.d4rk.lowbrightness"
- }
- },
- "oauth_client": [
- {
- "client_id": "289497233341-pnuduos4p4do7ut8re4bn7bu5n1nvnnq.apps.googleusercontent.com",
- "client_type": 1,
- "android_info": {
- "package_name": "com.d4rk.lowbrightness",
- "certificate_hash": "6f78b3fb143b7cb126fcde10311f4b70eb9737b3"
- }
- },
- {
- "client_id": "289497233341-odrjgj092f59d4sfgvtvnu41834f92sg.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyB4pDjHrxVmwSpSjuYc6Qi2MOxb6IsCtIQ"
- }
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "289497233341-33ic66r4d80qg7f793jgve1gvrlanrkk.apps.googleusercontent.com",
- "client_type": 3
- }
- ]
- }
- },
- "admob_app_id": "ca-app-pub-5294151573817700~8302025955"
- },
- {
- "client_info": {
- "mobilesdk_app_id": "1:289497233341:android:bf335a6184bda9112c84d4",
- "android_client_info": {
- "package_name": "com.d4rk.netprobe"
- }
- },
- "oauth_client": [
- {
- "client_id": "289497233341-mtufr5ekp4vl2428uho0rurvpl1nikpa.apps.googleusercontent.com",
- "client_type": 1,
- "android_info": {
- "package_name": "com.d4rk.netprobe",
- "certificate_hash": "751dc4555f286ca519c8ca138a3333c21f844a9f"
- }
- },
- {
- "client_id": "289497233341-odrjgj092f59d4sfgvtvnu41834f92sg.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyB4pDjHrxVmwSpSjuYc6Qi2MOxb6IsCtIQ"
- }
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "289497233341-33ic66r4d80qg7f793jgve1gvrlanrkk.apps.googleusercontent.com",
- "client_type": 3
- }
- ]
- }
- },
- "admob_app_id": "ca-app-pub-5294151573817700~3749243285"
- }
- ],
- "configuration_version": "1"
-}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb434..1bd4c5f9 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,21 +1,25 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
+##############################################
+# App - ProGuard / R8 rules
+##############################################
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
+# Crashlytics: keep line numbers for readable stacktraces
+-keepattributes SourceFile,LineNumberTable
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
+# Kotlinx Serialization
+-keepattributes *Annotation*,InnerClasses,EnclosingMethod
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+-keep @kotlinx.serialization.Serializable class com.d4rk.android.apps.** { *; }
+
+-keepclassmembers class com.d4rk.android.apps.** {
+ public static ** Companion;
+}
+
+-keepclassmembers class **$Companion {
+ public kotlinx.serialization.KSerializer serializer(...);
+}
+
+-keepclassmembers class **$$serializer { *; }
+
+# Optional noise suppression (safe)
+-dontwarn kotlinx.coroutines.**
+-dontwarn io.ktor.**
diff --git a/app/schemas/com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase/1.json b/app/schemas/com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase/1.json
new file mode 100644
index 00000000..15c1bbe8
--- /dev/null
+++ b/app/schemas/com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase/1.json
@@ -0,0 +1,35 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "favorites_schema_v1",
+ "entities": [
+ {
+ "tableName": "Favorite Lessons",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lessonId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `type` TEXT NOT NULL, `tags` TEXT NOT NULL, `bannerImageUrl` TEXT NOT NULL, `squareImageUrl` TEXT NOT NULL, `deepLinkPath` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, PRIMARY KEY(`lessonId`))",
+ "fields": [
+ {"fieldPath": "lessonId", "columnName": "lessonId", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "tags", "columnName": "tags", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "bannerImageUrl", "columnName": "bannerImageUrl", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "squareImageUrl", "columnName": "squareImageUrl", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "deepLinkPath", "columnName": "deepLinkPath", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "isFavorite", "columnName": "isFavorite", "affinity": "INTEGER", "notNull": true}
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": ["lessonId"]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'favorites_schema_v1')"
+ ]
+ }
+}
diff --git a/app/schemas/com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase/2.json b/app/schemas/com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase/2.json
new file mode 100644
index 00000000..42d38633
--- /dev/null
+++ b/app/schemas/com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase/2.json
@@ -0,0 +1,35 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 2,
+ "identityHash": "favorites_schema_v2",
+ "entities": [
+ {
+ "tableName": "Favorite Lessons",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lessonId` TEXT NOT NULL, `lessonTitle` TEXT NOT NULL, `lessonDescription` TEXT NOT NULL, `lessonType` TEXT NOT NULL, `lessonTags` TEXT NOT NULL, `thumbnailImageUrl` TEXT NOT NULL, `squareImageUrl` TEXT NOT NULL, `deepLinkPath` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, PRIMARY KEY(`lessonId`))",
+ "fields": [
+ {"fieldPath": "lessonId", "columnName": "lessonId", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "lessonTitle", "columnName": "lessonTitle", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "lessonDescription", "columnName": "lessonDescription", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "lessonType", "columnName": "lessonType", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "lessonTags", "columnName": "lessonTags", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "thumbnailImageUrl", "columnName": "thumbnailImageUrl", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "squareImageUrl", "columnName": "squareImageUrl", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "deepLinkPath", "columnName": "deepLinkPath", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "isFavorite", "columnName": "isFavorite", "affinity": "INTEGER", "notNull": true}
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": ["lessonId"]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'favorites_schema_v2')"
+ ]
+ }
+}
diff --git a/app/schemas/com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase/3.json b/app/schemas/com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase/3.json
new file mode 100644
index 00000000..680b83f9
--- /dev/null
+++ b/app/schemas/com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase/3.json
@@ -0,0 +1,35 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 3,
+ "identityHash": "favorites_schema_v3",
+ "entities": [
+ {
+ "tableName": "Favorite Lessons",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lessonId` TEXT NOT NULL, `lessonTitle` TEXT NOT NULL, `lessonDescription` TEXT NOT NULL, `lessonType` TEXT NOT NULL, `lessonTags` TEXT NOT NULL, `thumbnailImageUrl` TEXT NOT NULL, `squareImageUrl` TEXT NOT NULL, `deepLinkPath` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, PRIMARY KEY(`lessonId`))",
+ "fields": [
+ {"fieldPath": "lessonId", "columnName": "lessonId", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "lessonTitle", "columnName": "lessonTitle", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "lessonDescription", "columnName": "lessonDescription", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "lessonType", "columnName": "lessonType", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "lessonTags", "columnName": "lessonTags", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "thumbnailImageUrl", "columnName": "thumbnailImageUrl", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "squareImageUrl", "columnName": "squareImageUrl", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "deepLinkPath", "columnName": "deepLinkPath", "affinity": "TEXT", "notNull": true},
+ {"fieldPath": "isFavorite", "columnName": "isFavorite", "affinity": "INTEGER", "notNull": true}
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": ["lessonId"]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'favorites_schema_v3')"
+ ]
+ }
+}
diff --git a/app/src/androidTest/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/FavoritesMigrationTest.kt b/app/src/androidTest/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/FavoritesMigrationTest.kt
new file mode 100644
index 00000000..843caaaf
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/FavoritesMigrationTest.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.local.database
+
+import androidx.room.testing.MigrationTestHelper
+import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.migrations.MIGRATION_1_2
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.migrations.MIGRATION_2_3
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class FavoritesMigrationTest {
+
+ @get:Rule
+ val helper: MigrationTestHelper = MigrationTestHelper(
+ InstrumentationRegistry.getInstrumentation(),
+ FavoritesDatabase::class.java,
+ emptyList(),
+ FrameworkSQLiteOpenHelperFactory(),
+ )
+
+ @Test
+ fun migrate1To3_remapsLegacyColumnsAndPreservesValues() {
+ helper.createDatabase(TEST_DB, 1).apply {
+ execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `Favorite Lessons` (
+ `lessonId` TEXT PRIMARY KEY NOT NULL,
+ `title` TEXT NOT NULL,
+ `description` TEXT NOT NULL,
+ `type` TEXT NOT NULL,
+ `tags` TEXT NOT NULL,
+ `bannerImageUrl` TEXT NOT NULL,
+ `squareImageUrl` TEXT NOT NULL,
+ `deepLinkPath` TEXT NOT NULL,
+ `isFavorite` INTEGER NOT NULL
+ )
+ """.trimIndent(),
+ )
+ execSQL(
+ """
+ INSERT INTO `Favorite Lessons` (
+ `lessonId`, `title`, `description`, `type`, `tags`,
+ `bannerImageUrl`, `squareImageUrl`, `deepLinkPath`, `isFavorite`
+ ) VALUES (
+ 'android-basics',
+ 'Android Basics',
+ 'Learn Android fundamentals',
+ 'BEGINNER',
+ '["android","kotlin"]',
+ 'https://example.com/banner.png',
+ 'https://example.com/square.png',
+ 'lessons/android-basics',
+ 1
+ )
+ """.trimIndent(),
+ )
+ close()
+ }
+
+ helper.runMigrationsAndValidate(TEST_DB, 3, true, MIGRATION_1_2, MIGRATION_2_3).apply {
+ query(
+ """
+ SELECT lessonId, lessonTitle, lessonDescription, lessonType, lessonTags,
+ thumbnailImageUrl, squareImageUrl, deepLinkPath, isFavorite
+ FROM `Favorite Lessons`
+ WHERE lessonId = 'android-basics'
+ """.trimIndent(),
+ ).use { cursor ->
+ assertThat(cursor.moveToFirst()).isTrue()
+ assertThat(cursor.getString(cursor.getColumnIndexOrThrow("lessonTitle"))).isEqualTo("Android Basics")
+ assertThat(cursor.getString(cursor.getColumnIndexOrThrow("lessonDescription"))).isEqualTo("Learn Android fundamentals")
+ assertThat(cursor.getString(cursor.getColumnIndexOrThrow("lessonType"))).isEqualTo("BEGINNER")
+ assertThat(cursor.getString(cursor.getColumnIndexOrThrow("lessonTags"))).isEqualTo("[\"android\",\"kotlin\"]")
+ assertThat(cursor.getString(cursor.getColumnIndexOrThrow("thumbnailImageUrl"))).isEqualTo("https://example.com/banner.png")
+ assertThat(cursor.getString(cursor.getColumnIndexOrThrow("squareImageUrl"))).isEqualTo("https://example.com/square.png")
+ assertThat(cursor.getString(cursor.getColumnIndexOrThrow("deepLinkPath"))).isEqualTo("lessons/android-basics")
+ assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("isFavorite"))).isEqualTo(1)
+ }
+ close()
+ }
+ }
+
+ @Test
+ fun migrate2To3_validatesFinalSchema() {
+ helper.createDatabase(TEST_DB, 2).apply {
+ execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `Favorite Lessons` (
+ `lessonId` TEXT PRIMARY KEY NOT NULL,
+ `lessonTitle` TEXT NOT NULL,
+ `lessonDescription` TEXT NOT NULL,
+ `lessonType` TEXT NOT NULL,
+ `lessonTags` TEXT NOT NULL,
+ `thumbnailImageUrl` TEXT NOT NULL,
+ `squareImageUrl` TEXT NOT NULL,
+ `deepLinkPath` TEXT NOT NULL,
+ `isFavorite` INTEGER NOT NULL
+ )
+ """.trimIndent(),
+ )
+ execSQL(
+ """
+ INSERT INTO `Favorite Lessons` (
+ `lessonId`, `lessonTitle`, `lessonDescription`, `lessonType`, `lessonTags`,
+ `thumbnailImageUrl`, `squareImageUrl`, `deepLinkPath`, `isFavorite`
+ ) VALUES (
+ 'compose-navigation',
+ 'Compose Navigation',
+ 'Navigation with Compose',
+ 'INTERMEDIATE',
+ '["compose","navigation"]',
+ 'https://example.com/nav-banner.png',
+ 'https://example.com/nav-square.png',
+ 'lessons/compose-navigation',
+ 0
+ )
+ """.trimIndent(),
+ )
+ close()
+ }
+
+ helper.runMigrationsAndValidate(TEST_DB, 3, true, MIGRATION_2_3).apply {
+ query("SELECT COUNT(*) FROM `Favorite Lessons` WHERE lessonId = 'compose-navigation'").use { cursor ->
+ assertThat(cursor.moveToFirst()).isTrue()
+ assertThat(cursor.getInt(0)).isEqualTo(1)
+ }
+ close()
+ }
+ }
+
+ companion object {
+ private const val TEST_DB = "favorites-migration-test"
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a44a1794..50bb27b3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,41 +1,37 @@
+
+
-
-
-
-
-
-
-
-
-
+ android:roundIcon="@mipmap/ic_launcher_round">
-
-
@@ -74,80 +65,8 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/AndroidStudioTutorials.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/AndroidStudioTutorials.kt
new file mode 100644
index 00000000..6b4d6fbc
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/AndroidStudioTutorials.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+@file:Suppress("DEPRECATION")
+
+package com.d4rk.androidtutorials
+
+import android.app.Activity
+import android.os.Bundle
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import com.d4rk.android.libs.apptoolkit.app.theme.ui.style.AppThemeConfig
+import com.d4rk.android.libs.apptoolkit.app.theme.ui.style.colors.ColorPalette
+import com.d4rk.android.libs.apptoolkit.app.theme.ui.style.colors.ThemePaletteProvider
+import com.d4rk.android.libs.apptoolkit.core.BaseCoreManager
+import com.d4rk.android.libs.apptoolkit.core.data.local.datastore.CommonDataStore
+import com.d4rk.android.libs.apptoolkit.core.data.remote.ads.AdsCoreManager
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.colorscheme.StaticPaletteIds
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.date.isChristmasSeason
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.date.isHalloweenSeason
+import com.d4rk.androidtutorials.core.di.initializeKoin
+import com.d4rk.androidtutorials.core.utils.constants.ads.AdsConstants
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.supervisorScope
+import org.koin.android.ext.android.getKoin
+import java.time.LocalDate
+import java.time.ZoneId
+
+class AndroidStudioTutorials : BaseCoreManager(), DefaultLifecycleObserver {
+ private var currentActivity: Activity? = null
+
+ private val adsCoreManager: AdsCoreManager by lazy { getKoin().get() }
+
+ override fun onCreate() {
+ initializeKoin(context = this)
+ applyDefaultColorPalette()
+ super.onCreate()
+ registerActivityLifecycleCallbacks(this)
+ ProcessLifecycleOwner.get().lifecycle.addObserver(observer = this)
+ }
+
+ override suspend fun onInitializeApp(): Unit = supervisorScope {
+ listOf(async { initializeAds() }).awaitAll()
+ }
+
+ private suspend fun initializeAds() {
+ adsCoreManager.initializeAds(AdsConstants.APP_OPEN_UNIT_ID)
+ }
+
+ private fun applyDefaultColorPalette() {
+ val colorPalette: ColorPalette = resolveDefaultColorPalette()
+ AppThemeConfig.customLightScheme = colorPalette.lightColorScheme
+ AppThemeConfig.customDarkScheme = colorPalette.darkColorScheme
+ ThemePaletteProvider.defaultPalette = colorPalette
+ }
+
+ private fun resolveDefaultColorPalette(): ColorPalette {
+ val dataStore: CommonDataStore = CommonDataStore.getInstance(context = this)
+
+ val hasInteractedWithSettings: Boolean = runBlocking {
+ dataStore.settingsInteracted.first()
+ }
+
+ if (!hasInteractedWithSettings) {
+ val staticPaletteId: String = runBlocking { dataStore.staticPaletteId.first() }
+ val today: LocalDate = LocalDate.now(ZoneId.systemDefault())
+ val shouldUseSeasonalPalette: Boolean = staticPaletteId == StaticPaletteIds.DEFAULT
+
+ if (shouldUseSeasonalPalette) {
+ return when {
+ today.isHalloweenSeason -> ThemePaletteProvider.paletteById(StaticPaletteIds.HALLOWEEN)
+ today.isChristmasSeason -> ThemePaletteProvider.paletteById(StaticPaletteIds.CHRISTMAS)
+ else -> getKoin().get()
+ }
+ }
+ }
+
+ return getKoin().get()
+ }
+
+ override fun onStart(owner: LifecycleOwner) {
+ currentActivity?.let { adsCoreManager.showAdIfAvailable(it, owner.lifecycleScope) }
+ }
+
+ override fun onResume(owner: LifecycleOwner) {
+ owner.lifecycleScope.launch {
+ billingRepository.processPastPurchases()
+ }
+ }
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
+
+ override fun onActivityStarted(activity: Activity) {
+ currentActivity = activity
+ }
+
+ override fun onActivityStopped(activity: Activity) {
+ if (currentActivity === activity) {
+ currentActivity = null
+ }
+ }
+
+ override fun onActivityDestroyed(activity: Activity) {
+ if (currentActivity === activity) {
+ currentActivity = null
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/mapper/LessonMappers.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/mapper/LessonMappers.kt
new file mode 100644
index 00000000..0dbf2aa3
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/mapper/LessonMappers.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.data.mapper
+
+import com.d4rk.androidtutorials.app.lessons.details.data.remote.model.LessonContentDto
+import com.d4rk.androidtutorials.app.lessons.details.data.remote.model.LessonDto
+import com.d4rk.androidtutorials.app.lessons.details.data.remote.model.LessonResponseDto
+import com.d4rk.androidtutorials.app.lessons.details.domain.model.Lesson
+import com.d4rk.androidtutorials.app.lessons.details.domain.model.LessonContent
+
+internal fun LessonResponseDto.firstLessonOrNull(): Lesson? =
+ data.firstOrNull()?.toDomain()
+
+internal fun LessonDto.toDomain(): Lesson =
+ Lesson(
+ lessonTitle = lessonTitle,
+ writer = writer,
+ lessonContent = lessonContent.map(LessonContentDto::toDomain),
+ )
+
+internal fun LessonContentDto.toDomain(): LessonContent =
+ LessonContent(
+ contentId = contentId,
+ contentType = contentType,
+ contentText = contentText,
+ contentCode = contentCode,
+ programmingLanguage = programmingLanguage,
+ contentImageUrl = contentImageUrl,
+ contentAudioUrl = contentAudioUrl,
+ contentThumbnailUrl = contentThumbnailUrl,
+ contentTitle = contentTitle,
+ contentArtist = contentArtist,
+ contentAlbumTitle = contentAlbumTitle,
+ contentGenre = contentGenre,
+ contentDescription = contentDescription,
+ contentReleaseYear = contentReleaseYear,
+ writer = writer,
+ )
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/LessonRemoteDataSource.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/LessonRemoteDataSource.kt
new file mode 100644
index 00000000..8ffcbadd
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/LessonRemoteDataSource.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.data.remote
+
+import com.d4rk.androidtutorials.app.lessons.details.data.remote.model.LessonResponseDto
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsText
+import kotlinx.serialization.json.Json
+
+class LessonRemoteDataSource(
+ private val client: HttpClient,
+ private val jsonParser: Json,
+) {
+
+ suspend fun fetchLesson(urlString: String): LessonResponseDto {
+ val jsonString = client.get(urlString).bodyAsText()
+ return jsonString
+ .takeUnless { it.isBlank() }
+ ?.let { jsonParser.decodeFromString(it) }
+ ?: LessonResponseDto()
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/model/LessonContentDto.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/model/LessonContentDto.kt
new file mode 100644
index 00000000..ee80f9f1
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/model/LessonContentDto.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LessonContentDto(
+ @SerialName("content_id") val contentId: String = "",
+ @SerialName("content_type") val contentType: String = "",
+ @SerialName("content_text") val contentText: String = "",
+ @SerialName("content_code") val contentCode: String = "",
+ @SerialName("content_code_programming_language") val programmingLanguage: String = "",
+ @SerialName("content_audio_url") val contentAudioUrl: String = "",
+ @SerialName("content_image_url") val contentImageUrl: String = "",
+ @SerialName("content_thumbnail_url") val contentThumbnailUrl: String = "",
+ @SerialName("content_title") val contentTitle: String = "",
+ @SerialName("content_artist") val contentArtist: String = "",
+ @SerialName("content_album_title") val contentAlbumTitle: String = "",
+ @SerialName("content_genre") val contentGenre: String = "",
+ @SerialName("content_description") val contentDescription: String = "",
+ @SerialName("content_release_year") val contentReleaseYear: Int? = null,
+ @SerialName("writer") val writer: String = "",
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/model/LessonDto.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/model/LessonDto.kt
new file mode 100644
index 00000000..bfd70d55
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/model/LessonDto.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LessonDto(
+ @SerialName("lesson_title") val lessonTitle: String = "",
+ @SerialName("writer") val writer: String = "",
+ @SerialName("lesson_content") val lessonContent: List = emptyList(),
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/model/LessonResponseDto.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/model/LessonResponseDto.kt
new file mode 100644
index 00000000..38c08c23
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/model/LessonResponseDto.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LessonResponseDto(
+ @SerialName("data") val data: List = emptyList(),
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/repository/LessonRepositoryImpl.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/repository/LessonRepositoryImpl.kt
new file mode 100644
index 00000000..b8aa3df0
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/data/remote/repository/LessonRepositoryImpl.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.data.remote.repository
+
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.DataState
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.Errors
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.api.ApiEnvironments
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.errors.toError
+import com.d4rk.androidtutorials.BuildConfig
+import com.d4rk.androidtutorials.app.lessons.details.data.mapper.firstLessonOrNull
+import com.d4rk.androidtutorials.app.lessons.details.data.remote.LessonRemoteDataSource
+import com.d4rk.androidtutorials.app.lessons.details.domain.model.Lesson
+import com.d4rk.androidtutorials.app.lessons.details.domain.repository.LessonRepository
+import com.d4rk.androidtutorials.core.domain.model.network.AppErrors
+import com.d4rk.androidtutorials.core.utils.constants.api.AndroidStudioTutorialsApiEndpoints
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+import kotlinx.serialization.SerializationException
+
+class LessonRepositoryImpl(
+ private val remoteDataSource: LessonRemoteDataSource,
+) : LessonRepository {
+
+ override fun getLesson(lessonId: String): Flow> =
+ flow> {
+ if (lessonId.isBlank()) {
+ emit(DataState.Error(error = AppErrors.UseCase.INVALID_LESSON_ID))
+ return@flow
+ }
+
+ val environment =
+ if (BuildConfig.DEBUG) ApiEnvironments.ENV_DEBUG else ApiEnvironments.ENV_RELEASE
+
+ val urlString = AndroidStudioTutorialsApiEndpoints.lessonDetails(
+ environment = environment,
+ lessonId = lessonId,
+ )
+
+ val response = remoteDataSource.fetchLesson(urlString = urlString)
+ val lesson = response.firstLessonOrNull()
+
+ if (lesson == null) {
+ emit(DataState.Error(error = AppErrors.UseCase.LESSON_NOT_FOUND))
+ return@flow
+ }
+
+ emit(DataState.Success(data = lesson))
+ }.catch { throwable ->
+ if (throwable is CancellationException) throw throwable
+
+ val error: AppErrors = when (throwable) {
+ is SerializationException -> AppErrors.UseCase.FAILED_TO_PARSE_LESSON
+ else -> AppErrors.Common(
+ throwable.toError(default = Errors.Network.UNKNOWN)
+ )
+ }
+
+ emit(DataState.Error(error = error))
+ }
+
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/domain/model/Lesson.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/domain/model/Lesson.kt
new file mode 100644
index 00000000..aaa180ef
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/domain/model/Lesson.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.domain.model
+
+import androidx.compose.runtime.Immutable
+
+/**
+ * Represents the business data for a lesson before it is adapted for the UI layer.
+ */
+@Immutable
+data class Lesson(
+ val lessonTitle: String = "",
+ val writer: String = "",
+ val lessonContent: List = emptyList(),
+)
+
+/**
+ * Represents the raw lesson content returned by the backend service.
+ */
+@Immutable
+data class LessonContent(
+ val contentId: String = "",
+ val contentType: String = "",
+ val contentText: String = "",
+ val contentCode: String = "",
+ val programmingLanguage: String = "",
+ val contentImageUrl: String = "",
+ val contentAudioUrl: String = "",
+ val contentThumbnailUrl: String = "",
+ val contentTitle: String = "",
+ val contentArtist: String = "",
+ val contentAlbumTitle: String = "",
+ val contentGenre: String = "",
+ val contentDescription: String = "",
+ val contentReleaseYear: Int? = null,
+ val writer: String = "",
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/domain/repository/LessonRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/domain/repository/LessonRepository.kt
new file mode 100644
index 00000000..d8bc8ce8
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/domain/repository/LessonRepository.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.domain.repository
+
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.DataState
+import com.d4rk.androidtutorials.app.lessons.details.domain.model.Lesson
+import com.d4rk.androidtutorials.core.domain.model.network.AppErrors
+import kotlinx.coroutines.flow.Flow
+
+interface LessonRepository {
+ fun getLesson(lessonId: String): Flow>
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/domain/usecases/GetLessonUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/domain/usecases/GetLessonUseCase.kt
new file mode 100644
index 00000000..171427dd
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/domain/usecases/GetLessonUseCase.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.domain.usecases
+
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.DataState
+import com.d4rk.androidtutorials.app.lessons.details.domain.model.Lesson
+import com.d4rk.androidtutorials.app.lessons.details.domain.repository.LessonRepository
+import com.d4rk.androidtutorials.core.domain.model.network.AppErrors
+import kotlinx.coroutines.flow.Flow
+
+class GetLessonUseCase(
+ private val repository: LessonRepository,
+) {
+ operator fun invoke(lessonId: String): Flow> =
+ repository.getLesson(lessonId)
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/LessonActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/LessonActivity.kt
new file mode 100644
index 00000000..3ede57ef
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/LessonActivity.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.appcompat.app.AppCompatActivity
+import com.d4rk.android.libs.apptoolkit.app.theme.ui.style.AppTheme
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import org.koin.android.ext.android.inject
+import org.koin.core.qualifier.named
+
+class LessonActivity : AppCompatActivity() {
+ private val viewModel: LessonViewModel by inject()
+ private val bannerConfig: AdsConfig by inject()
+ private val mediumRectangleConfig: AdsConfig by inject(named("banner_medium_rectangle"))
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val lessonId = intent?.data?.lastPathSegment
+ lessonId?.let { viewModel.getLesson(it) }
+
+ setContent {
+ AppTheme {
+ LessonRoute(
+ viewModel = viewModel,
+ bannerConfig = bannerConfig,
+ mediumRectangleConfig = mediumRectangleConfig,
+ onBack = { finish() },
+ onPlayClick = {},
+ onSeek = {},
+ onPreparePlayer = { _, _, _ -> },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/LessonScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/LessonScreen.kt
new file mode 100644
index 00000000..5f54fe7e
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/LessonScreen.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.android.libs.apptoolkit.core.ui.views.layouts.LoadingScreen
+import com.d4rk.android.libs.apptoolkit.core.ui.views.layouts.NoDataScreen
+import com.d4rk.android.libs.apptoolkit.core.ui.views.layouts.ScreenStateHandler
+import com.d4rk.android.libs.apptoolkit.core.ui.views.navigation.LargeTopAppBarWithScaffold
+import com.d4rk.androidtutorials.app.lessons.details.ui.state.UiLessonContent
+import com.d4rk.androidtutorials.app.lessons.details.ui.state.UiLessonScreen
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.ListingColumn
+import com.d4rk.androidtutorials.core.utils.constants.ui.lessons.LessonContentTypes
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LessonRoute(
+ viewModel: LessonViewModel,
+ bannerConfig: AdsConfig,
+ mediumRectangleConfig: AdsConfig,
+ onBack: () -> Unit,
+ onPlayClick: () -> Unit,
+ onSeek: (Float) -> Unit,
+ onPreparePlayer: (UiLessonContent, String, Boolean) -> Unit,
+) {
+ val screenState: UiStateScreen by viewModel.uiState.collectAsStateWithLifecycle()
+ val listState = rememberLazyListState()
+ var preparedContentId by rememberSaveable { mutableStateOf(null) }
+ val currentOnPreparePlayer by rememberUpdatedState(newValue = onPreparePlayer)
+ val currentOnPlayClick by rememberUpdatedState(newValue = onPlayClick)
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ DisposableEffect(lifecycleOwner) {
+ val lifecycle = lifecycleOwner.lifecycle
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_DESTROY) {
+ preparedContentId = null
+ }
+ }
+
+ lifecycle.addObserver(observer)
+ onDispose { lifecycle.removeObserver(observer) }
+ }
+
+ val handlePlayClick: () -> Unit = {
+ val lesson = screenState.data
+ val content = lesson?.lessonContent?.firstOrNull {
+ it.contentType == LessonContentTypes.CONTENT_PLAYER
+ }
+
+ when {
+ lesson == null || content == null -> currentOnPlayClick()
+ preparedContentId != content.contentId -> {
+ currentOnPreparePlayer(content, lesson.lessonTitle, true)
+ preparedContentId = content.contentId
+ }
+ else -> currentOnPlayClick()
+ }
+ }
+
+
+ LessonScreen(
+ screenState = screenState,
+ bannerConfig = bannerConfig,
+ mediumRectangleConfig = mediumRectangleConfig,
+ onBack = onBack,
+ onPlayClick = { handlePlayClick() },
+ onSeek = onSeek,
+ listState = listState,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LessonScreen(
+ screenState: UiStateScreen,
+ bannerConfig: AdsConfig,
+ mediumRectangleConfig: AdsConfig,
+ onBack: () -> Unit,
+ onPlayClick: () -> Unit,
+ onSeek: (Float) -> Unit,
+ listState: LazyListState,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier) {
+ LargeTopAppBarWithScaffold(
+ title = screenState.data?.lessonTitle ?: "",
+ onBackClicked = onBack
+ ) { paddingValues ->
+
+ ScreenStateHandler(
+ screenState = screenState,
+ onLoading = {
+ LoadingScreen()
+ },
+ onEmpty = {
+ NoDataScreen()
+ },
+ onSuccess = { lesson ->
+ ListingColumn(
+ paddingValues = paddingValues,
+ listState = listState,
+ lesson = lesson,
+ bannerConfig = bannerConfig,
+ mediumRectangleConfig = mediumRectangleConfig,
+ onPlayClick = onPlayClick,
+ onSeek = onSeek,
+ )
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/LessonViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/LessonViewModel.kt
new file mode 100644
index 00000000..f632b657
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/LessonViewModel.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui
+
+import androidx.lifecycle.viewModelScope
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.DispatcherProvider
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.DataState
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.onFailure
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.onSuccess
+import com.d4rk.android.libs.apptoolkit.core.domain.repository.FirebaseController
+import com.d4rk.android.libs.apptoolkit.core.ui.base.LoggedScreenViewModel
+import com.d4rk.android.libs.apptoolkit.core.ui.state.ScreenState
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiSnackbar
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.android.libs.apptoolkit.core.ui.state.dismissSnackbar
+import com.d4rk.android.libs.apptoolkit.core.ui.state.setLoading
+import com.d4rk.android.libs.apptoolkit.core.ui.state.showSnackbar
+import com.d4rk.android.libs.apptoolkit.core.ui.state.updateState
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.ScreenMessageType
+import com.d4rk.androidtutorials.app.lessons.details.domain.model.Lesson
+import com.d4rk.androidtutorials.app.lessons.details.domain.usecases.GetLessonUseCase
+import com.d4rk.androidtutorials.app.lessons.details.ui.contract.LessonAction
+import com.d4rk.androidtutorials.app.lessons.details.ui.contract.LessonEvent
+import com.d4rk.androidtutorials.app.lessons.details.ui.mappers.toUiModel
+import com.d4rk.androidtutorials.app.lessons.details.ui.state.UiLessonScreen
+import com.d4rk.androidtutorials.core.domain.model.network.AppErrors
+import com.d4rk.androidtutorials.core.utils.extensions.toErrorMessage
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.update
+
+class LessonViewModel(
+ private val getLessonUseCase: GetLessonUseCase,
+ private val dispatchers: DispatcherProvider,
+ firebaseController: FirebaseController,
+) : LoggedScreenViewModel(
+ initialState = UiStateScreen(
+ screenState = ScreenState.IsLoading(),
+ data = UiLessonScreen(),
+ ),
+ firebaseController = firebaseController,
+ screenName = "LessonScreen",
+) {
+
+ private var fetchLessonJob: Job? = null
+
+ fun getLesson(lessonId: String) {
+ onEvent(LessonEvent.FetchLesson(lessonId))
+ }
+
+ override fun handleEvent(event: LessonEvent) {
+ when (event) {
+ is LessonEvent.FetchLesson -> fetchLesson(event.lessonId)
+ LessonEvent.DismissSnackbar -> screenState.dismissSnackbar()
+ }
+ }
+
+ private fun fetchLesson(lessonId: String) {
+ fetchLessonJob = fetchLessonJob.restart {
+ startOperation(
+ action = "fetchLesson",
+ extra = mapOf("lesson_id" to lessonId),
+ )
+
+ getLessonUseCase(lessonId)
+ .flowOn(dispatchers.io)
+ .onStart { screenState.setLoading() }
+ .onEach { result: DataState ->
+ result
+ .onSuccess { lesson: Lesson ->
+ val ui = lesson.toUiModel()
+ val newScreenState: ScreenState =
+ if (ui.lessonContent.isEmpty()) ScreenState.NoData()
+ else ScreenState.Success()
+
+ screenState.update { current ->
+ current.copy(
+ screenState = newScreenState,
+ data = ui,
+ )
+ }
+ }
+ .onFailure { error: AppErrors ->
+ screenState.updateState(ScreenState.NoData())
+ screenState.showSnackbar(
+ UiSnackbar(
+ message = error.toErrorMessage(),
+ isError = true,
+ timeStamp = System.nanoTime(),
+ type = ScreenMessageType.SNACKBAR,
+ )
+ )
+ }
+ }
+ .catchReport(
+ action = "fetchLesson",
+ extra = mapOf("lesson_id" to lessonId),
+ ) { throwable ->
+ // UI fallback for unexpected errors (logging already done by catchReport)
+ screenState.updateState(ScreenState.NoData())
+ }
+ .launchIn(viewModelScope)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/contract/LessonAction.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/contract/LessonAction.kt
new file mode 100644
index 00000000..ccd7e788
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/contract/LessonAction.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.ActionEvent
+
+sealed interface LessonAction : ActionEvent
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/contract/LessonEvent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/contract/LessonEvent.kt
new file mode 100644
index 00000000..f23097e9
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/contract/LessonEvent.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.UiEvent
+
+sealed interface LessonEvent : UiEvent {
+ data class FetchLesson(val lessonId: String) : LessonEvent
+ data object DismissSnackbar : LessonEvent
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/mappers/LessonUiMappers.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/mappers/LessonUiMappers.kt
new file mode 100644
index 00000000..570a8c99
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/mappers/LessonUiMappers.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.mappers
+
+import com.d4rk.androidtutorials.app.lessons.details.domain.model.Lesson
+import com.d4rk.androidtutorials.app.lessons.details.domain.model.LessonContent
+import com.d4rk.androidtutorials.app.lessons.details.ui.state.UiLessonContent
+import com.d4rk.androidtutorials.app.lessons.details.ui.state.UiLessonScreen
+internal fun Lesson.toUiModel(): UiLessonScreen =
+ UiLessonScreen(
+ lessonTitle = lessonTitle,
+ writer = writer,
+ lessonContent = lessonContent.map(LessonContent::toUiModel),
+ )
+
+internal fun LessonContent.toUiModel(): UiLessonContent =
+ UiLessonContent(
+ contentId = contentId,
+ contentType = contentType,
+ contentText = contentText,
+ contentCode = contentCode,
+ programmingLanguage = programmingLanguage,
+ contentImageUrl = contentImageUrl,
+ contentAudioUrl = contentAudioUrl,
+ contentThumbnailUrl = contentThumbnailUrl,
+ contentTitle = contentTitle,
+ contentArtist = contentArtist,
+ contentAlbumTitle = contentAlbumTitle,
+ contentGenre = contentGenre,
+ contentDescription = contentDescription,
+ contentReleaseYear = contentReleaseYear,
+ writer = writer,
+ )
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/state/UiLessonDetailsState.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/state/UiLessonDetailsState.kt
new file mode 100644
index 00000000..d8bf241f
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/state/UiLessonDetailsState.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.state
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class UiLessonScreen(
+ val isPlaying: Boolean = false,
+ val isBuffering: Boolean = false,
+ val playbackPosition: Long = 0L,
+ val playbackDuration: Long = 0L,
+ val hasPlaybackError: Boolean = false,
+ val lessonTitle: String = "",
+ val writer: String = "",
+ val lessonContent: List = emptyList(),
+)
+
+@Immutable
+data class UiLessonContent(
+ val contentId: String = "",
+ val contentType: String = "",
+ val contentText: String = "",
+ val contentCode: String = "",
+ val programmingLanguage: String = "",
+ val contentImageUrl: String = "",
+ val contentAudioUrl: String = "",
+ val contentThumbnailUrl: String = "",
+ val contentTitle: String = "",
+ val contentArtist: String = "",
+ val contentAlbumTitle: String = "",
+ val contentGenre: String = "",
+ val contentDescription: String = "",
+ val contentReleaseYear: Int? = null,
+ val writer: String = "",
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/cards/LessonPlaybackControlsCard.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/cards/LessonPlaybackControlsCard.kt
new file mode 100644
index 00000000..4a7683eb
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/cards/LessonPlaybackControlsCard.kt
@@ -0,0 +1,317 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.cards
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.d4rk.android.libs.apptoolkit.core.ui.views.modifiers.bounceClick
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.SizeConstants
+import com.d4rk.androidtutorials.app.lessons.details.ui.state.UiLessonContent
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.LessonPlaybackUiState
+import java.util.Locale
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun LessonAudioContent(
+ contentItem: UiLessonContent,
+ playbackState: LessonPlaybackUiState,
+ onPlayClick: () -> Unit,
+ onSeek: (Float) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val playButtonCorner by animateFloatAsState(
+ targetValue = if (playbackState.isPlaying || playbackState.isBuffering) 16f else 28f,
+ animationSpec = tween(durationMillis = 200),
+ label = "play_button_corner",
+ )
+ val bottomCornerRadius by animateDpAsState(
+ targetValue = if (playbackState.hasPlaybackError) 4.dp else 24.dp,
+ animationSpec = tween(durationMillis = 200),
+ label = "playback_corner",
+ )
+
+ val sliderMax = remember(playbackState.playbackDuration) {
+ playbackState.playbackDuration.takeIf { it > 0f } ?: 1f
+ }
+ val sliderRange = remember(sliderMax) { 0f..sliderMax }
+
+ val targetSliderValue by remember(playbackState.sliderPosition, sliderRange) {
+ derivedStateOf { playbackState.sliderPosition.coerceIn(sliderRange.start, sliderRange.endInclusive) }
+ }
+
+ var sliderValue by remember(sliderRange.endInclusive) { mutableFloatStateOf(targetSliderValue) }
+ var isDragging by remember(sliderRange.endInclusive) { mutableStateOf(false) }
+
+ val isSliderEnabled by remember(playbackState.playbackDuration, playbackState.hasPlaybackError) {
+ derivedStateOf { playbackState.playbackDuration > 0f && !playbackState.hasPlaybackError }
+ }
+
+ LaunchedEffect(targetSliderValue, sliderRange.endInclusive, isDragging) {
+ if (!isDragging) sliderValue = targetSliderValue
+ }
+
+ LaunchedEffect(isSliderEnabled, targetSliderValue, sliderRange.endInclusive) {
+ if (!isSliderEnabled) {
+ isDragging = false
+ sliderValue = targetSliderValue
+ }
+ }
+
+ Column(modifier = modifier.fillMaxWidth()) {
+ LessonPlaybackControlsCard(
+ contentItem = contentItem,
+ onPlayClick = onPlayClick,
+ sliderValue = sliderValue,
+ sliderMax = sliderMax,
+ sliderRange = sliderRange,
+ isSliderEnabled = isSliderEnabled,
+ isPlaying = playbackState.isPlaying,
+ isBuffering = playbackState.isBuffering,
+ onSliderValueChange = { value ->
+ isDragging = true
+ sliderValue = value
+ },
+ onSliderValueChangeFinished = {
+ val targetValue = sliderValue.coerceIn(sliderRange.start, sliderRange.endInclusive)
+ sliderValue = targetValue
+ onSeek(targetValue)
+ isDragging = false
+ },
+ playButtonCorner = playButtonCorner,
+ bottomCornerRadius = bottomCornerRadius,
+ showError = playbackState.hasPlaybackError,
+ )
+
+ LessonPlaybackErrorMessage(isVisible = playbackState.hasPlaybackError)
+ }
+}
+
+@Composable
+fun LessonPlaybackControlsCard(
+ contentItem: UiLessonContent,
+ onPlayClick: () -> Unit,
+ sliderValue: Float,
+ sliderMax: Float,
+ sliderRange: ClosedFloatingPointRange,
+ isSliderEnabled: Boolean,
+ isPlaying: Boolean,
+ isBuffering: Boolean,
+ onSliderValueChange: (Float) -> Unit,
+ onSliderValueChangeFinished: () -> Unit,
+ playButtonCorner: Float,
+ bottomCornerRadius: Dp,
+ showError: Boolean,
+) {
+ ElevatedCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = SizeConstants.SmallSize, bottom = if (showError) 0.dp else SizeConstants.SmallSize),
+ shape = RoundedCornerShape(
+ topStart = 24.dp,
+ topEnd = 24.dp,
+ bottomStart = bottomCornerRadius,
+ bottomEnd = bottomCornerRadius,
+ ),
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ ) {
+ // NEW: Media Info Row (Thumbnail, Title, Artist)
+ if (contentItem.contentTitle.isNotBlank()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (contentItem.contentThumbnailUrl.isNotBlank()) {
+ AsyncImage(
+ model = contentItem.contentThumbnailUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ contentScale = ContentScale.Crop
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = contentItem.contentTitle,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (contentItem.contentArtist.isNotBlank()) {
+ Text(
+ text = contentItem.contentArtist,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+
+ // Controls Row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ FloatingActionButton(
+ onClick = onPlayClick,
+ modifier = Modifier.bounceClick(),
+ shape = RoundedCornerShape(playButtonCorner.dp),
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ ) {
+ if (isBuffering) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp,
+ color = LocalContentColor.current,
+ )
+ } else {
+ Icon(
+ imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ contentDescription = if (isPlaying) "Pause" else "Play",
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Slider(
+ value = sliderValue,
+ onValueChange = onSliderValueChange,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = isSliderEnabled,
+ valueRange = sliderRange,
+ onValueChangeFinished = onSliderValueChangeFinished,
+ )
+
+ // NEW: Time duration text below slider
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = formatTime(sliderValue),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = formatTime(sliderMax),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun LessonPlaybackErrorMessage(isVisible: Boolean) {
+ AnimatedVisibility(visible = isVisible) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = SizeConstants.SmallSize)
+ .background(
+ color = MaterialTheme.colorScheme.errorContainer,
+ shape = RoundedCornerShape(
+ topStart = 4.dp, topEnd = 4.dp,
+ bottomStart = 24.dp, bottomEnd = 24.dp,
+ )
+ )
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "Playback unavailable. Please check your connection.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ }
+ }
+}
+
+// Helper for formatting M:SS
+private fun formatTime(seconds: Float): String {
+ if (seconds.isNaN() || seconds < 0f) return "0:00"
+ val totalSeconds = seconds.toInt()
+ val m = totalSeconds / 60
+ val s = totalSeconds % 60
+ return String.format(Locale.getDefault(), "%d:%02d", m, s)
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/ListingColumn.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/ListingColumn.kt
new file mode 100644
index 00000000..2e105d57
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/ListingColumn.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import com.d4rk.android.libs.apptoolkit.core.ui.views.modifiers.animateVisibility
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.SizeConstants
+import com.d4rk.androidtutorials.app.lessons.details.ui.state.UiLessonScreen
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items.LessonWriterFooterItem
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.render.LessonSectionRenderer
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ListingColumn(
+ paddingValues: PaddingValues,
+ listState: LazyListState,
+ lesson: UiLessonScreen,
+ bannerConfig: AdsConfig,
+ mediumRectangleConfig: AdsConfig,
+ onPlayClick: () -> Unit,
+ onSeek: (Float) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val playbackState by remember(
+ lesson.playbackPosition,
+ lesson.playbackDuration,
+ lesson.isPlaying,
+ lesson.isBuffering,
+ lesson.hasPlaybackError,
+ ) {
+ derivedStateOf {
+ LessonPlaybackUiState(
+ sliderPosition = lesson.playbackPosition.toSeconds(),
+ playbackDuration = lesson.playbackDuration.toSeconds(),
+ isPlaying = lesson.isPlaying,
+ isBuffering = lesson.isBuffering,
+ hasPlaybackError = lesson.hasPlaybackError,
+ )
+ }
+ }
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ state = listState,
+ contentPadding = PaddingValues(
+ start = SizeConstants.LargeSize,
+ top = paddingValues.calculateTopPadding() + SizeConstants.LargeSize,
+ end = SizeConstants.LargeSize,
+ bottom = paddingValues.calculateBottomPadding() + SizeConstants.LargeSize,
+ ),
+ verticalArrangement = Arrangement.spacedBy(SizeConstants.MediumSize),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ itemsIndexed(
+ items = lesson.lessonContent,
+ key = { index, content ->
+ content.contentId.takeIf { it.isNotBlank() }
+ ?: "${content.contentType}_${content.hashCode()}_$index"
+ },
+ contentType = { _, content -> content.contentType },
+ ) { index, contentItem ->
+ LessonSectionRenderer(
+ content = contentItem,
+ playbackState = playbackState,
+ bannerConfig = bannerConfig,
+ mediumRectangleConfig = mediumRectangleConfig,
+ onPlayClick = onPlayClick,
+ onSeek = onSeek,
+ modifier = Modifier
+ .animateVisibility(index = index)
+ .animateItem(),
+ )
+ }
+
+ if (lesson.writer.isNotBlank()) {
+ item(key = "lesson_writer_footer") {
+ LessonWriterFooterItem(writer = lesson.writer)
+ }
+ }
+ }
+}
+
+@Immutable
+data class LessonPlaybackUiState(
+ val sliderPosition: Float,
+ val playbackDuration: Float,
+ val isPlaying: Boolean,
+ val isBuffering: Boolean,
+ val hasPlaybackError: Boolean,
+)
+
+fun Long.toSeconds(): Float = (toFloat() / 1000f).coerceAtLeast(0f)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonAdBanner.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonAdBanner.kt
new file mode 100644
index 00000000..5d4a2735
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonAdBanner.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import com.d4rk.android.libs.apptoolkit.core.ui.views.ads.AdBanner
+
+@Composable
+fun LessonAdBanner(
+ adsConfig: AdsConfig,
+ modifier: Modifier = Modifier,
+) {
+ AdBanner(
+ modifier = modifier.fillMaxWidth(),
+ adsConfig = adsConfig,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonBodyText.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonBodyText.kt
new file mode 100644
index 00000000..fa938f01
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonBodyText.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun LessonBodyText(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ LessonHtmlText(
+ modifier = modifier,
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonCodeContent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonCodeContent.kt
new file mode 100644
index 00000000..0536a314
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonCodeContent.kt
@@ -0,0 +1,165 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items
+
+import android.content.ClipData
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CopyAll
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.toClipEntry
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.d4rk.androidtutorials.R
+import com.wakaztahir.codeeditor.highlight.model.CodeLang
+import com.wakaztahir.codeeditor.highlight.prettify.PrettifyParser
+import com.wakaztahir.codeeditor.highlight.theme.CodeThemeType
+import com.wakaztahir.codeeditor.highlight.utils.parseCodeAsAnnotatedString
+import kotlinx.coroutines.launch
+
+@Composable
+fun LessonCodeContent(
+ code: String,
+ programmingLanguage: String,
+ modifier: Modifier = Modifier,
+) {
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+
+ val parser = remember { PrettifyParser() }
+ val codeLanguage = remember(programmingLanguage) { programmingLanguage.toCodeLang() }
+ val theme = remember { CodeThemeType.Default.theme() }
+ val annotatedCode = remember(code, codeLanguage, theme) {
+ parseCodeAsAnnotatedString(
+ parser = parser,
+ theme = theme,
+ lang = codeLanguage,
+ code = code,
+ )
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = RoundedCornerShape(12.dp),
+ )
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = programmingLanguage.ifBlank { "code" },
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ AssistChip(
+ onClick = {
+ scope.launch {
+ clipboard.setClipEntry(ClipData.newPlainText(code, code).toClipEntry())
+ }
+ },
+ label = { Text(text = stringResource(android.R.string.copy)) },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Outlined.CopyAll,
+ contentDescription = stringResource(R.string.copy_code_content_description),
+ )
+ },
+ )
+ }
+
+ SelectionContainer {
+ Text(
+ text = annotatedCode,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontFamily = FontFamily(
+ Font(R.font.font_google_sans_code, weight = FontWeight.Normal)
+ )
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
+
+private fun String.toCodeLang(): CodeLang {
+ return when (trim().lowercase()) {
+ "c" -> CodeLang.C
+ "cpp", "c++" -> CodeLang.CPP
+ "objectivec", "objective-c", "objc" -> CodeLang.ObjectiveC
+ "csharp", "c#" -> CodeLang.CSharp
+ "java", "kotlin", "kt" -> CodeLang.Java
+ "bash", "sh", "shell" -> CodeLang.Bash
+ "python", "py" -> CodeLang.Python
+ "perl" -> CodeLang.Perl
+ "ruby", "rb" -> CodeLang.Ruby
+ "javascript", "js" -> CodeLang.JavaScript
+ "coffeescript", "coffee" -> CodeLang.CoffeeScript
+ "rust", "rs" -> CodeLang.Rust
+ "basic" -> CodeLang.Basic
+ "clojure" -> CodeLang.Clojure
+ "css" -> CodeLang.CSS
+ "dart" -> CodeLang.Dart
+ "erlang", "erl" -> CodeLang.Erlang
+ "go", "golang" -> CodeLang.Go
+ "haskell", "hs" -> CodeLang.Haskell
+ "lisp" -> CodeLang.Lisp
+ "lua" -> CodeLang.Lua
+ "matlab" -> CodeLang.Matlab
+ "ml" -> CodeLang.ML
+ "sml" -> CodeLang.SML
+ "mumps" -> CodeLang.Mumps
+ "pascal" -> CodeLang.Pascal
+ "scala" -> CodeLang.Scala
+ "sql" -> CodeLang.SQL
+ "vhdl" -> CodeLang.VHDL
+ "tcl" -> CodeLang.Tcl
+ "wiki" -> CodeLang.Wiki
+ "xquery" -> CodeLang.XQuery
+ "yaml", "yml" -> CodeLang.YAML
+ "markdown", "md" -> CodeLang.Markdown
+ "json" -> CodeLang.JSON
+ "xml" -> CodeLang.XML
+ else -> CodeLang.Java
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonContentDivider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonContentDivider.kt
new file mode 100644
index 00000000..c0b3734a
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonContentDivider.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun LessonContentDivider(modifier: Modifier = Modifier) {
+ HorizontalDivider(modifier = modifier.fillMaxWidth())
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonHeaderText.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonHeaderText.kt
new file mode 100644
index 00000000..1fb53249
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonHeaderText.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun LessonHeaderText(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .height(28.dp)
+ .width(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(MaterialTheme.colorScheme.primary)
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ LessonHtmlText(
+ text = text,
+ style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonHtmlText.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonHtmlText.kt
new file mode 100644
index 00000000..cab05647
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonHtmlText.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.fromHtml
+
+@Composable
+fun LessonHtmlText(
+ modifier: Modifier = Modifier,
+ text: String,
+ style: TextStyle,
+ color: Color,
+) {
+ val annotatedString = remember(text) { AnnotatedString.fromHtml(text) }
+
+ Text(
+ modifier = modifier,
+ text = annotatedString,
+ style = style,
+ color = color,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonImageContent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonImageContent.kt
new file mode 100644
index 00000000..97f96b41
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonImageContent.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+
+@Composable
+fun LessonImageContent(
+ imageUrl: String,
+ modifier: Modifier = Modifier,
+ contentDescription: String? = null,
+) {
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ AsyncImage(
+ model = imageUrl,
+ contentScale = ContentScale.FillWidth,
+ contentDescription = contentDescription,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp)) // Ensures the image respects the border
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonUnsupportedContent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonUnsupportedContent.kt
new file mode 100644
index 00000000..2c1ca243
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonUnsupportedContent.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun LessonUnsupportedContent(
+ contentType: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = "Unsupported content type: $contentType",
+ modifier = modifier.fillMaxWidth(),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonWriterFooterItem.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonWriterFooterItem.kt
new file mode 100644
index 00000000..d1f0b020
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/items/LessonWriterFooterItem.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.EditNote
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.d4rk.androidtutorials.R
+
+@Composable
+fun LessonWriterFooterItem(
+ writer: String,
+ modifier: Modifier = Modifier,
+) {
+ ElevatedCard(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.EditNote,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp),
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = stringResource(R.string.lesson_written_by, writer),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/render/LessonSectionRenderer.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/render/LessonSectionRenderer.kt
new file mode 100644
index 00000000..1953ae83
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/details/ui/views/list/render/LessonSectionRenderer.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.details.ui.views.list.render
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import com.d4rk.androidtutorials.app.lessons.details.ui.state.UiLessonContent
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.cards.LessonAudioContent
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.LessonPlaybackUiState
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items.LessonAdBanner
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items.LessonBodyText
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items.LessonCodeContent
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items.LessonContentDivider
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items.LessonHeaderText
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items.LessonImageContent
+import com.d4rk.androidtutorials.app.lessons.details.ui.views.list.items.LessonUnsupportedContent
+import com.d4rk.androidtutorials.core.utils.constants.ui.lessons.LessonContentTypes
+
+@Composable
+fun LessonSectionRenderer(
+ content: UiLessonContent,
+ playbackState: LessonPlaybackUiState,
+ bannerConfig: AdsConfig,
+ mediumRectangleConfig: AdsConfig,
+ onPlayClick: () -> Unit,
+ onSeek: (Float) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ when (content.contentType) {
+ LessonContentTypes.HEADER -> LessonHeaderText(
+ text = content.contentText,
+ modifier = modifier,
+ )
+
+ LessonContentTypes.TEXT -> LessonBodyText(
+ text = content.contentText,
+ modifier = modifier,
+ )
+
+ LessonContentTypes.CODE -> LessonCodeContent(
+ code = content.contentCode,
+ programmingLanguage = content.programmingLanguage,
+ modifier = modifier,
+ )
+
+ LessonContentTypes.CONTENT_PLAYER -> LessonAudioContent(
+ contentItem = content,
+ playbackState = playbackState,
+ onPlayClick = onPlayClick,
+ onSeek = onSeek,
+ modifier = modifier,
+ )
+
+ LessonContentTypes.TYPE_DIVIDER -> LessonContentDivider(modifier = modifier)
+
+ LessonContentTypes.IMAGE -> LessonImageContent(
+ imageUrl = content.contentImageUrl,
+ modifier = modifier,
+ )
+
+ LessonContentTypes.AD_BANNER -> LessonAdBanner(
+ adsConfig = bannerConfig,
+ modifier = modifier,
+ )
+
+ LessonContentTypes.AD_BANNER_FULL -> LessonAdBanner(
+ adsConfig = bannerConfig,
+ modifier = modifier,
+ )
+
+ LessonContentTypes.AD_LARGE_BANNER -> LessonAdBanner(
+ adsConfig = mediumRectangleConfig,
+ modifier = modifier,
+ )
+
+ LessonContentTypes.FULL_IMAGE_BANNER -> LessonImageContent(
+ imageUrl = content.contentImageUrl,
+ modifier = modifier,
+ )
+
+ else -> LessonUnsupportedContent(
+ contentType = content.contentType,
+ modifier = modifier,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/favorites/ui/navigation/FavoritesEntryBuilder.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/favorites/ui/navigation/FavoritesEntryBuilder.kt
new file mode 100644
index 00000000..1aa2d942
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/favorites/ui/navigation/FavoritesEntryBuilder.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.favorites.ui.navigation
+
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationEntryBuilder
+import com.d4rk.androidtutorials.app.lessons.listing.ui.ListingRoute
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingMode
+import com.d4rk.androidtutorials.app.main.ui.views.navigation.AppNavigationEntryContext
+import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey
+import com.d4rk.androidtutorials.app.main.utils.constants.FavoritesRoute
+
+fun favoritesEntryBuilder(
+ context: AppNavigationEntryContext,
+): NavigationEntryBuilder = {
+ entry {
+ ListingRoute(
+ paddingValues = context.paddingValues,
+ listingMode = ListingMode.FAVORITES,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/FavoritesDatabase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/FavoritesDatabase.kt
new file mode 100644
index 00000000..dab62966
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/FavoritesDatabase.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.local.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.dao.FavoriteLessonsDao
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.table.FavoriteLessonTable
+
+@Database(entities = [FavoriteLessonTable::class], version = 3, exportSchema = true)
+abstract class FavoritesDatabase : RoomDatabase() {
+ abstract fun favoriteLessonsDao(): FavoriteLessonsDao
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/converters/Converters.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/converters/Converters.kt
new file mode 100644
index 00000000..7e495181
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/converters/Converters.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.local.database.converters
+
+import androidx.room.TypeConverter
+import kotlinx.serialization.json.Json
+
+class Converters {
+ @TypeConverter
+ fun fromStringList(value: List): String = Json.encodeToString(value)
+
+ @TypeConverter
+ fun toStringList(value: String): List = Json.decodeFromString(value)
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/dao/FavoriteLessonsDao.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/dao/FavoriteLessonsDao.kt
new file mode 100644
index 00000000..d7e40644
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/dao/FavoriteLessonsDao.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.local.database.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.table.FavoriteLessonTable
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface FavoriteLessonsDao {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(favoriteLesson: FavoriteLessonTable)
+
+ @Delete
+ suspend fun delete(favoriteLesson: FavoriteLessonTable)
+
+ @Query("SELECT * FROM `Favorite Lessons`")
+ fun observeFavorites(): Flow>
+
+ @Query("SELECT COUNT(*) FROM `Favorite Lessons` WHERE lessonId = :lessonId")
+ suspend fun isFavorite(lessonId: String): Int
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/migrations/Migrations.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/migrations/Migrations.kt
new file mode 100644
index 00000000..f4693f03
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/migrations/Migrations.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.local.database.migrations
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+val MIGRATION_1_2: Migration = object : Migration(startVersion = 1, endVersion = 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `Favorite Lessons_new` (
+ `lessonId` TEXT PRIMARY KEY NOT NULL,
+ `lessonTitle` TEXT NOT NULL,
+ `lessonDescription` TEXT NOT NULL,
+ `lessonType` TEXT NOT NULL,
+ `lessonTags` TEXT NOT NULL,
+ `thumbnailImageUrl` TEXT NOT NULL,
+ `squareImageUrl` TEXT NOT NULL,
+ `deepLinkPath` TEXT NOT NULL,
+ `isFavorite` INTEGER NOT NULL
+ )
+ """.trimIndent()
+ )
+
+ db.execSQL(
+ """
+ INSERT INTO `Favorite Lessons_new` (
+ `lessonId`, `lessonTitle`, `lessonDescription`, `lessonType`,
+ `lessonTags`, `thumbnailImageUrl`, `squareImageUrl`, `deepLinkPath`, `isFavorite`
+ )
+ SELECT
+ `lessonId`,
+ `title`,
+ `description`,
+ `type`,
+ `tags`,
+ `bannerImageUrl`,
+ `squareImageUrl`,
+ `deepLinkPath`,
+ `isFavorite`
+ FROM `Favorite Lessons`
+ """.trimIndent()
+ )
+
+ db.execSQL("DROP TABLE `Favorite Lessons`")
+ db.execSQL("ALTER TABLE `Favorite Lessons_new` RENAME TO `Favorite Lessons`")
+ }
+}
+
+val MIGRATION_2_3: Migration = object : Migration(startVersion = 2, endVersion = 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `Favorite Lessons_new` (
+ `lessonId` TEXT PRIMARY KEY NOT NULL,
+ `lessonTitle` TEXT NOT NULL,
+ `lessonDescription` TEXT NOT NULL,
+ `lessonType` TEXT NOT NULL,
+ `lessonTags` TEXT NOT NULL,
+ `thumbnailImageUrl` TEXT NOT NULL,
+ `squareImageUrl` TEXT NOT NULL,
+ `deepLinkPath` TEXT NOT NULL,
+ `isFavorite` INTEGER NOT NULL
+ )
+ """.trimIndent()
+ )
+
+ db.execSQL(
+ """
+ INSERT INTO `Favorite Lessons_new` (
+ `lessonId`, `lessonTitle`, `lessonDescription`, `lessonType`,
+ `lessonTags`, `thumbnailImageUrl`, `squareImageUrl`, `deepLinkPath`, `isFavorite`
+ )
+ SELECT
+ `lessonId`, `lessonTitle`, `lessonDescription`, `lessonType`,
+ `lessonTags`, `thumbnailImageUrl`, `squareImageUrl`, `deepLinkPath`, `isFavorite`
+ FROM `Favorite Lessons`
+ """.trimIndent()
+ )
+
+ db.execSQL("DROP TABLE `Favorite Lessons`")
+ db.execSQL("ALTER TABLE `Favorite Lessons_new` RENAME TO `Favorite Lessons`")
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/table/FavoriteLessonTable.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/table/FavoriteLessonTable.kt
new file mode 100644
index 00000000..0af1d9d2
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/local/database/table/FavoriteLessonTable.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.local.database.table
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.TypeConverters
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.converters.Converters
+
+@Entity(tableName = "Favorite Lessons")
+@TypeConverters(Converters::class)
+data class FavoriteLessonTable(
+ @PrimaryKey val lessonId: String,
+ val lessonTitle: String,
+ val lessonDescription: String,
+ val lessonType: String,
+ val lessonTags: List,
+ val thumbnailImageUrl: String,
+ val squareImageUrl: String,
+ val deepLinkPath: String,
+ val isFavorite: Boolean,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/mapper/FavoriteLessonMappers.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/mapper/FavoriteLessonMappers.kt
new file mode 100644
index 00000000..158aee41
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/mapper/FavoriteLessonMappers.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.mapper
+
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.table.FavoriteLessonTable
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLesson
+
+internal fun FavoriteLessonTable.toFavoriteDomain(): ListingLesson =
+ ListingLesson(
+ id = lessonId,
+ title = lessonTitle,
+ description = lessonDescription,
+ type = lessonType,
+ imageUrl = thumbnailImageUrl.ifBlank { squareImageUrl }.ifBlank { null },
+ thumbnailImageUrl = thumbnailImageUrl.ifBlank { null },
+ squareImageUrl = squareImageUrl.ifBlank { null },
+ tags = lessonTags,
+ deepLink = deepLinkPath,
+ isFavorite = isFavorite,
+ )
+
+internal fun ListingLesson.toFavoriteTable(): FavoriteLessonTable =
+ FavoriteLessonTable(
+ lessonId = id,
+ lessonTitle = title,
+ lessonDescription = description,
+ lessonType = type,
+ lessonTags = tags,
+ thumbnailImageUrl = thumbnailImageUrl.orEmpty(),
+ squareImageUrl = squareImageUrl.orEmpty(),
+ deepLinkPath = deepLink,
+ isFavorite = true,
+ )
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/mapper/ListingLessonMappers.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/mapper/ListingLessonMappers.kt
new file mode 100644
index 00000000..1a1775a5
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/mapper/ListingLessonMappers.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.mapper
+
+import com.d4rk.androidtutorials.app.lessons.listing.data.remote.model.ListingLessonDto
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLesson
+import com.d4rk.androidtutorials.core.utils.constants.ui.lessons.LessonConstants
+
+internal fun List.toDomain(): List =
+ mapNotNull(ListingLessonDto::toDomainOrNull)
+
+internal fun ListingLessonDto.toDomainOrNull(): ListingLesson? {
+ if (lessonType.isBlank()) return null
+
+ val normalizedType: String = lessonType.trim()
+ val isSupportedType: Boolean = normalizedType in setOf(
+ LessonConstants.TYPE_FULL_IMAGE_BANNER,
+ LessonConstants.TYPE_SQUARE_IMAGE,
+ LessonConstants.TYPE_AD_BANNER,
+ LessonConstants.TYPE_AD_FULL_BANNER,
+ LessonConstants.TYPE_AD_LARGE_BANNER,
+ )
+
+ if (!isSupportedType) return null
+
+ val normalizedThumbnailImageUrl: String? = thumbnailImageUrl?.trim().orEmpty().ifBlank { null }
+ val normalizedSquareImageUrl: String? = squareImageUrl?.trim().orEmpty().ifBlank { null }
+
+ val resolvedImageUrl: String? = when (normalizedType) {
+ LessonConstants.TYPE_FULL_IMAGE_BANNER -> normalizedThumbnailImageUrl ?: normalizedSquareImageUrl
+ LessonConstants.TYPE_SQUARE_IMAGE -> normalizedSquareImageUrl ?: normalizedThumbnailImageUrl
+ else -> normalizedThumbnailImageUrl ?: normalizedSquareImageUrl
+ }
+
+ val resolvedDeepLinkPath: String = deepLinkPath.ifBlank {
+ lessonId.takeIf { it.isNotBlank() }?.let { "com.d4rk.androidtutorials://lesson/$it" }.orEmpty()
+ }
+
+ return ListingLesson(
+ id = lessonId,
+ title = lessonTitle,
+ description = lessonDescription,
+ type = normalizedType,
+ imageUrl = resolvedImageUrl,
+ thumbnailImageUrl = normalizedThumbnailImageUrl,
+ squareImageUrl = normalizedSquareImageUrl,
+ tags = lessonTags,
+ deepLink = resolvedDeepLinkPath,
+ )
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/remote/ListingDataSource.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/remote/ListingDataSource.kt
new file mode 100644
index 00000000..398fe352
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/remote/ListingDataSource.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.remote
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsText
+
+fun interface ListingDataSource {
+ suspend fun fetchJson(urlString: String): String
+}
+
+class KtorListingDataSource(
+ private val client: HttpClient,
+) : ListingDataSource {
+
+ override suspend fun fetchJson(urlString: String): String =
+ client.get(urlString).bodyAsText()
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/remote/model/ListingLessonDto.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/remote/model/ListingLessonDto.kt
new file mode 100644
index 00000000..dcd6e925
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/remote/model/ListingLessonDto.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ListingLessonDto(
+ @SerialName("lesson_id") val lessonId: String = "",
+ @SerialName("lesson_title") val lessonTitle: String = "",
+ @SerialName("lesson_description") val lessonDescription: String = "",
+ @SerialName("lesson_type") val lessonType: String = "",
+ @SerialName("thumbnail_image_url") val thumbnailImageUrl: String? = null,
+ @SerialName("square_image_url") val squareImageUrl: String? = null,
+ @SerialName("lesson_tags") val lessonTags: List = emptyList(),
+ @SerialName("deep_link_path") val deepLinkPath: String = "",
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/remote/model/ListingLessonsResponseDto.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/remote/model/ListingLessonsResponseDto.kt
new file mode 100644
index 00000000..911012bc
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/remote/model/ListingLessonsResponseDto.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ListingLessonsResponseDto(
+ @SerialName("data") val data: List = emptyList(),
+)
+
+@Serializable
+data class ListingLessonsIndexDto(
+ @SerialName("per_page") val perPage: Int = 0,
+ @SerialName("total_items") val totalItems: Int = 0,
+ @SerialName("total_pages") val totalPages: Int = 1,
+ @SerialName("first_page_url") val firstPageUrl: String = "",
+ @SerialName("data") val data: List = emptyList(),
+)
+
+@Serializable
+data class ListingLessonsPageDto(
+ @SerialName("page") val page: Int = 1,
+ @SerialName("per_page") val perPage: Int = 0,
+ @SerialName("total_pages") val totalPages: Int = 1,
+ @SerialName("items") val items: List = emptyList(),
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/repository/ListingRepositoryImpl.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/repository/ListingRepositoryImpl.kt
new file mode 100644
index 00000000..90f01bfe
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/data/repository/ListingRepositoryImpl.kt
@@ -0,0 +1,136 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.data.repository
+
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.DataState
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.Errors
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.errors.toError
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.dao.FavoriteLessonsDao
+import com.d4rk.androidtutorials.app.lessons.listing.data.mapper.toDomain
+import com.d4rk.androidtutorials.app.lessons.listing.data.mapper.toFavoriteDomain
+import com.d4rk.androidtutorials.app.lessons.listing.data.mapper.toFavoriteTable
+import com.d4rk.androidtutorials.app.lessons.listing.data.remote.ListingDataSource
+import com.d4rk.androidtutorials.app.lessons.listing.data.remote.model.ListingLessonsIndexDto
+import com.d4rk.androidtutorials.app.lessons.listing.data.remote.model.ListingLessonsPageDto
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLesson
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLessonsPage
+import com.d4rk.androidtutorials.app.lessons.listing.domain.repository.ListingRepository
+import com.d4rk.androidtutorials.core.domain.model.network.AppErrors
+import com.d4rk.androidtutorials.core.utils.constants.api.AndroidStudioTutorialsApiEndpoints
+import io.ktor.http.Url
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+
+class ListingRepositoryImpl(
+ private val remoteDataSource: ListingDataSource,
+ private val jsonParser: Json,
+ private val favoriteLessonsDao: FavoriteLessonsDao,
+) : ListingRepository {
+
+
+ override fun observeFavoriteLessons(): Flow> =
+ favoriteLessonsDao.observeFavorites().map { favorites -> favorites.map { it.toFavoriteDomain() } }
+
+ override suspend fun toggleFavoriteLesson(lesson: ListingLesson) {
+ val isFavorite = favoriteLessonsDao.isFavorite(lessonId = lesson.id) > 0
+ if (isFavorite) {
+ favoriteLessonsDao.delete(favoriteLesson = lesson.copy(isFavorite = true).toFavoriteTable())
+ return
+ }
+
+ favoriteLessonsDao.insert(favoriteLesson = lesson.copy(isFavorite = true).toFavoriteTable())
+ }
+
+ override fun fetchListingLessons(page: Int): Flow> =
+ flow> {
+ val indexUrl: String = AndroidStudioTutorialsApiEndpoints.HOME_LESSONS_RELEASE_EN
+ val indexResponse = remoteDataSource.fetchJson(urlString = indexUrl)
+ val index = jsonParser.decodeFromString(indexResponse)
+
+ // Backward compatibility with old API that returned a single "data" payload.
+ if (index.data.isNotEmpty()) {
+ emit(
+ DataState.Success(
+ data = ListingLessonsPage(
+ page = 1,
+ totalPages = 1,
+ lessons = index.data.toDomain(),
+ )
+ )
+ )
+ return@flow
+ }
+
+ val safeRequestedPage = page.coerceAtLeast(1)
+ val totalPages = index.totalPages.coerceAtLeast(1)
+ val effectivePage = safeRequestedPage.coerceAtMost(totalPages)
+ val pageUrl = buildPageUrl(indexUrl = indexUrl, firstPagePath = index.firstPageUrl, page = effectivePage)
+
+ val pageResponse = remoteDataSource.fetchJson(urlString = pageUrl)
+ val payload = jsonParser.decodeFromString(pageResponse)
+
+ emit(
+ DataState.Success(
+ data = ListingLessonsPage(
+ page = payload.page.coerceAtLeast(1),
+ totalPages = payload.totalPages.coerceAtLeast(1),
+ lessons = payload.items.toDomain(),
+ )
+ )
+ )
+ }.catch { throwable ->
+ if (throwable is CancellationException) throw throwable
+
+ val error: AppErrors = when (throwable) {
+ is SerializationException -> AppErrors.UseCase.FAILED_TO_PARSE_LESSONS
+ else -> AppErrors.Common(
+ throwable.toError(default = Errors.Network.UNKNOWN)
+ )
+ }
+
+ emit(DataState.Error(error = error))
+ }
+
+ private fun buildPageUrl(indexUrl: String, firstPagePath: String, page: Int): String {
+ val resolvedFirstPageUrl = resolveAgainstIndex(indexUrl = indexUrl, path = firstPagePath)
+
+ if (page == 1) return resolvedFirstPageUrl
+
+ val updatedPageUrl = resolvedFirstPageUrl.replace(Regex("_page_\\d+"), "_page_$page")
+ if (updatedPageUrl != resolvedFirstPageUrl) return updatedPageUrl
+
+ val fallbackPath = "api_get_lessons_page_${page}.json"
+ return resolveAgainstIndex(indexUrl = indexUrl, path = fallbackPath)
+ }
+
+ private fun resolveAgainstIndex(indexUrl: String, path: String): String {
+ if (path.startsWith("http://") || path.startsWith("https://")) return path
+
+ val index = Url(indexUrl)
+ val basePath = index.encodedPath.trimStart('/').split('/').dropLast(1).filter(String::isNotBlank)
+ val normalizedPath = path.trimStart('/')
+ val mergedPath = (basePath + normalizedPath).joinToString(separator = "/")
+
+ return "${index.protocol.name}://${index.host}${index.port.takeIf { it != index.protocol.defaultPort }?.let { ":$it" }.orEmpty()}/$mergedPath"
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/model/ListingLesson.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/model/ListingLesson.kt
new file mode 100644
index 00000000..fdce5108
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/model/ListingLesson.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.domain.model
+
+data class ListingLesson(
+ val id: String = "",
+ val title: String = "",
+ val description: String = "",
+ val type: String = "",
+ val imageUrl: String? = null,
+ val thumbnailImageUrl: String? = null,
+ val squareImageUrl: String? = null,
+ val tags: List = emptyList(),
+ val deepLink: String = "",
+ val isFavorite: Boolean = false,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/model/ListingLessonsPage.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/model/ListingLessonsPage.kt
new file mode 100644
index 00000000..ede4852b
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/model/ListingLessonsPage.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.domain.model
+
+data class ListingLessonsPage(
+ val page: Int = 1,
+ val totalPages: Int = 1,
+ val lessons: List = emptyList(),
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/repository/ListingRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/repository/ListingRepository.kt
new file mode 100644
index 00000000..6459ed87
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/repository/ListingRepository.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.domain.repository
+
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.DataState
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLesson
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLessonsPage
+import com.d4rk.androidtutorials.core.domain.model.network.AppErrors
+import kotlinx.coroutines.flow.Flow
+
+interface ListingRepository {
+ fun fetchListingLessons(page: Int): Flow>
+ fun observeFavoriteLessons(): Flow>
+ suspend fun toggleFavoriteLesson(lesson: ListingLesson)
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/usecases/GetListingLessonsUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/usecases/GetListingLessonsUseCase.kt
new file mode 100644
index 00000000..33892c7b
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/usecases/GetListingLessonsUseCase.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.domain.usecases
+
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.DataState
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLessonsPage
+import com.d4rk.androidtutorials.app.lessons.listing.domain.repository.ListingRepository
+import com.d4rk.androidtutorials.core.domain.model.network.AppErrors
+import kotlinx.coroutines.flow.Flow
+
+class GetListingLessonsUseCase(
+ private val repository: ListingRepository,
+) {
+ operator fun invoke(page: Int): Flow> =
+ repository.fetchListingLessons(page = page)
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/usecases/ObserveFavoriteLessonsUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/usecases/ObserveFavoriteLessonsUseCase.kt
new file mode 100644
index 00000000..5eef5b4b
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/usecases/ObserveFavoriteLessonsUseCase.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.domain.usecases
+
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLesson
+import com.d4rk.androidtutorials.app.lessons.listing.domain.repository.ListingRepository
+import kotlinx.coroutines.flow.Flow
+
+class ObserveFavoriteLessonsUseCase(
+ private val repository: ListingRepository,
+) {
+ operator fun invoke(): Flow> = repository.observeFavoriteLessons()
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/usecases/ToggleFavoriteLessonUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/usecases/ToggleFavoriteLessonUseCase.kt
new file mode 100644
index 00000000..f08435e4
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/domain/usecases/ToggleFavoriteLessonUseCase.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.domain.usecases
+
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLesson
+import com.d4rk.androidtutorials.app.lessons.listing.domain.repository.ListingRepository
+
+class ToggleFavoriteLessonUseCase(
+ private val repository: ListingRepository,
+) {
+ suspend operator fun invoke(lesson: ListingLesson) {
+ repository.toggleFavoriteLesson(lesson = lesson)
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/ListingScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/ListingScreen.kt
new file mode 100644
index 00000000..037c0907
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/ListingScreen.kt
@@ -0,0 +1,274 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.HeartBroken
+import androidx.compose.material.icons.outlined.WifiTetheringError
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.net.toUri
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import com.d4rk.android.libs.apptoolkit.core.ui.state.ScreenState
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.android.libs.apptoolkit.core.ui.views.layouts.LoadingScreen
+import com.d4rk.android.libs.apptoolkit.core.ui.views.layouts.NoDataScreen
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.context.startActivitySafely
+import com.d4rk.androidtutorials.R
+import com.d4rk.androidtutorials.app.lessons.listing.ui.contract.ListingEvent
+import com.d4rk.androidtutorials.app.lessons.listing.ui.contract.OpenLessonDetailsAction
+import com.d4rk.androidtutorials.app.lessons.listing.ui.contract.ShareLessonAction
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingLessonUiModel
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingMode
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingUiState
+import com.d4rk.androidtutorials.app.lessons.listing.ui.views.list.ListingColumn
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.compose.koinInject
+import org.koin.compose.viewmodel.koinViewModel
+import org.koin.core.qualifier.named
+
+@Composable
+fun ListingRoute(
+ paddingValues: PaddingValues,
+ listingMode: ListingMode = ListingMode.ALL,
+) {
+
+ val viewModel: ListingViewModel = koinViewModel()
+ val context = LocalContext.current
+
+ val screenState: UiStateScreen by viewModel.uiState.collectAsStateWithLifecycle()
+ val lessonListState = rememberLazyListState()
+ val bannerAdsConfig: AdsConfig = koinInject()
+ val mediumRectangleAdsConfig: AdsConfig =
+ koinInject(qualifier = named(name = "banner_medium_rectangle"))
+ val onRetryAction = remember(viewModel) {
+ { viewModel.onEvent(event = ListingEvent.LoadLessons) }
+ }
+ val onRefreshAction = remember(viewModel) {
+ { viewModel.onEvent(event = ListingEvent.RefreshLessons) }
+ }
+ val onLoadMoreAction = remember(viewModel) {
+ { viewModel.onEvent(event = ListingEvent.LoadNextPage) }
+ }
+ val onLessonSelected: (ListingLessonUiModel) -> Unit = remember(viewModel) {
+ { lesson ->
+ viewModel.onEvent(
+ ListingEvent.OpenLessonDetails(
+ lessonId = lesson.id,
+ deepLink = lesson.deepLink,
+ )
+ )
+ }
+ }
+
+ LaunchedEffect(viewModel, context) {
+ viewModel.actionEvent.collectLatest { action ->
+ when (action) {
+ is OpenLessonDetailsAction -> {
+ context.openLessonDetails(lessonId = action.lessonId, deepLink = action.deepLink)
+ }
+
+ is ShareLessonAction -> {
+ viewModel.shareLesson(
+ context = context,
+ title = action.title,
+ deepLink = action.deepLink,
+ )
+ }
+ }
+ }
+ }
+
+ val onFavoriteClick: (ListingLessonUiModel) -> Unit = remember(viewModel) {
+ { lesson -> viewModel.onEvent(event = ListingEvent.ToggleLessonFavorite(lesson = lesson)) }
+ }
+ val onShareClick: (ListingLessonUiModel) -> Unit = remember(viewModel) {
+ { lesson -> viewModel.onEvent(event = ListingEvent.ShareLesson(lesson = lesson)) }
+ }
+
+ ListingScreen(
+ screenState = screenState,
+ listingMode = listingMode,
+ onRetry = onRetryAction,
+ onRefresh = onRefreshAction,
+ onLessonSelected = onLessonSelected,
+ onFavoriteClick = onFavoriteClick,
+ onShareClick = onShareClick,
+ onLoadMore = onLoadMoreAction,
+ bannerAdsConfig = bannerAdsConfig,
+ mediumRectangleAdsConfig = mediumRectangleAdsConfig,
+ paddingValues = paddingValues,
+ lessonListState = lessonListState,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ListingScreen(
+ screenState: UiStateScreen,
+ listingMode: ListingMode,
+ onRetry: () -> Unit,
+ onRefresh: () -> Unit,
+ onLessonSelected: (ListingLessonUiModel) -> Unit,
+ onFavoriteClick: (ListingLessonUiModel) -> Unit,
+ onShareClick: (ListingLessonUiModel) -> Unit,
+ onLoadMore: () -> Unit,
+ bannerAdsConfig: AdsConfig,
+ mediumRectangleAdsConfig: AdsConfig,
+ paddingValues: PaddingValues,
+ lessonListState: LazyListState,
+ modifier: Modifier = Modifier,
+) {
+ val state = screenState.screenState
+ val isLoading = state is ScreenState.IsLoading
+ val lessons = screenState.data?.lessons.orEmpty()
+ val favoriteLessons = screenState.data?.favoriteLessons.orEmpty()
+ val filteredLessons = remember(lessons, favoriteLessons, listingMode) {
+ when (listingMode) {
+ ListingMode.ALL -> lessons
+ ListingMode.FAVORITES -> favoriteLessons
+ }.toImmutableList()
+ }
+ val isRefreshing = screenState.data?.isRefreshing == true
+ val isAppending = screenState.data?.isAppending == true
+
+ val shouldLoadMore by remember(filteredLessons, listingMode, isRefreshing, isAppending, lessonListState, screenState.data?.hasMorePages) {
+ derivedStateOf {
+ if (listingMode != ListingMode.ALL || isRefreshing || isAppending) return@derivedStateOf false
+ if (screenState.data?.hasMorePages != true || filteredLessons.isEmpty()) return@derivedStateOf false
+
+ val lastVisibleIndex = lessonListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
+ lastVisibleIndex >= filteredLessons.lastIndex - 3
+ }
+ }
+
+ LaunchedEffect(shouldLoadMore) {
+ if (shouldLoadMore) onLoadMore()
+ }
+
+ if (isLoading) {
+ LoadingScreen()
+ return
+ }
+
+ PullToRefreshBox(
+ isRefreshing = isRefreshing,
+ onRefresh = onRefresh,
+ modifier = modifier,
+ ) {
+ when {
+ filteredLessons.isNotEmpty() -> {
+ ListingColumn(
+ lessons = filteredLessons,
+ bannerAdsConfig = bannerAdsConfig,
+ mediumRectangleAdsConfig = mediumRectangleAdsConfig,
+ onLessonClick = onLessonSelected,
+ onFavoriteClick = onFavoriteClick,
+ onShareClick = onShareClick,
+ paddingValues = paddingValues,
+ listState = lessonListState,
+ showLoadMoreIndicator = listingMode == ListingMode.ALL && isAppending,
+ )
+ }
+
+ listingMode == ListingMode.FAVORITES -> {
+ NoDataScreen(
+ icon = Icons.Outlined.HeartBroken,
+ text = R.string.no_favorite_lessons_found,
+ showRetry = true,
+ onRetry = onRefresh,
+ )
+ }
+
+ state is ScreenState.Error -> {
+ NoDataScreen(
+ icon = Icons.Outlined.WifiTetheringError,
+ showRetry = true,
+ onRetry = onRetry,
+ )
+ }
+
+ else -> {
+ NoDataScreen(
+ icon = Icons.Outlined.WifiTetheringError,
+ showRetry = true,
+ onRetry = onRetry,
+ )
+ }
+ }
+ }
+}
+
+
+private fun android.content.Context.openLessonDetails(lessonId: String, deepLink: String) {
+ val target = deepLink.ifBlank { "com.d4rk.androidtutorials://lesson/$lessonId" }.trim()
+ if (target.isBlank()) return
+
+ val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, target.toUri()).apply {
+ addCategory(android.content.Intent.CATEGORY_DEFAULT)
+ addCategory(android.content.Intent.CATEGORY_BROWSABLE)
+ }
+
+ startActivitySafely(intent = intent)
+}
+
+private fun ListingViewModel.shareLesson(
+ context: android.content.Context,
+ title: String,
+ deepLink: String,
+) {
+ val appLink = deepLink.trim().ifBlank {
+ "https://play.google.com/store/apps/details?id=${context.packageName}"
+ }
+
+ val content = buildString {
+ append(context.getString(R.string.share_lesson_message_title, title.trim()))
+ append("\n\n")
+ append(context.getString(R.string.share_lesson_message_download))
+ if (appLink.isNotBlank()) {
+ append("\n")
+ append(appLink)
+ }
+ }
+
+ if (content.isBlank()) return
+
+ val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(android.content.Intent.EXTRA_TEXT, content)
+ putExtra(android.content.Intent.EXTRA_SUBJECT, title)
+ }
+
+ val chooser = android.content.Intent.createChooser(
+ shareIntent,
+ context.getString(com.d4rk.android.libs.apptoolkit.R.string.share),
+ )
+ context.startActivitySafely(intent = chooser)
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/ListingViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/ListingViewModel.kt
new file mode 100644
index 00000000..8fa5f993
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/ListingViewModel.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui
+
+import androidx.lifecycle.viewModelScope
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.DispatcherProvider
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.onFailure
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.onSuccess
+import com.d4rk.android.libs.apptoolkit.core.domain.repository.FirebaseController
+import com.d4rk.android.libs.apptoolkit.core.ui.base.LoggedScreenViewModel
+import com.d4rk.android.libs.apptoolkit.core.ui.state.ScreenState
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiSnackbar
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.android.libs.apptoolkit.core.ui.state.dismissSnackbar
+import com.d4rk.android.libs.apptoolkit.core.ui.state.setLoading
+import com.d4rk.android.libs.apptoolkit.core.ui.state.showSnackbar
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.ScreenMessageType
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLessonsPage
+import com.d4rk.androidtutorials.app.lessons.listing.domain.usecases.GetListingLessonsUseCase
+import com.d4rk.androidtutorials.app.lessons.listing.domain.usecases.ObserveFavoriteLessonsUseCase
+import com.d4rk.androidtutorials.app.lessons.listing.domain.usecases.ToggleFavoriteLessonUseCase
+import com.d4rk.androidtutorials.app.lessons.listing.ui.contract.ListingAction
+import com.d4rk.androidtutorials.app.lessons.listing.ui.contract.ListingEvent
+import com.d4rk.androidtutorials.app.lessons.listing.ui.contract.OpenLessonDetailsAction
+import com.d4rk.androidtutorials.app.lessons.listing.ui.contract.ShareLessonAction
+import com.d4rk.androidtutorials.app.lessons.listing.ui.mapper.toDomainModel
+import com.d4rk.androidtutorials.app.lessons.listing.ui.mapper.toUiModel
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingLessonUiModel
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingUiState
+import com.d4rk.androidtutorials.core.domain.model.network.AppErrors
+import com.d4rk.androidtutorials.core.utils.extensions.toErrorMessage
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class ListingViewModel(
+ private val getListingLessonsUseCase: GetListingLessonsUseCase,
+ private val observeFavoriteLessonsUseCase: ObserveFavoriteLessonsUseCase,
+ private val toggleFavoriteLessonUseCase: ToggleFavoriteLessonUseCase,
+ private val dispatchers: DispatcherProvider,
+ firebaseController: FirebaseController,
+) : LoggedScreenViewModel(
+ initialState = UiStateScreen(
+ screenState = ScreenState.IsLoading(),
+ data = ListingUiState(),
+ ),
+ firebaseController = firebaseController,
+ screenName = "ListingScreen",
+) {
+
+ private var loadLessonsJob: Job? = null
+
+ init {
+ observeFavorites()
+ onEvent(event = ListingEvent.LoadLessons)
+ }
+
+ override fun handleEvent(event: ListingEvent) {
+ when (event) {
+ ListingEvent.LoadLessons -> loadPage(page = 1, isRefresh = false)
+ ListingEvent.RefreshLessons -> loadPage(page = 1, isRefresh = true)
+ ListingEvent.LoadNextPage -> loadNextPage()
+ ListingEvent.DismissSnackbar -> screenState.dismissSnackbar()
+ is ListingEvent.OpenLessonDetails -> {
+ sendAction(
+ OpenLessonDetailsAction(
+ lessonId = event.lessonId,
+ deepLink = event.deepLink,
+ )
+ )
+ }
+
+ is ListingEvent.ToggleLessonFavorite -> toggleFavorite(lesson = event.lesson)
+
+ is ListingEvent.ShareLesson -> {
+ sendAction(
+ ShareLessonAction(
+ title = event.lesson.title,
+ deepLink = event.lesson.deepLink,
+ )
+ )
+ }
+ }
+ }
+
+ private fun observeFavorites() {
+ observeFavoriteLessonsUseCase()
+ .flowOn(context = dispatchers.io)
+ .onEach { favoriteLessons ->
+ val favoriteIds = favoriteLessons.asSequence().map { it.id }.toSet()
+ val favoriteUiLessons =
+ favoriteLessons.map { it.copy(isFavorite = true).toUiModel() }.toImmutableList()
+
+ screenState.update { current ->
+ val currentData = current.data ?: ListingUiState()
+ val updatedLessons = currentData.lessons.map { lesson ->
+ lesson.copy(isFavorite = favoriteIds.contains(lesson.id))
+ }.toImmutableList()
+
+ current.copy(
+ data = currentData.copy(
+ lessons = updatedLessons,
+ favoriteLessons = favoriteUiLessons,
+ )
+ )
+ }
+ }
+ .launchIn(viewModelScope)
+ }
+
+ private fun toggleFavorite(lesson: ListingLessonUiModel) {
+ viewModelScope.launch(context = dispatchers.io) {
+ toggleFavoriteLessonUseCase(lesson = lesson.toDomainModel())
+ }
+ }
+
+ private fun loadNextPage() {
+ val currentData = screenState.value.data ?: return
+
+ if (currentData.isRefreshing || currentData.isAppending || !currentData.hasMorePages) {
+ return
+ }
+
+ loadPage(page = currentData.currentPage + 1, isRefresh = false, append = true)
+ }
+
+ private fun loadPage(page: Int, isRefresh: Boolean, append: Boolean = false) {
+ loadLessonsJob?.cancel()
+
+ when {
+ isRefresh -> {
+ screenState.update { current ->
+ current.copy(
+ data = current.data?.copy(
+ isRefreshing = true,
+ isAppending = false,
+ ) ?: ListingUiState(isRefreshing = true),
+ )
+ }
+ }
+
+ append -> {
+ screenState.update { current ->
+ current.copy(
+ data = current.data?.copy(isAppending = true) ?: ListingUiState(
+ isAppending = true
+ ),
+ )
+ }
+ }
+
+ else -> {
+ screenState.setLoading()
+ }
+ }
+
+ loadLessonsJob = getListingLessonsUseCase(page = page)
+ .flowOn(context = dispatchers.io)
+ .onEach { state ->
+ state.onSuccess { pageData ->
+ applyLessonsPage(
+ pageData = pageData,
+ append = append,
+ )
+ }.onFailure { error ->
+ handleLoadError(error = error, append = append, isRefresh = isRefresh)
+ }
+ }
+ .launchIn(scope = viewModelScope)
+ }
+
+ private fun applyLessonsPage(pageData: ListingLessonsPage, append: Boolean) {
+ screenState.update { current ->
+ val currentData = current.data ?: ListingUiState()
+ val favoriteIds = currentData.favoriteLessons.asSequence().map { it.id }.toSet()
+ val incomingLessons = pageData.lessons.map { lesson ->
+ lesson.copy(isFavorite = favoriteIds.contains(lesson.id)).toUiModel()
+ }
+ val mergedLessons = if (append) {
+ (currentData.lessons + incomingLessons).distinctBy { it.id }
+ } else {
+ incomingLessons
+ }
+
+ current.copy(
+ screenState = ScreenState.Success(),
+ data = currentData.copy(
+ lessons = mergedLessons.toImmutableList(),
+ currentPage = pageData.page,
+ totalPages = pageData.totalPages,
+ isRefreshing = false,
+ isAppending = false,
+ ),
+ )
+ }
+ }
+
+ private fun handleLoadError(error: AppErrors, append: Boolean, isRefresh: Boolean) {
+ val currentData = screenState.value.data ?: ListingUiState()
+ val errorMessage = error.toErrorMessage()
+
+ when {
+ append || isRefresh || currentData.lessons.isNotEmpty() -> {
+ screenState.update { current ->
+ current.copy(
+ screenState = ScreenState.Success(),
+ data = (current.data ?: ListingUiState()).copy(
+ isRefreshing = false,
+ isAppending = false,
+ ),
+ )
+ }
+ screenState.showSnackbar(
+ UiSnackbar(
+ message = errorMessage,
+ isError = true,
+ timeStamp = System.nanoTime(),
+ type = ScreenMessageType.SNACKBAR,
+ )
+ )
+ }
+
+ else -> {
+ screenState.update { current ->
+ current.copy(
+ screenState = ScreenState.Error(),
+ data = (current.data ?: ListingUiState()).copy(
+ isRefreshing = false,
+ isAppending = false,
+ ),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/contract/ListingAction.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/contract/ListingAction.kt
new file mode 100644
index 00000000..3681e0ac
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/contract/ListingAction.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.ActionEvent
+
+sealed interface ListingAction : ActionEvent
+
+data class OpenLessonDetailsAction(
+ val lessonId: String,
+ val deepLink: String,
+) : ListingAction
+
+data class ShareLessonAction(
+ val title: String,
+ val deepLink: String,
+) : ListingAction
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/contract/ListingEvent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/contract/ListingEvent.kt
new file mode 100644
index 00000000..465a43e6
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/contract/ListingEvent.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.UiEvent
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingLessonUiModel
+
+sealed interface ListingEvent : UiEvent {
+ data object LoadLessons : ListingEvent
+ data object RefreshLessons : ListingEvent
+ data object LoadNextPage : ListingEvent
+ data object DismissSnackbar : ListingEvent
+ data class OpenLessonDetails(val lessonId: String, val deepLink: String) : ListingEvent
+ data class ToggleLessonFavorite(val lesson: ListingLessonUiModel) : ListingEvent
+ data class ShareLesson(val lesson: ListingLessonUiModel) : ListingEvent
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/mapper/ListingUiMappers.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/mapper/ListingUiMappers.kt
new file mode 100644
index 00000000..25c2ece8
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/mapper/ListingUiMappers.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.mapper
+
+import com.d4rk.androidtutorials.app.lessons.listing.domain.model.ListingLesson
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingLessonUiModel
+
+internal fun ListingLesson.toUiModel(): ListingLessonUiModel =
+ ListingLessonUiModel(
+ id = id,
+ title = title,
+ description = description,
+ type = type,
+ imageUrl = imageUrl,
+ thumbnailImageUrl = thumbnailImageUrl,
+ squareImageUrl = squareImageUrl,
+ deepLink = deepLink,
+ tags = tags,
+ isFavorite = isFavorite,
+ )
+
+
+internal fun ListingLessonUiModel.toDomainModel(): ListingLesson =
+ ListingLesson(
+ id = id,
+ title = title,
+ description = description,
+ type = type,
+ imageUrl = imageUrl,
+ thumbnailImageUrl = thumbnailImageUrl,
+ squareImageUrl = squareImageUrl,
+ deepLink = deepLink,
+ tags = tags,
+ isFavorite = isFavorite,
+ )
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/navigation/ListingEntryBuilder.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/navigation/ListingEntryBuilder.kt
new file mode 100644
index 00000000..dfa9918e
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/navigation/ListingEntryBuilder.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.navigation
+
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationEntryBuilder
+import com.d4rk.androidtutorials.app.lessons.listing.ui.ListingRoute
+import com.d4rk.androidtutorials.app.main.ui.views.navigation.AppNavigationEntryContext
+import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey
+import com.d4rk.androidtutorials.app.main.utils.constants.HomeRoute
+
+fun listingEntryBuilder(
+ context: AppNavigationEntryContext,
+): NavigationEntryBuilder = {
+ entry {
+ ListingRoute(
+ paddingValues = context.paddingValues,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/state/ListingMode.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/state/ListingMode.kt
new file mode 100644
index 00000000..9026d27a
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/state/ListingMode.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.state
+
+enum class ListingMode {
+ ALL,
+ FAVORITES,
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/state/ListingUiState.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/state/ListingUiState.kt
new file mode 100644
index 00000000..4b1b2744
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/state/ListingUiState.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.state
+
+import androidx.compose.runtime.Immutable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Immutable
+data class ListingUiState(
+ val lessons: ImmutableList = persistentListOf(),
+ val favoriteLessons: ImmutableList = persistentListOf(),
+ val currentPage: Int = 0,
+ val totalPages: Int = 1,
+ val isRefreshing: Boolean = false,
+ val isAppending: Boolean = false,
+) {
+ val hasMorePages: Boolean
+ get() = currentPage in 1..totalPages
+}
+
+@Immutable
+data class ListingLessonUiModel(
+ val id: String = "",
+ val title: String = "",
+ val description: String = "",
+ val type: String = "",
+ val imageUrl: String? = null,
+ val thumbnailImageUrl: String? = null,
+ val squareImageUrl: String? = null,
+ val deepLink: String = "",
+ val tags: List = emptyList(),
+ val isFavorite: Boolean = false,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/cards/LessonCard.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/cards/LessonCard.kt
new file mode 100644
index 00000000..e76cced7
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/cards/LessonCard.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.views.cards
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Shape
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.SizeConstants
+
+@Composable
+fun LessonCard(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ shape: Shape = RoundedCornerShape(SizeConstants.LargeIncreasedSize),
+ content: @Composable () -> Unit,
+) {
+ ElevatedCard(
+ shape = shape,
+ elevation = CardDefaults.elevatedCardElevation(defaultElevation = SizeConstants.ExtraSmallSize),
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
+ ),
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .clickable(onClick = onClick),
+ ) {
+ Box(modifier = Modifier.fillMaxWidth()) {
+ content()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/ListingColumn.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/ListingColumn.kt
new file mode 100644
index 00000000..b62ce470
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/ListingColumn.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.views.list
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import com.d4rk.android.libs.apptoolkit.core.ui.views.modifiers.animateVisibility
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.SizeConstants
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingLessonUiModel
+import com.d4rk.androidtutorials.app.lessons.listing.ui.views.list.render.ListingSectionRenderer
+import kotlinx.collections.immutable.ImmutableList
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ListingColumn(
+ lessons: ImmutableList,
+ bannerAdsConfig: AdsConfig,
+ mediumRectangleAdsConfig: AdsConfig,
+ onLessonClick: (ListingLessonUiModel) -> Unit,
+ onFavoriteClick: (ListingLessonUiModel) -> Unit,
+ onShareClick: (ListingLessonUiModel) -> Unit,
+ paddingValues: PaddingValues,
+ listState: LazyListState,
+ showLoadMoreIndicator: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val layoutDirection = LocalLayoutDirection.current
+ val listContentPadding = PaddingValues(
+ start = paddingValues.calculateStartPadding(layoutDirection) + SizeConstants.LargeSize,
+ top = paddingValues.calculateTopPadding() + SizeConstants.LargeSize,
+ end = paddingValues.calculateEndPadding(layoutDirection) + SizeConstants.LargeSize,
+ bottom = paddingValues.calculateBottomPadding() + SizeConstants.LargeSize,
+ )
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(SizeConstants.LargeSize),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ state = listState,
+ contentPadding = listContentPadding,
+ ) {
+ itemsIndexed(
+ items = lessons,
+ key = { index, item -> "${item.id.ifBlank { item.type }}_$index" },
+ contentType = { _, item -> item.type },
+ ) { index, item ->
+ ListingSectionRenderer(
+ lesson = item,
+ bannerAdsConfig = bannerAdsConfig,
+ mediumRectangleAdsConfig = mediumRectangleAdsConfig,
+ onLessonClick = onLessonClick,
+ onFavoriteClick = onFavoriteClick,
+ onShareClick = onShareClick,
+ modifier = Modifier
+ .animateVisibility(index = index)
+ .animateItem(),
+ )
+ }
+
+ if (showLoadMoreIndicator) {
+ item(key = "pagination_loading") {
+ Box(contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/items/BannerAdView.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/items/BannerAdView.kt
new file mode 100644
index 00000000..995cfb6f
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/items/BannerAdView.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.views.list.items
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import com.d4rk.android.libs.apptoolkit.core.ui.views.ads.AdBanner
+
+@Composable
+fun BannerAdView(
+ adsConfig: AdsConfig,
+ modifier: Modifier = Modifier,
+) {
+ AdBanner(
+ modifier = modifier.fillMaxWidth(),
+ adsConfig = adsConfig,
+ )
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/items/FullImageBannerLessonItem.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/items/FullImageBannerLessonItem.kt
new file mode 100644
index 00000000..6ba70692
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/items/FullImageBannerLessonItem.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.views.list.items
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import coil3.compose.AsyncImage
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.SizeConstants
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingLessonUiModel
+
+@Composable
+fun FullImageBannerLessonItem(
+ lesson: ListingLessonUiModel,
+ onFavoriteClick: () -> Unit,
+ onShareClick: () -> Unit,
+) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ AsyncImage(
+ model = lesson.thumbnailImageUrl.orEmpty(),
+ contentDescription = lesson.title,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(SizeConstants.LargeIncreasedSize))
+ .aspectRatio(ratio = 16f / 9f),
+ contentScale = ContentScale.Crop,
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(SizeConstants.MediumSize),
+ ) {
+ LessonTextContent(
+ lesson = lesson,
+ descriptionMaxLines = 2,
+ )
+
+ LessonTagsRow(tags = lesson.tags)
+ LessonButtonsRow(
+ isFavorite = lesson.isFavorite,
+ onFavoriteClick = onFavoriteClick,
+ onShareClick = onShareClick,
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/items/SquareImageLessonItem.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/items/SquareImageLessonItem.kt
new file mode 100644
index 00000000..96327f40
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/items/SquareImageLessonItem.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.views.list.items
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Favorite
+import androidx.compose.material.icons.outlined.FavoriteBorder
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.d4rk.android.libs.apptoolkit.core.ui.views.modifiers.bounceClick
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.SizeConstants
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingLessonUiModel
+
+@Composable
+fun SquareImageLessonItem(
+ lesson: ListingLessonUiModel,
+ onFavoriteClick: () -> Unit,
+ onShareClick: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(SizeConstants.MediumSize),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(SizeConstants.MediumSize),
+ ) {
+ AsyncImage(
+ model = lesson.squareImageUrl.orEmpty().ifBlank { lesson.imageUrl.orEmpty() },
+ contentDescription = lesson.title,
+ modifier = Modifier
+ .size(98.dp)
+ .aspectRatio(ratio = 1f)
+ .clip(RoundedCornerShape(size = 12.dp)),
+ contentScale = ContentScale.Crop,
+ )
+
+ LessonTextContent(
+ lesson = lesson,
+ descriptionMaxLines = 3,
+ modifier = Modifier.weight(1f),
+ )
+ }
+
+ LessonTagsRow(tags = lesson.tags)
+ LessonButtonsRow(
+ isFavorite = lesson.isFavorite,
+ onFavoriteClick = onFavoriteClick,
+ onShareClick = onShareClick,
+ )
+ }
+}
+
+@Composable
+internal fun LessonTextContent(
+ lesson: ListingLessonUiModel,
+ descriptionMaxLines: Int,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(SizeConstants.SmallSize),
+ ) {
+ Text(
+ text = lesson.title,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ if (lesson.description.isNotBlank()) {
+ Text(
+ text = lesson.description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = descriptionMaxLines,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+internal fun LessonTagsRow(tags: List) {
+ if (tags.isEmpty()) return
+
+ LazyRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = SizeConstants.MediumSize),
+ horizontalArrangement = Arrangement.spacedBy(SizeConstants.SmallSize),
+ ) {
+ items(tags) { tag ->
+ AssistChip(onClick = {}, label = { Text(text = tag) })
+ }
+ }
+}
+
+@Composable
+internal fun LessonButtonsRow(
+ isFavorite: Boolean,
+ onFavoriteClick: () -> Unit,
+ onShareClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = SizeConstants.ExtraSmallSize),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ IconButton(
+ modifier = Modifier.bounceClick(),
+ onClick = onShareClick,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Share,
+ contentDescription = null,
+ )
+ }
+
+ IconButton(
+ modifier = Modifier.bounceClick(),
+ onClick = onFavoriteClick,
+ ) {
+ Icon(
+ imageVector = if (isFavorite) Icons.Outlined.Favorite else Icons.Outlined.FavoriteBorder,
+ tint = if (isFavorite) MaterialTheme.colorScheme.error else Color.Unspecified,
+ contentDescription = null,
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/render/ListingSectionRenderer.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/render/ListingSectionRenderer.kt
new file mode 100644
index 00000000..c29e15e1
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/lessons/listing/ui/views/list/render/ListingSectionRenderer.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.lessons.listing.ui.views.list.render
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import com.d4rk.androidtutorials.app.lessons.listing.ui.state.ListingLessonUiModel
+import com.d4rk.androidtutorials.app.lessons.listing.ui.views.cards.LessonCard
+import com.d4rk.androidtutorials.app.lessons.listing.ui.views.list.items.BannerAdView
+import com.d4rk.androidtutorials.app.lessons.listing.ui.views.list.items.FullImageBannerLessonItem
+import com.d4rk.androidtutorials.app.lessons.listing.ui.views.list.items.SquareImageLessonItem
+import com.d4rk.androidtutorials.core.utils.constants.ui.lessons.LessonConstants
+
+@Composable
+fun ListingSectionRenderer(
+ lesson: ListingLessonUiModel,
+ bannerAdsConfig: AdsConfig,
+ mediumRectangleAdsConfig: AdsConfig,
+ onLessonClick: (ListingLessonUiModel) -> Unit,
+ onFavoriteClick: (ListingLessonUiModel) -> Unit,
+ onShareClick: (ListingLessonUiModel) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ when (lesson.type) {
+ LessonConstants.TYPE_AD_BANNER,
+ LessonConstants.TYPE_AD_FULL_BANNER,
+ -> BannerAdView(
+ adsConfig = bannerAdsConfig,
+ modifier = modifier,
+ )
+
+ LessonConstants.TYPE_AD_LARGE_BANNER -> BannerAdView(
+ adsConfig = mediumRectangleAdsConfig,
+ modifier = modifier,
+ )
+
+ LessonConstants.TYPE_SQUARE_IMAGE -> LessonCard(
+ onClick = { onLessonClick(lesson) },
+ modifier = modifier,
+ ) {
+ SquareImageLessonItem(
+ lesson = lesson,
+ onFavoriteClick = { onFavoriteClick(lesson) },
+ onShareClick = { onShareClick(lesson) },
+ )
+ }
+
+ else -> LessonCard(
+ onClick = { onLessonClick(lesson) },
+ modifier = modifier,
+ ) {
+ FullImageBannerLessonItem(
+ lesson = lesson,
+ onFavoriteClick = { onFavoriteClick(lesson) },
+ onShareClick = { onShareClick(lesson) },
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/data/repository/MainNavigationRepositoryImpl.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/data/repository/MainNavigationRepositoryImpl.kt
new file mode 100644
index 00000000..e7d941d7
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/data/repository/MainNavigationRepositoryImpl.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.data.repository
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.EventNote
+import androidx.compose.material.icons.automirrored.outlined.HelpOutline
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.Share
+import com.d4rk.android.libs.apptoolkit.app.main.domain.repository.NavigationRepository
+import com.d4rk.android.libs.apptoolkit.app.main.utils.constants.NavigationDrawerRoutes
+import com.d4rk.android.libs.apptoolkit.core.domain.repository.FirebaseController
+import com.d4rk.android.libs.apptoolkit.core.ui.model.navigation.NavigationDrawerItem
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onStart
+import com.d4rk.android.libs.apptoolkit.R as ToolkitR
+
+class MainNavigationRepositoryImpl(private val firebaseController: FirebaseController) : NavigationRepository {
+ override fun getNavigationDrawerItems(): Flow> =
+ flow {
+ emit(
+ listOf(
+ NavigationDrawerItem(
+ title = ToolkitR.string.settings,
+ selectedIcon = Icons.Outlined.Settings,
+ route = NavigationDrawerRoutes.ROUTE_SETTINGS,
+ ),
+ NavigationDrawerItem(
+ title = ToolkitR.string.help_and_feedback,
+ selectedIcon = Icons.AutoMirrored.Outlined.HelpOutline,
+ route = NavigationDrawerRoutes.ROUTE_HELP_AND_FEEDBACK,
+ ),
+ NavigationDrawerItem(
+ title = ToolkitR.string.updates,
+ selectedIcon = Icons.AutoMirrored.Outlined.EventNote,
+ route = NavigationDrawerRoutes.ROUTE_UPDATES,
+ ),
+ NavigationDrawerItem(
+ title = ToolkitR.string.share,
+ selectedIcon = Icons.Outlined.Share,
+ route = NavigationDrawerRoutes.ROUTE_SHARE,
+ )
+ )
+ )
+ }.onStart {
+ firebaseController.logBreadcrumb(
+ message = "Navigation drawer items requested",
+ attributes = mapOf("source" to "MainNavigationRepositoryImpl"),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/domain/usecases/GetNavigationDrawerItemsUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/domain/usecases/GetNavigationDrawerItemsUseCase.kt
new file mode 100644
index 00000000..c9c1399b
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/domain/usecases/GetNavigationDrawerItemsUseCase.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.domain.usecases
+
+import com.d4rk.android.libs.apptoolkit.app.main.domain.repository.NavigationRepository
+import com.d4rk.android.libs.apptoolkit.core.ui.model.navigation.NavigationDrawerItem
+import kotlinx.coroutines.flow.Flow
+
+class GetNavigationDrawerItemsUseCase(private val navigationRepository: NavigationRepository) {
+
+ operator fun invoke(): Flow> {
+ return navigationRepository.getNavigationDrawerItems()
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainActivity.kt
new file mode 100644
index 00000000..168c9770
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainActivity.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.ui
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.IntentSenderRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.d4rk.android.libs.apptoolkit.app.consent.domain.usecases.ApplyInitialConsentUseCase
+import com.d4rk.android.libs.apptoolkit.app.main.ui.factory.GmsHostFactory
+import com.d4rk.android.libs.apptoolkit.app.startup.ui.StartupActivity
+import com.d4rk.android.libs.apptoolkit.app.theme.ui.style.AppTheme
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.DispatcherProvider
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.context.openActivity
+import com.d4rk.androidtutorials.app.main.ui.contract.MainAction
+import com.d4rk.androidtutorials.app.main.ui.contract.MainEvent
+import com.d4rk.androidtutorials.core.data.local.datastore.DataStore
+import com.google.android.gms.ads.MobileAds
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class MainActivity : AppCompatActivity() {
+
+ private val dataStore: DataStore by inject()
+ private val dispatchers: DispatcherProvider by inject()
+ private val viewModel: MainViewModel by viewModel()
+ private val applyInitialConsentUseCase: ApplyInitialConsentUseCase by inject()
+ private val gmsHostFactory: GmsHostFactory by inject()
+ private var updateResultLauncher: ActivityResultLauncher =
+ registerForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) {}
+ private var keepSplashVisible: Boolean = true
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val splashScreen = installSplashScreen()
+ splashScreen.setKeepOnScreenCondition { keepSplashVisible }
+ enableEdgeToEdge()
+ initializeDependencies()
+ handleStartup()
+ observeActions()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ handleGmsEvents()
+ }
+
+ private fun initializeDependencies() {
+ lifecycleScope.launch {
+ coroutineScope {
+ val adsInitialization =
+ async(dispatchers.default) { MobileAds.initialize(this@MainActivity) {} }
+ val consentInitialization =
+ async(dispatchers.io) { applyInitialConsentUseCase.invoke() }
+ awaitAll(adsInitialization, consentInitialization)
+ }
+ }
+ }
+
+ private fun handleStartup() {
+ lifecycleScope.launch {
+ val isFirstLaunch: Boolean = withContext(context = dispatchers.io) { dataStore.startup.first() }
+ keepSplashVisible = false
+ if (isFirstLaunch) {
+ startStartupActivity()
+ } else {
+ setMainActivityContent()
+ }
+ }
+ }
+
+ private fun startStartupActivity() {
+ openActivity(activityClass = StartupActivity::class.java)
+ finish()
+ }
+
+ private fun setMainActivityContent() {
+ setContent {
+ AppTheme {
+ MainScreen()
+ }
+ }
+ }
+
+ private fun observeActions() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.actionEvent.collect { action ->
+ when (action) {
+ is MainAction.ReviewOutcomeReported -> Unit
+ is MainAction.InAppUpdateResultReported -> Unit
+ }
+ }
+ }
+ }
+ }
+
+ private fun handleGmsEvents() {
+ viewModel.onEvent(event = MainEvent.RequestConsent(host = gmsHostFactory.createConsentHost(activity = this)))
+ viewModel.onEvent(event = MainEvent.RequestReview(host = gmsHostFactory.createReviewHost(activity = this)))
+ viewModel.onEvent(event = MainEvent.RequestInAppUpdate(host = gmsHostFactory.createUpdateHost(activity = this, launcher = updateResultLauncher)))
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainScreen.kt
new file mode 100644
index 00000000..f68581da
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainScreen.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.ui
+
+import android.content.Context
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.MenuOpen
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material3.BottomAppBarDefaults
+import androidx.compose.material3.DrawerState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.d4rk.android.libs.apptoolkit.app.main.domain.model.BottomBarItem
+import com.d4rk.android.libs.apptoolkit.app.main.ui.views.dialogs.ChangelogDialog
+import com.d4rk.android.libs.apptoolkit.app.main.ui.views.navigation.BottomNavigationBar
+import com.d4rk.android.libs.apptoolkit.app.main.ui.views.navigation.HideOnScrollBottomBar
+import com.d4rk.android.libs.apptoolkit.app.main.ui.views.navigation.LeftNavigationRail
+import com.d4rk.android.libs.apptoolkit.app.main.ui.views.navigation.MainTopAppBar
+import com.d4rk.android.libs.apptoolkit.core.data.local.datastore.startupDestinationFlow
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationState
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.Navigator
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.rememberNavigationState
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.android.libs.apptoolkit.core.ui.views.snackbar.DefaultSnackbarHost
+import com.d4rk.android.libs.apptoolkit.core.ui.window.AppWindowWidthSizeClass
+import com.d4rk.android.libs.apptoolkit.core.ui.window.rememberWindowWidthSizeClass
+import com.d4rk.androidtutorials.app.main.ui.state.MainUiState
+import com.d4rk.androidtutorials.app.main.ui.views.navigation.AppNavigationHost
+import com.d4rk.androidtutorials.app.main.ui.views.navigation.NavigationDrawer
+import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey
+import com.d4rk.androidtutorials.app.main.utils.constants.HomeRoute
+import com.d4rk.androidtutorials.app.main.utils.constants.NavigationRoutes
+import com.d4rk.androidtutorials.app.main.utils.constants.toNavKeyOrDefault
+import com.d4rk.androidtutorials.app.main.utils.defaults.MainNavigationDefaults
+import com.d4rk.androidtutorials.core.data.local.datastore.DataStore
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.koin.compose.koinInject
+import org.koin.compose.viewmodel.koinViewModel
+import org.koin.core.qualifier.named
+
+@Composable
+fun MainScreen() {
+ val windowWidthSizeClass: AppWindowWidthSizeClass = rememberWindowWidthSizeClass()
+ val viewModel: MainViewModel = koinViewModel()
+ val screenState: UiStateScreen by viewModel.uiState.collectAsStateWithLifecycle()
+
+ val bottomItems: ImmutableList> = MainNavigationDefaults.bottomBarItems
+ val dataStore: DataStore = koinInject()
+ val startupRoute: AppNavKey by dataStore
+ .startupDestinationFlow(
+ defaultRoute = NavigationRoutes.ROUTE_HOME,
+ mapToKey = { value -> value.toNavKeyOrDefault() },
+ )
+ .collectAsStateWithLifecycle(initialValue = HomeRoute)
+
+ val navigationState: NavigationState = rememberNavigationState(
+ startRoute = startupRoute,
+ topLevelRoutes = NavigationRoutes.topLevelRoutes,
+ )
+ val navigator: Navigator = remember(startupRoute) { Navigator(navigationState) }
+
+ if (windowWidthSizeClass == AppWindowWidthSizeClass.Compact) {
+ NavigationDrawer(
+ uiState = screenState.data ?: MainUiState(),
+ windowWidthSizeClass = windowWidthSizeClass,
+ bottomItems = bottomItems,
+ navigationState = navigationState,
+ navigator = navigator,
+ )
+ } else {
+ MainScaffoldTabletContent(
+ uiState = screenState.data ?: MainUiState(),
+ windowWidthSizeClass = windowWidthSizeClass,
+ bottomItems = bottomItems,
+ navigationState = navigationState,
+ navigator = navigator,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun MainScaffoldContent(
+ drawerState: DrawerState,
+ windowWidthSizeClass: AppWindowWidthSizeClass,
+ bottomItems: ImmutableList>,
+ navigationState: NavigationState,
+ navigator: Navigator,
+) {
+ val scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+ val bottomAppBarScrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior()
+ val snackBarHostState: SnackbarHostState = remember { SnackbarHostState() }
+ val coroutineScope: CoroutineScope = rememberCoroutineScope()
+
+ Scaffold(
+ modifier = Modifier
+ .imePadding()
+ .nestedScroll(bottomAppBarScrollBehavior.nestedScrollConnection)
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ MainTopAppBar(
+ navigationIcon = if (drawerState.isOpen)
+ Icons.AutoMirrored.Outlined.MenuOpen
+ else Icons.Default.Menu,
+ onNavigationIconClick = { coroutineScope.launch { drawerState.open() } },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ snackbarHost = { DefaultSnackbarHost(snackbarState = snackBarHostState) },
+ bottomBar = {
+ HideOnScrollBottomBar(scrollBehavior = bottomAppBarScrollBehavior) {
+ BottomNavigationBar(
+ currentRoute = navigationState.currentRoute,
+ items = bottomItems,
+ onNavigate = navigator::navigate,
+ )
+ }
+ },
+ ) { paddingValues ->
+ AppNavigationHost(
+ navigationState = navigationState,
+ navigator = navigator,
+ paddingValues = paddingValues,
+ windowWidthSizeClass = windowWidthSizeClass,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MainScaffoldTabletContent(
+ uiState: MainUiState,
+ windowWidthSizeClass: AppWindowWidthSizeClass,
+ bottomItems: ImmutableList>,
+ navigationState: NavigationState,
+ navigator: Navigator,
+) {
+ val scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+ val isRailExpanded = rememberSaveable(windowWidthSizeClass) {
+ mutableStateOf(windowWidthSizeClass >= AppWindowWidthSizeClass.Expanded)
+ }
+
+ val context: Context = LocalContext.current
+ val changelogUrl: String = koinInject(qualifier = named("github_changelog"))
+ val showChangelog = rememberSaveable { mutableStateOf(false) }
+
+ val currentRoute: AppNavKey = navigationState.currentRoute
+
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ MainTopAppBar(
+ navigationIcon = if (isRailExpanded.value)
+ Icons.AutoMirrored.Outlined.MenuOpen
+ else Icons.Default.Menu,
+ onNavigationIconClick = { isRailExpanded.value = !isRailExpanded.value },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ ) { paddingValues ->
+ LeftNavigationRail(
+ drawerItems = uiState.navigationDrawerItems,
+ currentRoute = currentRoute,
+ isRailExpanded = isRailExpanded.value,
+ bottomItems = bottomItems,
+ paddingValues = paddingValues,
+ onDrawerItemClick = { item ->
+ com.d4rk.android.libs.apptoolkit.app.main.ui.navigation.handleNavigationItemClick(
+ context = context,
+ item = item,
+ onChangelogRequested = { showChangelog.value = true },
+ )
+ },
+ content = {
+ AppNavigationHost(
+ navigationState = navigationState,
+ navigator = navigator,
+ paddingValues = PaddingValues(),
+ windowWidthSizeClass = windowWidthSizeClass,
+ )
+ },
+ )
+ }
+
+ if (showChangelog.value) {
+ ChangelogDialog(
+ changelogUrl = changelogUrl,
+ onDismiss = { showChangelog.value = false },
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainViewModel.kt
new file mode 100644
index 00000000..3ea2ac37
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/MainViewModel.kt
@@ -0,0 +1,230 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.ui
+
+import androidx.lifecycle.viewModelScope
+import com.d4rk.android.libs.apptoolkit.R
+import com.d4rk.android.libs.apptoolkit.app.consent.domain.model.ConsentHost
+import com.d4rk.android.libs.apptoolkit.app.consent.domain.usecases.RequestConsentUseCase
+import com.d4rk.android.libs.apptoolkit.app.main.domain.model.InAppUpdateHost
+import com.d4rk.android.libs.apptoolkit.app.main.domain.model.InAppUpdateResult
+import com.d4rk.android.libs.apptoolkit.app.main.domain.usecases.RequestInAppUpdateUseCase
+import com.d4rk.android.libs.apptoolkit.app.review.domain.model.ReviewHost
+import com.d4rk.android.libs.apptoolkit.app.review.domain.model.ReviewOutcome
+import com.d4rk.android.libs.apptoolkit.app.review.domain.usecases.RequestInAppReviewUseCase
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.DispatcherProvider
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.DataState
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.Errors
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.onFailure
+import com.d4rk.android.libs.apptoolkit.core.domain.repository.FirebaseController
+import com.d4rk.android.libs.apptoolkit.core.ui.base.LoggedScreenViewModel
+import com.d4rk.android.libs.apptoolkit.core.ui.model.navigation.NavigationDrawerItem
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiSnackbar
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.android.libs.apptoolkit.core.ui.state.dismissSnackbar
+import com.d4rk.android.libs.apptoolkit.core.ui.state.setLoading
+import com.d4rk.android.libs.apptoolkit.core.ui.state.setNoData
+import com.d4rk.android.libs.apptoolkit.core.ui.state.setSuccess
+import com.d4rk.android.libs.apptoolkit.core.ui.state.showSnackbar
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ui.ScreenMessageType
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.errors.asUiText
+import com.d4rk.android.libs.apptoolkit.core.utils.platform.UiTextHelper
+import com.d4rk.androidtutorials.app.main.domain.usecases.GetNavigationDrawerItemsUseCase
+import com.d4rk.androidtutorials.app.main.ui.contract.MainAction
+import com.d4rk.androidtutorials.app.main.ui.contract.MainEvent
+import com.d4rk.androidtutorials.app.main.ui.state.MainUiState
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.withContext
+
+/**
+ * ViewModel for the main screen that loads navigation drawer content.
+ */
+class MainViewModel(
+ private val getNavigationDrawerItemsUseCase: GetNavigationDrawerItemsUseCase,
+ private val requestConsentUseCase: RequestConsentUseCase,
+ private val requestInAppReviewUseCase: RequestInAppReviewUseCase,
+ private val requestInAppUpdateUseCase: RequestInAppUpdateUseCase,
+ private val dispatchers: DispatcherProvider,
+ firebaseController: FirebaseController,
+) : LoggedScreenViewModel(
+ initialState = UiStateScreen(data = MainUiState()),
+ firebaseController = firebaseController,
+ screenName = "Main",
+) {
+
+ private var navigationJob: Job? = null
+ private var consentJob: Job? = null
+ private var reviewJob: Job? = null
+ private var updateJob: Job? = null
+
+ init {
+ onEvent(MainEvent.LoadNavigation)
+ }
+
+ override fun handleEvent(event: MainEvent) {
+ when (event) {
+ is MainEvent.LoadNavigation -> loadNavigationItems()
+ is MainEvent.RequestConsent -> requestConsent(host = event.host)
+ is MainEvent.RequestReview -> requestReview(host = event.host)
+ is MainEvent.RequestInAppUpdate -> requestInAppUpdate(host = event.host)
+ }
+ }
+
+ private fun loadNavigationItems() {
+ startOperation(action = Actions.LOAD_NAVIGATION)
+ navigationJob = navigationJob.restart {
+ getNavigationDrawerItemsUseCase.invoke()
+ .flowOn(dispatchers.io)
+ .onStart {
+ updateStateThreadSafe {
+ screenState.dismissSnackbar()
+ screenState.setLoading()
+ }
+ }
+ .onEach { items: List ->
+ updateStateThreadSafe {
+ val immutable = items.toImmutableList()
+ val base = screenData ?: MainUiState()
+ val updated = base.copy(navigationDrawerItems = immutable)
+
+ if (items.isEmpty()) {
+ screenState.setNoData(data = updated)
+ } else {
+ screenState.setSuccess(data = updated)
+ }
+ }
+ }
+ .catchReport(action = Actions.LOAD_NAVIGATION) {
+ updateStateThreadSafe {
+ val base = screenData ?: MainUiState()
+
+ if (base.navigationDrawerItems.isEmpty()) {
+ screenState.setNoData(data = base)
+ } else {
+ screenState.setSuccess(data = base)
+ }
+
+ screenState.showSnackbar(
+ UiSnackbar(
+ type = ScreenMessageType.SNACKBAR,
+ message = UiTextHelper.StringResource(R.string.error_failed_to_load_navigation),
+ isError = true,
+ timeStamp = System.nanoTime(),
+ )
+ )
+ }
+ }
+
+ .launchIn(viewModelScope)
+ }
+ }
+
+ private fun requestConsent(host: ConsentHost) {
+ startOperation(
+ action = Actions.REQUEST_CONSENT,
+ extra = mapOf(ExtraKeys.HOST to host.activity::class.java.name)
+ )
+ consentJob = consentJob.restart {
+ requestConsentUseCase.invoke(host = host)
+ .flowOn(dispatchers.main)
+ .onEach { result: DataState ->
+ result.onFailure { error ->
+ updateStateThreadSafe {
+ screenState.showSnackbar(
+ UiSnackbar(
+ type = ScreenMessageType.SNACKBAR,
+ message = error.asUiText(),
+ isError = true,
+ timeStamp = System.nanoTime(),
+ )
+ )
+ }
+ }
+ }
+ .catchReport(
+ action = Actions.REQUEST_CONSENT,
+ extra = mapOf(ExtraKeys.HOST to host.activity::class.java.name)
+ ) {
+ updateStateThreadSafe {
+ screenState.showSnackbar(
+ UiSnackbar(
+ type = ScreenMessageType.SNACKBAR,
+ message = Errors.UseCase.FAILED_TO_LOAD_CONSENT_INFO.asUiText(),
+ isError = true,
+ timeStamp = System.nanoTime(),
+ )
+ )
+ }
+ }
+ .launchIn(viewModelScope)
+ }
+ }
+
+ private fun requestReview(host: ReviewHost) {
+ startOperation(
+ action = Actions.REQUEST_REVIEW,
+ extra = mapOf(ExtraKeys.HOST to host.activity::class.java.name)
+ )
+ reviewJob = reviewJob.restart {
+ launchReport(
+ action = Actions.REQUEST_REVIEW,
+ extra = mapOf(ExtraKeys.HOST to host.activity::class.java.name),
+ block = {
+ val outcome = withContext(dispatchers.io) {
+ requestInAppReviewUseCase(host = host)
+ }
+ sendAction(action = MainAction.ReviewOutcomeReported(outcome = outcome))
+ },
+ onError = {
+ sendAction(action = MainAction.ReviewOutcomeReported(outcome = ReviewOutcome.Failed))
+ }
+ )
+ }
+ }
+
+ private fun requestInAppUpdate(host: InAppUpdateHost) {
+ startOperation(action = Actions.REQUEST_UPDATE)
+ updateJob = updateJob.restart {
+ requestInAppUpdateUseCase(host = host)
+ .flowOn(dispatchers.io)
+ .onEach { result ->
+ sendAction(action = MainAction.InAppUpdateResultReported(result = result))
+ }
+ .catchReport(action = Actions.REQUEST_UPDATE) {
+ sendAction(action = MainAction.InAppUpdateResultReported(result = InAppUpdateResult.Failed))
+ }
+ .launchIn(viewModelScope)
+ }
+ }
+
+ private object Actions {
+ const val LOAD_NAVIGATION: String = "loadNavigationItems"
+ const val REQUEST_CONSENT: String = "requestConsent"
+ const val REQUEST_REVIEW: String = "requestReview"
+ const val REQUEST_UPDATE: String = "requestInAppUpdate"
+ }
+
+ private object ExtraKeys {
+ const val HOST: String = "host"
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/contract/MainAction.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/contract/MainAction.kt
new file mode 100644
index 00000000..bba1bf50
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/contract/MainAction.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.app.main.domain.model.InAppUpdateResult
+import com.d4rk.android.libs.apptoolkit.app.review.domain.model.ReviewOutcome
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.ActionEvent
+
+sealed interface MainAction : ActionEvent {
+ data class ReviewOutcomeReported(val outcome: ReviewOutcome) : MainAction
+ data class InAppUpdateResultReported(val result: InAppUpdateResult) : MainAction
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/contract/MainEvent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/contract/MainEvent.kt
new file mode 100644
index 00000000..039803ec
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/contract/MainEvent.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.app.consent.domain.model.ConsentHost
+import com.d4rk.android.libs.apptoolkit.app.main.domain.model.InAppUpdateHost
+import com.d4rk.android.libs.apptoolkit.app.review.domain.model.ReviewHost
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.UiEvent
+
+sealed interface MainEvent : UiEvent {
+ data object LoadNavigation : MainEvent
+ data class RequestConsent(val host: ConsentHost) : MainEvent
+ data class RequestReview(val host: ReviewHost) : MainEvent
+ data class RequestInAppUpdate(val host: InAppUpdateHost) : MainEvent
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/state/MainUiState.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/state/MainUiState.kt
new file mode 100644
index 00000000..1d85ce4c
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/state/MainUiState.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.ui.state
+
+import androidx.compose.runtime.Immutable
+import com.d4rk.android.libs.apptoolkit.core.ui.model.navigation.NavigationDrawerItem
+import com.d4rk.android.libs.apptoolkit.core.utils.platform.UiTextHelper
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Immutable
+data class MainUiState(
+ val showSnackbar: Boolean = false,
+ val snackbarMessage: UiTextHelper = UiTextHelper.DynamicString(""),
+ val showDialog: Boolean = false,
+ val navigationDrawerItems: ImmutableList = persistentListOf()
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationGraph.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationGraph.kt
new file mode 100644
index 00000000..ac9c1d60
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationGraph.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.ui.views.navigation
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Stable
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationEntryBuilder
+import com.d4rk.android.libs.apptoolkit.core.ui.window.AppWindowWidthSizeClass
+import com.d4rk.androidtutorials.app.lessons.favorites.ui.navigation.favoritesEntryBuilder
+import com.d4rk.androidtutorials.app.lessons.listing.ui.navigation.listingEntryBuilder
+import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey
+import com.d4rk.androidtutorials.app.studiobot.ui.navigation.studioBotEntryBuilder
+
+/**
+ * Context shared by all navigation entry builders in the app module.
+ */
+@Stable
+data class AppNavigationEntryContext(
+ val paddingValues: PaddingValues,
+ val windowWidthSizeClass: AppWindowWidthSizeClass,
+)
+
+/**
+ * Default app navigation builders that can be extended with additional entries.
+ */
+fun appNavigationEntryBuilders(
+ context: AppNavigationEntryContext,
+ additionalEntryBuilders: List> = emptyList(),
+): List> = buildList {
+ addAll(defaultAppNavigationEntryBuilders(context))
+ addAll(additionalEntryBuilders)
+}
+
+private fun defaultAppNavigationEntryBuilders(
+ context: AppNavigationEntryContext,
+): List> = listOf(
+ listingEntryBuilder(context),
+ studioBotEntryBuilder(context),
+ favoritesEntryBuilder(context),
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationHost.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationHost.kt
new file mode 100644
index 00000000..8748ac02
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/AppNavigationHost.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.ui.views.navigation
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.ui.NavDisplay
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationAnimations
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationEntryBuilder
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationState
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.Navigator
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.entryProviderFor
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.rememberNavigationEntryDecorators
+import com.d4rk.android.libs.apptoolkit.core.ui.window.AppWindowWidthSizeClass
+import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun AppNavigationHost(
+ modifier: Modifier = Modifier,
+ navigationState: NavigationState,
+ navigator: Navigator,
+ paddingValues: PaddingValues,
+ windowWidthSizeClass: AppWindowWidthSizeClass,
+ additionalEntryBuilders: ImmutableList> = persistentListOf(),
+) {
+ val entryBuilders: List> =
+ remember(paddingValues, windowWidthSizeClass, additionalEntryBuilders) {
+ val context = AppNavigationEntryContext(
+ paddingValues = paddingValues,
+ windowWidthSizeClass = windowWidthSizeClass,
+ )
+ appNavigationEntryBuilders(
+ context = context,
+ additionalEntryBuilders = additionalEntryBuilders,
+ )
+ }
+
+ val entryDecorators = rememberNavigationEntryDecorators()
+
+ val entryProvider: (AppNavKey) -> NavEntry =
+ remember(entryBuilders) {
+ entryProviderFor(entryBuilders)
+ }
+
+ BackHandler(enabled = navigator.canGoBack()) {
+ navigator.goBack()
+ }
+
+ NavDisplay(
+ modifier = modifier,
+ backStack = navigationState.currentBackStack,
+ entryDecorators = entryDecorators,
+ entryProvider = entryProvider,
+ onBack = { navigator.goBack() },
+ transitionSpec = { NavigationAnimations.default() },
+ popTransitionSpec = { NavigationAnimations.default() },
+ predictivePopTransitionSpec = { NavigationAnimations.default() },
+ )
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/NavigationDrawer.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/NavigationDrawer.kt
new file mode 100644
index 00000000..485ac170
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/ui/views/navigation/NavigationDrawer.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.ui.views.navigation
+
+import android.content.Context
+import androidx.compose.material3.DrawerState
+import androidx.compose.material3.DrawerValue
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.d4rk.android.libs.apptoolkit.app.main.domain.model.BottomBarItem
+import com.d4rk.android.libs.apptoolkit.app.main.ui.navigation.handleNavigationItemClick
+import com.d4rk.android.libs.apptoolkit.app.main.ui.views.dialogs.ChangelogDialog
+import com.d4rk.android.libs.apptoolkit.app.main.ui.views.navigation.NavigationDrawerItemContent
+import com.d4rk.android.libs.apptoolkit.core.ui.model.navigation.NavigationDrawerItem
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationState
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.Navigator
+import com.d4rk.android.libs.apptoolkit.core.ui.views.modifiers.hapticDrawerSwipe
+import com.d4rk.android.libs.apptoolkit.core.ui.views.spacers.LargeVerticalSpacer
+import com.d4rk.android.libs.apptoolkit.core.ui.window.AppWindowWidthSizeClass
+import com.d4rk.androidtutorials.app.main.ui.MainScaffoldContent
+import com.d4rk.androidtutorials.app.main.ui.state.MainUiState
+import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.CoroutineScope
+import org.koin.compose.koinInject
+import org.koin.core.qualifier.named
+
+@Composable
+fun NavigationDrawer(
+ uiState: MainUiState,
+ windowWidthSizeClass: AppWindowWidthSizeClass,
+ bottomItems: ImmutableList>,
+ navigationState: NavigationState,
+ navigator: Navigator,
+) {
+ val drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
+ val coroutineScope: CoroutineScope = rememberCoroutineScope()
+ val context: Context = LocalContext.current
+ val changelogUrl: String = koinInject(qualifier = named("github_changelog"))
+
+ val showChangelog = rememberSaveable { mutableStateOf(false) }
+
+ val appRouteHandlers = remember(navigator) { emptyMap Unit>() }
+
+ ModalNavigationDrawer(
+ modifier = Modifier.hapticDrawerSwipe(state = drawerState),
+ drawerState = drawerState,
+ drawerContent = {
+ ModalDrawerSheet {
+ LargeVerticalSpacer()
+
+ uiState.navigationDrawerItems.forEach { item: NavigationDrawerItem ->
+ NavigationDrawerItemContent(
+ item = item,
+ handleNavigationItemClick = {
+ handleNavigationItemClick(
+ context = context,
+ item = item,
+ drawerState = drawerState,
+ coroutineScope = coroutineScope,
+ onChangelogRequested = { showChangelog.value = true },
+ additionalHandlers = appRouteHandlers,
+ )
+ },
+ )
+ }
+ }
+ },
+ ) {
+ MainScaffoldContent(
+ drawerState = drawerState,
+ windowWidthSizeClass = windowWidthSizeClass,
+ bottomItems = bottomItems,
+ navigationState = navigationState,
+ navigator = navigator,
+ )
+ }
+
+ if (showChangelog.value) {
+ ChangelogDialog(
+ changelogUrl = changelogUrl,
+ onDismiss = { showChangelog.value = false },
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/constants/NavigationRoutes.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/constants/NavigationRoutes.kt
new file mode 100644
index 00000000..b764d8e5
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/constants/NavigationRoutes.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.utils.constants
+
+import androidx.compose.runtime.Immutable
+import com.d4rk.android.libs.apptoolkit.core.ui.model.navigation.StableNavKey
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.serialization.Serializable
+
+@Immutable
+@Serializable
+sealed interface AppNavKey : StableNavKey
+
+@Serializable
+data object HomeRoute : AppNavKey
+
+@Serializable
+data object StudioBotRoute : AppNavKey
+
+@Serializable
+data object FavoritesRoute : AppNavKey
+
+
+object NavigationRoutes {
+ const val ROUTE_HOME: String = "home"
+ const val ROUTE_STUDIO_BOT: String = "studio_bot"
+ const val ROUTE_FAVORITES: String = "favorites"
+
+ val topLevelRoutes: ImmutableSet =
+ persistentSetOf(HomeRoute, StudioBotRoute, FavoritesRoute)
+}
+
+fun String.toNavKeyOrDefault(): AppNavKey =
+ when (this) {
+ NavigationRoutes.ROUTE_STUDIO_BOT -> StudioBotRoute
+ NavigationRoutes.ROUTE_FAVORITES -> FavoritesRoute
+ else -> HomeRoute
+ }
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/defaults/MainNavigationDefaults.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/defaults/MainNavigationDefaults.kt
new file mode 100644
index 00000000..2a19d248
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/main/utils/defaults/MainNavigationDefaults.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.main.utils.defaults
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.outlined.Home
+import androidx.compose.material.icons.rounded.AutoAwesome
+import androidx.compose.material.icons.rounded.Favorite
+import androidx.compose.material.icons.sharp.AutoAwesome
+import androidx.compose.material.icons.sharp.FavoriteBorder
+import com.d4rk.android.libs.apptoolkit.app.main.domain.model.BottomBarItem
+import com.d4rk.androidtutorials.R
+import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey
+import com.d4rk.androidtutorials.app.main.utils.constants.FavoritesRoute
+import com.d4rk.androidtutorials.app.main.utils.constants.HomeRoute
+import com.d4rk.androidtutorials.app.main.utils.constants.StudioBotRoute
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import com.d4rk.android.libs.apptoolkit.R as ToolkitR
+
+internal object MainNavigationDefaults {
+ val bottomBarItems: ImmutableList> = persistentListOf(
+ BottomBarItem(
+ route = HomeRoute,
+ icon = Icons.Outlined.Home,
+ selectedIcon = Icons.Filled.Home,
+ title = ToolkitR.string.home,
+ ),
+ BottomBarItem(
+ route = StudioBotRoute,
+ icon = Icons.Sharp.AutoAwesome,
+ selectedIcon = Icons.Rounded.AutoAwesome,
+ title = R.string.studio_bot,
+ ),
+ BottomBarItem(
+ route = FavoritesRoute,
+ icon = Icons.Sharp.FavoriteBorder,
+ selectedIcon = Icons.Rounded.Favorite,
+ title = R.string.favorites,
+ ),
+ )
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/onboarding/utils/constants/OnboardingKeys.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/onboarding/utils/constants/OnboardingKeys.kt
new file mode 100644
index 00000000..29736d54
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/onboarding/utils/constants/OnboardingKeys.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.onboarding.utils.constants
+
+object OnboardingKeys {
+ const val WELCOME = "welcome"
+ const val LEARN = "feature_highlight_1"
+ const val THEME_OPTIONS = "theme_options"
+ const val CRASHLYTICS_OPTIONS = "crashlytics_options"
+ const val ONBOARDING_COMPLETE = "onboarding_complete"
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/onboarding/utils/interfaces/providers/OnboardingProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/onboarding/utils/interfaces/providers/OnboardingProvider.kt
new file mode 100644
index 00000000..45c50a52
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/onboarding/utils/interfaces/providers/OnboardingProvider.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.onboarding.utils.interfaces.providers
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.MenuBook
+import androidx.compose.material.icons.outlined.Translate
+import com.d4rk.android.libs.apptoolkit.app.onboarding.ui.model.OnboardingPage
+import com.d4rk.android.libs.apptoolkit.app.onboarding.ui.views.pages.finish.FinishOnboardingPage
+
+import com.d4rk.android.libs.apptoolkit.app.onboarding.ui.views.pages.firebase.FirebaseOnboardingPage
+import com.d4rk.android.libs.apptoolkit.app.onboarding.ui.views.pages.theme.ThemeOnboardingPageTab
+import com.d4rk.android.libs.apptoolkit.app.onboarding.utils.interfaces.providers.OnboardingProvider
+import com.d4rk.androidtutorials.R
+import com.d4rk.androidtutorials.app.main.ui.MainActivity
+import com.d4rk.androidtutorials.app.onboarding.utils.constants.OnboardingKeys
+
+class AppOnboardingProvider : OnboardingProvider {
+
+ override fun getOnboardingPages(context: Context): List {
+ return listOf(
+ OnboardingPage.DefaultPage(
+ key = OnboardingKeys.WELCOME,
+ title = context.getString(R.string.app_name),
+ description = context.getString(R.string.app_name),
+ imageVector = Icons.Outlined.Translate,
+ ),
+ OnboardingPage.DefaultPage(
+ key = OnboardingKeys.LEARN,
+ title = context.getString(R.string.app_name),
+ description = context.getString(R.string.app_name),
+ imageVector = Icons.AutoMirrored.Outlined.MenuBook,
+ ),
+ OnboardingPage.CustomPage(
+ key = OnboardingKeys.THEME_OPTIONS,
+ content = {
+ ThemeOnboardingPageTab()
+ },
+ ),
+ OnboardingPage.CustomPage(
+ key = OnboardingKeys.CRASHLYTICS_OPTIONS,
+ content = { isSelected ->
+ FirebaseOnboardingPage(isSelected = isSelected)
+ }
+ ),
+ OnboardingPage.CustomPage(
+ key = OnboardingKeys.ONBOARDING_COMPLETE,
+ content = {
+ FinishOnboardingPage()
+ }
+ ),
+ ).filter {
+ when (it) {
+ is OnboardingPage.DefaultPage -> it.isEnabled
+ is OnboardingPage.CustomPage -> it.isEnabled
+ }
+ }
+ }
+
+ override fun onOnboardingFinished(context: Context) {
+ context.startActivity(Intent(context, MainActivity::class.java))
+ if (context is Activity) {
+ context.finish()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/constants/SettingsConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/constants/SettingsConstants.kt
new file mode 100644
index 00000000..3f0b98b1
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/constants/SettingsConstants.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.settings.settings.utils.constants
+
+object SettingsConstants {
+ const val KEY_SETTINGS_NOTIFICATION: String = "notifications"
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppAboutSettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppAboutSettingsProvider.kt
new file mode 100644
index 00000000..640b6aa8
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppAboutSettingsProvider.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.settings.settings.utils.providers
+
+import android.content.Context
+import android.os.Build
+import com.d4rk.android.libs.apptoolkit.R
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.AboutSettingsProvider
+import com.d4rk.androidtutorials.BuildConfig
+
+class AppAboutSettingsProvider(val context: Context) : AboutSettingsProvider {
+ override val deviceInfo: String
+ get() {
+ return context.getString(
+ R.string.app_build,
+ "${context.getString(com.d4rk.android.libs.apptoolkit.R.string.manufacturer)} ${Build.MANUFACTURER}",
+ "${context.getString(com.d4rk.android.libs.apptoolkit.R.string.device_model)} ${Build.MODEL}",
+ "${context.getString(com.d4rk.android.libs.apptoolkit.R.string.android_version)} ${Build.VERSION.RELEASE}",
+ "${context.getString(com.d4rk.android.libs.apptoolkit.R.string.api_level)} ${Build.VERSION.SDK_INT}",
+ "${context.getString(com.d4rk.android.libs.apptoolkit.R.string.arch)} ${Build.SUPPORTED_ABIS.joinToString()}",
+ if (BuildConfig.DEBUG) context.getString(com.d4rk.android.libs.apptoolkit.R.string.debug) else context.getString(
+ com.d4rk.android.libs.apptoolkit.R.string.release
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppAdvancedSettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppAdvancedSettingsProvider.kt
new file mode 100644
index 00000000..c864332d
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppAdvancedSettingsProvider.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.settings.settings.utils.providers
+
+import android.content.Context
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.AdvancedSettingsProvider
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.github.GithubConstants
+
+class AppAdvancedSettingsProvider(val context: Context) : AdvancedSettingsProvider {
+ override val bugReportUrl: String
+ get() = "${GithubConstants.GITHUB_BASE}English-with-Lidia-for-Android${GithubConstants.GITHUB_ISSUES_SUFFIX}"
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppBuildInfoProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppBuildInfoProvider.kt
new file mode 100644
index 00000000..f4863b2e
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppBuildInfoProvider.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.settings.settings.utils.providers
+
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.BuildInfoProvider
+import com.d4rk.androidtutorials.BuildConfig
+
+class AppBuildInfoProvider : BuildInfoProvider {
+
+ override val packageName: String get() = BuildConfig.APPLICATION_ID
+
+ override val appVersion: String get() = BuildConfig.VERSION_NAME
+
+ override val appVersionCode: Int
+ get() {
+ return BuildConfig.VERSION_CODE
+ }
+
+ override val isDebugBuild: Boolean
+ get() {
+ return BuildConfig.DEBUG
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppDisplaySettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppDisplaySettingsProvider.kt
new file mode 100644
index 00000000..f80b2e52
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppDisplaySettingsProvider.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.settings.settings.utils.providers
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import com.d4rk.android.libs.apptoolkit.R
+import com.d4rk.android.libs.apptoolkit.app.display.ui.views.dialogs.SelectStartupScreenAlertDialog
+import com.d4rk.android.libs.apptoolkit.app.settings.general.ui.GeneralSettingsActivity
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.constants.SettingsContent
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.DisplaySettingsProvider
+
+class AppDisplaySettingsProvider(val context: Context) : DisplaySettingsProvider {
+ override fun openThemeSettings() {
+ GeneralSettingsActivity.start(
+ context = context,
+ title = context.getString(R.string.dark_theme),
+ contentKey = SettingsContent.THEME
+ )
+ }
+
+ override val supportsStartupPage: Boolean = true
+
+ @Composable
+ override fun StartupPageDialog(onDismiss: () -> Unit, onStartupSelected: (String) -> Unit) {
+ SelectStartupScreenAlertDialog(onDismiss = onDismiss, onStartupSelected = onStartupSelected)
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppPrivacySettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppPrivacySettingsProvider.kt
new file mode 100644
index 00000000..0b9fae10
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppPrivacySettingsProvider.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.settings.settings.utils.providers
+
+import android.content.Context
+import com.d4rk.android.libs.apptoolkit.R
+import com.d4rk.android.libs.apptoolkit.app.ads.ui.AdsSettingsActivity
+import com.d4rk.android.libs.apptoolkit.app.permissions.ui.PermissionsActivity
+import com.d4rk.android.libs.apptoolkit.app.settings.general.ui.GeneralSettingsActivity
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.constants.SettingsContent
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.PrivacySettingsProvider
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.context.openActivity
+
+class AppPrivacySettingsProvider(val context: Context) : PrivacySettingsProvider {
+
+ override fun openPermissionsScreen() {
+ context.openActivity(PermissionsActivity::class.java)
+ }
+
+ override fun openAdsScreen() {
+ context.openActivity(AdsSettingsActivity::class.java)
+ }
+
+ override fun openUsageAndDiagnosticsScreen() {
+ GeneralSettingsActivity.start(
+ context = context,
+ title = context.getString(R.string.usage_and_diagnostics),
+ contentKey = SettingsContent.USAGE_AND_DIAGNOSTICS
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppSettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppSettingsProvider.kt
new file mode 100644
index 00000000..64bcab2c
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/AppSettingsProvider.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.settings.settings.utils.providers
+
+import android.content.Context
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Build
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Notifications
+import androidx.compose.material.icons.outlined.Palette
+import androidx.compose.material.icons.outlined.Security
+import com.d4rk.android.libs.apptoolkit.R
+import com.d4rk.android.libs.apptoolkit.app.settings.general.ui.GeneralSettingsActivity
+import com.d4rk.android.libs.apptoolkit.app.settings.settings.domain.model.SettingsCategory
+import com.d4rk.android.libs.apptoolkit.app.settings.settings.domain.model.SettingsConfig
+import com.d4rk.android.libs.apptoolkit.app.settings.settings.domain.model.SettingsPreference
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.constants.SettingsContent
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.interfaces.SettingsProvider
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.context.openAppNotificationSettings
+import com.d4rk.androidtutorials.app.settings.settings.utils.constants.SettingsConstants
+
+class AppSettingsProvider : SettingsProvider {
+ override fun provideSettingsConfig(context: Context): SettingsConfig {
+ return SettingsConfig(
+ title = context.getString(R.string.settings),
+ categories = listOf(
+ SettingsCategory(
+ preferences = listOf(
+ SettingsPreference(
+ key = SettingsConstants.KEY_SETTINGS_NOTIFICATION,
+ icon = Icons.Outlined.Notifications,
+ title = context.getString(R.string.notifications),
+ summary = context.getString(R.string.summary_preference_settings_notifications),
+ action = {
+ val opened = context.openAppNotificationSettings()
+ if (!opened) {
+ GeneralSettingsActivity.start(
+ context = context,
+ title = context.getString(R.string.security_and_privacy),
+ contentKey = SettingsContent.SECURITY_AND_PRIVACY,
+ )
+ }
+ },
+ ),
+ SettingsPreference(
+ key = SettingsContent.DISPLAY,
+ icon = Icons.Outlined.Palette,
+ title = context.getString(R.string.display),
+ summary = context.getString(R.string.summary_preference_settings_display),
+ action = {
+ GeneralSettingsActivity.start(
+ context = context,
+ title = context.getString(R.string.display),
+ contentKey = SettingsContent.DISPLAY,
+ )
+ },
+ ),
+ ),
+ ),
+ SettingsCategory(
+ preferences = listOf(
+ SettingsPreference(
+ key = SettingsContent.SECURITY_AND_PRIVACY,
+ icon = Icons.Outlined.Security,
+ title = context.getString(R.string.security_and_privacy),
+ summary = context.getString(R.string.summary_preference_settings_privacy_and_security),
+ action = {
+ GeneralSettingsActivity.start(
+ context = context,
+ title = context.getString(R.string.security_and_privacy),
+ contentKey = SettingsContent.SECURITY_AND_PRIVACY,
+ )
+ },
+ ),
+ SettingsPreference(
+ key = SettingsContent.ADVANCED,
+ icon = Icons.Outlined.Build,
+ title = context.getString(R.string.advanced),
+ summary = context.getString(R.string.summary_preference_settings_advanced),
+ action = {
+ GeneralSettingsActivity.start(
+ context = context,
+ title = context.getString(R.string.advanced),
+ contentKey = SettingsContent.ADVANCED,
+ )
+ },
+ ),
+ SettingsPreference(
+ key = SettingsContent.ABOUT,
+ icon = Icons.Outlined.Info,
+ title = context.getString(R.string.about),
+ summary = context.getString(R.string.summary_preference_settings_about),
+ action = {
+ GeneralSettingsActivity.start(
+ context = context,
+ title = context.getString(R.string.about),
+ contentKey = SettingsContent.ABOUT,
+ )
+ },
+ ),
+ ),
+ ),
+ ),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/PermissionsSettingsRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/PermissionsSettingsRepository.kt
new file mode 100644
index 00000000..7027f4f2
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/settings/settings/utils/providers/PermissionsSettingsRepository.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.settings.settings.utils.providers
+
+import android.content.Context
+import com.d4rk.android.libs.apptoolkit.R
+import com.d4rk.android.libs.apptoolkit.app.permissions.domain.repository.PermissionsRepository
+import com.d4rk.android.libs.apptoolkit.app.settings.settings.domain.model.SettingsCategory
+import com.d4rk.android.libs.apptoolkit.app.settings.settings.domain.model.SettingsConfig
+import com.d4rk.android.libs.apptoolkit.app.settings.settings.domain.model.SettingsPreference
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+
+class PermissionsSettingsRepository(
+ private val context: Context,
+) : PermissionsRepository {
+
+ override fun getPermissionsConfig(): Flow =
+ flow {
+ emit(
+ SettingsConfig(
+ title = context.getString(R.string.permissions),
+ categories = listOf(
+ SettingsCategory(
+ title = context.getString(R.string.normal),
+ preferences = listOf(
+ SettingsPreference(
+ title = context.getString(R.string.access_network_state),
+ summary = context.getString(R.string.summary_preference_permissions_access_network_state),
+ ),
+ SettingsPreference(
+ title = context.getString(R.string.ad_id),
+ summary = context.getString(R.string.summary_preference_permissions_ad_id),
+ ),
+ SettingsPreference(
+ title = context.getString(R.string.billing),
+ summary = context.getString(R.string.summary_preference_permissions_billing),
+ ),
+ SettingsPreference(
+ title = context.getString(R.string.check_license),
+ summary = context.getString(R.string.summary_preference_permissions_check_license),
+ ),
+ SettingsPreference(
+ title = context.getString(R.string.foreground_service),
+ summary = context.getString(R.string.summary_preference_permissions_foreground_service),
+ ),
+ SettingsPreference(
+ title = context.getString(R.string.internet),
+ summary = context.getString(R.string.summary_preference_permissions_internet),
+ ),
+ SettingsPreference(
+ title = context.getString(R.string.wake_lock),
+ summary = context.getString(R.string.summary_preference_permissions_wake_lock),
+ ),
+ ),
+ ),
+ SettingsCategory(
+ title = context.getString(R.string.runtime),
+ preferences = listOf(
+ SettingsPreference(
+ title = context.getString(R.string.post_notifications),
+ summary = context.getString(R.string.summary_preference_permissions_post_notifications),
+ ),
+ ),
+ ),
+ SettingsCategory(
+ title = context.getString(R.string.special),
+ preferences = listOf(
+ SettingsPreference(
+ title = context.getString(R.string.access_notification_policy),
+ summary = context.getString(R.string.summary_preference_permissions_access_notification_policy),
+ ),
+ ),
+ ),
+ ),
+ ),
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/startup/utils/interfaces/providers/AppStartupProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/startup/utils/interfaces/providers/AppStartupProvider.kt
new file mode 100644
index 00000000..e37b9ea1
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/startup/utils/interfaces/providers/AppStartupProvider.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.startup.utils.interfaces.providers
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import com.d4rk.android.libs.apptoolkit.app.onboarding.ui.OnboardingActivity
+import com.d4rk.android.libs.apptoolkit.app.startup.utils.interfaces.providers.StartupProvider
+import javax.inject.Inject
+
+class AppStartupProvider @Inject constructor() : StartupProvider {
+ override val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ emptyArray()
+ }
+
+ override fun getNextIntent(context: Context) = Intent(context, OnboardingActivity::class.java)
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/data/repository/FirebaseStudioBotRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/data/repository/FirebaseStudioBotRepository.kt
new file mode 100644
index 00000000..2734a25c
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/data/repository/FirebaseStudioBotRepository.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.data.repository
+
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.repository.StudioBotRepository
+import com.google.firebase.Firebase
+import com.google.firebase.ai.ai
+import com.google.firebase.ai.type.GenerativeBackend
+import com.google.firebase.ai.type.TextPart
+import com.google.firebase.ai.type.content
+
+class FirebaseStudioBotRepository : StudioBotRepository {
+
+ private val model = Firebase.ai(backend = GenerativeBackend.googleAI())
+ .generativeModel("gemini-2.5-flash-lite")
+
+ private val systemInstruction: String =
+ "You are Studio Bot, an AI assistant integrated into Android Studio Tutorials. " +
+ "Focus on Android development, Kotlin, Jetpack Compose, architecture, and best practices. " +
+ "Keep answers practical, concise, and production-ready. " +
+ "If a request is outside Android development, politely say that your scope is Android topics."
+
+ override suspend fun generateReply(
+ message: String,
+ history: List,
+ ): String {
+ val chat = model.startChat(
+ history = history
+ .takeLast(MAX_HISTORY_MESSAGES)
+ .map { chatMessage ->
+ content(role = if (chatMessage.isBot) MODEL_ROLE else USER_ROLE) {
+ text(chatMessage.text)
+ }
+ },
+ )
+
+ val response = chat.sendMessage(prompt = "$systemInstruction\n\n$message")
+ return response.candidates
+ .firstOrNull()
+ ?.content
+ ?.parts
+ ?.filterIsInstance()
+ ?.joinToString(separator = "\n") { part -> part.text }
+ ?.trim()
+ .orEmpty()
+ }
+
+ private companion object {
+ const val USER_ROLE: String = "user"
+ const val MODEL_ROLE: String = "model"
+ const val MAX_HISTORY_MESSAGES: Int = 24
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/domain/model/StudioBotMessage.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/domain/model/StudioBotMessage.kt
new file mode 100644
index 00000000..03bdb732
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/domain/model/StudioBotMessage.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.domain.model
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class StudioBotMessage(
+ val id: Long,
+ val text: String,
+ val isBot: Boolean,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/domain/repository/StudioBotRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/domain/repository/StudioBotRepository.kt
new file mode 100644
index 00000000..1c8a0c0b
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/domain/repository/StudioBotRepository.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.domain.repository
+
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+
+interface StudioBotRepository {
+ suspend fun generateReply(
+ message: String,
+ history: List,
+ ): String
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/domain/usecases/SendStudioBotMessageUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/domain/usecases/SendStudioBotMessageUseCase.kt
new file mode 100644
index 00000000..76d89461
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/domain/usecases/SendStudioBotMessageUseCase.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.domain.usecases
+
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.repository.StudioBotRepository
+
+class SendStudioBotMessageUseCase(
+ private val repository: StudioBotRepository,
+) {
+ suspend operator fun invoke(message: String, history: List): String =
+ repository.generateReply(message = message, history = history)
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/StudioBotScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/StudioBotScreen.kt
new file mode 100644
index 00000000..de061122
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/StudioBotScreen.kt
@@ -0,0 +1,177 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.androidtutorials.R
+import com.d4rk.androidtutorials.app.studiobot.chat.ui.contract.StudioBotEvent
+import com.d4rk.androidtutorials.app.studiobot.chat.ui.state.StudioBotUiState
+import com.d4rk.androidtutorials.app.studiobot.chat.ui.views.MessageBubble
+import org.koin.compose.viewmodel.koinViewModel
+
+@Composable
+fun StudioBotScreen(
+ paddingValues: PaddingValues,
+ conversationId: Long,
+ onBackToConversations: (() -> Unit)? = null,
+) {
+ val viewModel: StudioBotViewModel = koinViewModel()
+ val screenState: UiStateScreen by viewModel.uiState.collectAsStateWithLifecycle()
+ val uiState = screenState.data ?: StudioBotUiState()
+ val listState = rememberLazyListState()
+
+ LaunchedEffect(conversationId) {
+ viewModel.onEvent(StudioBotEvent.Initialize(conversationId = conversationId))
+ }
+
+ LaunchedEffect(uiState.messages.size) {
+ if (uiState.messages.isNotEmpty()) {
+ listState.animateScrollToItem(uiState.messages.lastIndex)
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .imePadding(),
+ ) {
+ onBackToConversations?.let { onBackClick ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ TextButton(onClick = onBackClick) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(id = R.string.studio_bot_back_to_conversations),
+ )
+ Text(
+ text = stringResource(id = R.string.studio_bot_back_to_conversations),
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+ }
+
+ LazyColumn(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ state = listState,
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(items = uiState.messages, key = { it.id }) { message ->
+ MessageBubble(message = message)
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.Bottom,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ OutlinedTextField(
+ modifier = Modifier.weight(1f),
+ value = uiState.draft,
+ onValueChange = { input ->
+ val formatted = input.replaceFirstChar { character ->
+ if (character.isLowerCase()) character.titlecase() else character.toString()
+ }
+ viewModel.onEvent(StudioBotEvent.DraftChanged(formatted))
+ },
+ placeholder = { Text(text = stringResource(id = R.string.type_a_message)) },
+ enabled = !uiState.isSending,
+ shape = CircleShape,
+ maxLines = 4,
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.Sentences,
+ autoCorrectEnabled = true,
+ imeAction = ImeAction.Send,
+ ),
+ keyboardActions = KeyboardActions(
+ onSend = {
+ if (uiState.draft.isNotBlank()) viewModel.onEvent(StudioBotEvent.SendMessage)
+ },
+ ),
+ )
+
+ if (uiState.isSending) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .padding(bottom = 8.dp)
+ .size(40.dp),
+ )
+ } else {
+ IconButton(
+ onClick = {
+ if (uiState.draft.isNotBlank()) viewModel.onEvent(StudioBotEvent.SendMessage)
+ },
+ modifier = Modifier
+ .padding(bottom = 2.dp)
+ .size(48.dp),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Send,
+ contentDescription = stringResource(id = R.string.send),
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/StudioBotViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/StudioBotViewModel.kt
new file mode 100644
index 00000000..3456f83d
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/StudioBotViewModel.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.ui
+
+import androidx.lifecycle.viewModelScope
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.DispatcherProvider
+import com.d4rk.android.libs.apptoolkit.core.domain.repository.FirebaseController
+import com.d4rk.android.libs.apptoolkit.core.ui.base.LoggedScreenViewModel
+import com.d4rk.android.libs.apptoolkit.core.ui.state.ScreenState
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.usecases.SendStudioBotMessageUseCase
+import com.d4rk.androidtutorials.app.studiobot.chat.ui.contract.StudioBotAction
+import com.d4rk.androidtutorials.app.studiobot.chat.ui.contract.StudioBotEvent
+import com.d4rk.androidtutorials.app.studiobot.chat.ui.state.StudioBotUiState
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.ObserveConversationMessagesUseCase
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.SaveConversationMessageUseCase
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicLong
+
+class StudioBotViewModel(
+ private val sendStudioBotMessageUseCase: SendStudioBotMessageUseCase,
+ private val observeConversationMessagesUseCase: ObserveConversationMessagesUseCase,
+ private val saveConversationMessageUseCase: SaveConversationMessageUseCase,
+ private val dispatchers: DispatcherProvider,
+ firebaseController: FirebaseController,
+) : LoggedScreenViewModel(
+ initialState = UiStateScreen(data = StudioBotUiState()),
+ firebaseController = firebaseController,
+ screenName = "StudioBotScreen",
+) {
+
+ private var messageJob: Job = Job()
+ private var observeMessagesJob: Job? = null
+
+ override fun handleEvent(event: StudioBotEvent) {
+ when (event) {
+ is StudioBotEvent.Initialize -> initializeConversation(event.conversationId)
+ is StudioBotEvent.DraftChanged -> updateDraft(event.value)
+ StudioBotEvent.SendMessage -> sendMessage()
+ }
+ }
+
+ private fun observeConversationHistory(conversationId: Long) {
+ observeMessagesJob?.cancel()
+ observeMessagesJob = observeConversationMessagesUseCase(conversationId = conversationId)
+ .onEach { messages ->
+ val currentData = screenState.value.data ?: StudioBotUiState()
+ screenState.value = screenState.value.copy(
+ screenState = ScreenState.Success(),
+ data = currentData.copy(messages = messages, conversationId = conversationId),
+ )
+ }
+ .launchIn(viewModelScope)
+ }
+
+ private fun initializeConversation(conversationId: Long) {
+ if (conversationId <= 0L) return
+
+ val currentData = screenState.value.data ?: StudioBotUiState()
+ screenState.value = screenState.value.copy(
+ screenState = ScreenState.Success(),
+ data = currentData.copy(conversationId = conversationId),
+ )
+
+ observeConversationHistory(conversationId)
+ }
+
+ private fun updateDraft(value: String) {
+ val currentData = screenState.value.data ?: StudioBotUiState()
+ screenState.value = screenState.value.copy(data = currentData.copy(draft = value))
+ }
+
+ private fun sendMessage() {
+ val currentData = screenState.value.data ?: return
+ val conversationId = currentData.conversationId
+ if (conversationId <= 0L) return
+ val message = currentData.draft.trim()
+ if (message.isBlank() || currentData.isSending) return
+
+ val userMessage = StudioBotMessage(
+ id = nextMessageId(),
+ text = message,
+ isBot = false,
+ )
+
+ screenState.value = screenState.value.copy(
+ data = currentData.copy(
+ draft = "",
+ isSending = true,
+ messages = currentData.messages + userMessage,
+ ),
+ )
+
+ viewModelScope.launch(dispatchers.io) {
+ saveConversationMessageUseCase(
+ conversationId = conversationId,
+ message = userMessage,
+ )
+ }
+
+ val history = currentData.messages
+
+ messageJob = messageJob.restart {
+ startOperation(action = ACTION_SEND_MESSAGE)
+ flow { emit(sendStudioBotMessageUseCase(message = message, history = history)) }
+ .flowOn(dispatchers.io)
+ .onEach { responseText ->
+ val botMessage = StudioBotMessage(
+ id = nextMessageId(),
+ text = responseText.ifBlank { DEFAULT_ERROR_REPLY },
+ isBot = true,
+ )
+
+ viewModelScope.launch(dispatchers.io) {
+ saveConversationMessageUseCase(
+ conversationId = conversationId,
+ message = botMessage,
+ )
+ }
+
+ updateStateThreadSafe {
+ val current = screenState.value.data ?: StudioBotUiState()
+ screenState.value = screenState.value.copy(
+ screenState = ScreenState.Success(),
+ data = current.copy(isSending = false),
+ )
+ }
+ }
+ .catchReport(action = ACTION_SEND_MESSAGE) {
+ val fallbackMessage = StudioBotMessage(
+ id = nextMessageId(),
+ text = DEFAULT_ERROR_REPLY,
+ isBot = true,
+ )
+
+ viewModelScope.launch(dispatchers.io) {
+ saveConversationMessageUseCase(
+ conversationId = conversationId,
+ message = fallbackMessage,
+ )
+ }
+
+ updateStateThreadSafe {
+ val current = screenState.value.data ?: StudioBotUiState()
+ screenState.value = screenState.value.copy(
+ screenState = ScreenState.Success(),
+ data = current.copy(isSending = false),
+ )
+ }
+ }
+ .launchIn(viewModelScope)
+ }
+ }
+
+ private fun nextMessageId(): Long = messageIdCounter.incrementAndGet()
+
+ private companion object {
+ const val ACTION_SEND_MESSAGE: String = "sendMessage"
+ const val DEFAULT_ERROR_REPLY: String =
+ "I couldn't generate a response right now. Please try again."
+ val messageIdCounter: AtomicLong = AtomicLong(System.currentTimeMillis())
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/contract/StudioBotAction.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/contract/StudioBotAction.kt
new file mode 100644
index 00000000..60ffa329
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/contract/StudioBotAction.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.ActionEvent
+
+sealed interface StudioBotAction : ActionEvent
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/contract/StudioBotEvent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/contract/StudioBotEvent.kt
new file mode 100644
index 00000000..5db8f8fc
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/contract/StudioBotEvent.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.UiEvent
+
+sealed interface StudioBotEvent : UiEvent {
+ data class Initialize(val conversationId: Long) : StudioBotEvent
+ data class DraftChanged(val value: String) : StudioBotEvent
+ data object SendMessage : StudioBotEvent
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/state/StudioBotUiState.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/state/StudioBotUiState.kt
new file mode 100644
index 00000000..eeea3dcc
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/state/StudioBotUiState.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.ui.state
+
+import androidx.compose.runtime.Immutable
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+
+@Immutable
+data class StudioBotUiState(
+ val conversationId: Long = 0L,
+ val messages: List = emptyList(),
+ val draft: String = "",
+ val isSending: Boolean = false,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/views/MessageActions.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/views/MessageActions.kt
new file mode 100644
index 00000000..59090b9c
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/views/MessageActions.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.ui.views
+
+import android.content.ClipData
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ThumbDown
+import androidx.compose.material.icons.filled.ThumbUp
+import androidx.compose.material.icons.outlined.CopyAll
+import androidx.compose.material.icons.outlined.ThumbDown
+import androidx.compose.material.icons.outlined.ThumbUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.toClipEntry
+import androidx.compose.ui.unit.dp
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+import kotlinx.coroutines.launch
+
+@Composable
+fun MessageActions(
+ message: StudioBotMessage,
+ contentColor: Color
+) {
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+
+ var isLiked by remember(message.id) { mutableStateOf(false) }
+ var isDisliked by remember(message.id) { mutableStateOf(false) }
+
+ CompositionLocalProvider(
+ LocalContentColor provides contentColor.copy(alpha = 0.7f)
+ ) {
+ Row(
+ modifier = Modifier.padding(start = 4.dp, end = 8.dp, bottom = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(0.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ onClick = {
+ scope.launch {
+ val clipData = ClipData.newPlainText(message.text, message.text)
+ clipboard.setClipEntry(clipData.toClipEntry())
+ }
+ },
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.CopyAll,
+ contentDescription = "Copy message",
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ if (message.isBot) {
+ IconButton(
+ onClick = {
+ isLiked = !isLiked
+ if (isLiked) isDisliked = false
+ },
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ imageVector = if (isLiked) Icons.Filled.ThumbUp else Icons.Outlined.ThumbUp,
+ contentDescription = "Like",
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ IconButton(
+ onClick = {
+ isDisliked = !isDisliked
+ if (isDisliked) isLiked = false
+ },
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ imageVector = if (isDisliked) Icons.Filled.ThumbDown else Icons.Outlined.ThumbDown,
+ contentDescription = "Dislike",
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/views/MessageBubble.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/views/MessageBubble.kt
new file mode 100644
index 00000000..ada27aed
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/views/MessageBubble.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.ui.views
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AutoAwesome
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+import dev.jeziellago.compose.markdowntext.MarkdownText
+
+@Composable
+fun MessageBubble(
+ message: StudioBotMessage,
+) {
+ val isBot = message.isBot
+
+ val bubbleColor = if (isBot) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.background
+ val textColor = if (isBot) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onBackground
+
+ val bubbleShape = if (isBot) {
+ RoundedCornerShape(20.dp, 20.dp, 20.dp, 4.dp)
+ } else {
+ RoundedCornerShape(20.dp, 20.dp, 4.dp, 20.dp)
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = if (isBot) Arrangement.Start else Arrangement.End,
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ if (isBot) {
+ ProfilePicture(icon = Icons.Outlined.AutoAwesome)
+ }
+
+ Card(
+ shape = bubbleShape,
+ colors = CardDefaults.cardColors(
+ containerColor = bubbleColor,
+ contentColor = textColor
+ ),
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(0.85f),
+ ) {
+ Column {
+ if (isBot) {
+ MarkdownText(
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
+ markdown = message.text,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ } else {
+ Text(
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
+ text = message.text,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+
+ MessageActions(message = message, contentColor = textColor)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/views/ProfilePicture.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/views/ProfilePicture.kt
new file mode 100644
index 00000000..da0cf880
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/chat/ui/views/ProfilePicture.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.chat.ui.views
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun ProfilePicture(icon: ImageVector) {
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .background(
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ shape = CircleShape,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = "AI Avatar",
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/StudioBotConversationsDatabase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/StudioBotConversationsDatabase.kt
new file mode 100644
index 00000000..a055bcbf
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/StudioBotConversationsDatabase.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.dao.StudioBotConversationsDao
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.table.StudioBotConversationTable
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.table.StudioBotMessageTable
+
+@Database(
+ entities = [StudioBotConversationTable::class, StudioBotMessageTable::class],
+ version = 1,
+ exportSchema = false,
+)
+abstract class StudioBotConversationsDatabase : RoomDatabase() {
+ abstract fun studioBotConversationsDao(): StudioBotConversationsDao
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/dao/StudioBotConversationsDao.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/dao/StudioBotConversationsDao.kt
new file mode 100644
index 00000000..bc64c868
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/dao/StudioBotConversationsDao.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.model.ConversationSummaryTable
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.table.StudioBotConversationTable
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.table.StudioBotMessageTable
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface StudioBotConversationsDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertConversation(conversation: StudioBotConversationTable)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertMessage(message: StudioBotMessageTable)
+
+ @Query(
+ """
+ SELECT * FROM `Studio Bot Messages`
+ WHERE conversationOwnerId = :conversationId
+ ORDER BY createdAt ASC
+ """
+ )
+ fun observeMessages(conversationId: Long): Flow>
+
+ @Query(
+ """
+ DELETE FROM `Studio Bot Conversations`
+ WHERE conversationId = :conversationId
+ """
+ )
+ suspend fun deleteConversation(conversationId: Long)
+
+ @Query(
+ """
+ SELECT
+ conversations.conversationId AS conversationId,
+ conversations.title AS title,
+ conversations.updatedAt AS updatedAt,
+ (
+ SELECT messages.text FROM `Studio Bot Messages` AS messages
+ WHERE messages.conversationOwnerId = conversations.conversationId
+ ORDER BY messages.createdAt DESC
+ LIMIT 1
+ ) AS lastMessageText,
+ (
+ SELECT COUNT(*) FROM `Studio Bot Messages` AS messages
+ WHERE messages.conversationOwnerId = conversations.conversationId
+ ) AS messageCount
+ FROM `Studio Bot Conversations` AS conversations
+ ORDER BY conversations.updatedAt DESC
+ """
+ )
+ fun observeConversations(): Flow>
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/model/ConversationSummaryTable.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/model/ConversationSummaryTable.kt
new file mode 100644
index 00000000..21009c42
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/model/ConversationSummaryTable.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.model
+
+data class ConversationSummaryTable(
+ val conversationId: Long,
+ val title: String,
+ val updatedAt: Long,
+ val lastMessageText: String?,
+ val messageCount: Int,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/table/StudioBotConversationTable.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/table/StudioBotConversationTable.kt
new file mode 100644
index 00000000..300a7b09
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/table/StudioBotConversationTable.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.table
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "Studio Bot Conversations")
+data class StudioBotConversationTable(
+ @PrimaryKey val conversationId: Long,
+ val title: String,
+ val updatedAt: Long,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/table/StudioBotMessageTable.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/table/StudioBotMessageTable.kt
new file mode 100644
index 00000000..9b083b7a
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/local/database/table/StudioBotMessageTable.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.table
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "Studio Bot Messages",
+ foreignKeys = [
+ ForeignKey(
+ entity = StudioBotConversationTable::class,
+ parentColumns = ["conversationId"],
+ childColumns = ["conversationOwnerId"],
+ onDelete = ForeignKey.CASCADE,
+ )
+ ],
+ indices = [Index("conversationOwnerId")],
+)
+data class StudioBotMessageTable(
+ @PrimaryKey val messageId: Long,
+ val conversationOwnerId: Long,
+ val text: String,
+ val isBot: Boolean,
+ val createdAt: Long,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/repository/StudioBotConversationsRepositoryImpl.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/repository/StudioBotConversationsRepositoryImpl.kt
new file mode 100644
index 00000000..dff6ee4c
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/data/repository/StudioBotConversationsRepositoryImpl.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.data.repository
+
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.dao.StudioBotConversationsDao
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.table.StudioBotConversationTable
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.table.StudioBotMessageTable
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.model.StudioBotConversation
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.repository.StudioBotConversationsRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+class StudioBotConversationsRepositoryImpl(
+ private val conversationsDao: StudioBotConversationsDao,
+) : StudioBotConversationsRepository {
+
+ override suspend fun ensureConversation(conversationId: Long, title: String) {
+ conversationsDao.upsertConversation(
+ StudioBotConversationTable(
+ conversationId = conversationId,
+ title = title,
+ updatedAt = System.currentTimeMillis(),
+ )
+ )
+ }
+
+ override suspend fun saveMessage(conversationId: Long, message: StudioBotMessage) {
+ val now = System.currentTimeMillis()
+ conversationsDao.upsertConversation(
+ StudioBotConversationTable(
+ conversationId = conversationId,
+ title = DEFAULT_CONVERSATION_TITLE,
+ updatedAt = now,
+ )
+ )
+ conversationsDao.upsertMessage(
+ StudioBotMessageTable(
+ messageId = message.id,
+ conversationOwnerId = conversationId,
+ text = message.text,
+ isBot = message.isBot,
+ createdAt = now,
+ )
+ )
+ }
+
+ override suspend fun deleteConversation(conversationId: Long) {
+ conversationsDao.deleteConversation(conversationId = conversationId)
+ }
+
+ override fun observeMessages(conversationId: Long): Flow> =
+ conversationsDao.observeMessages(conversationId).map { messages ->
+ messages.map { message ->
+ StudioBotMessage(
+ id = message.messageId,
+ text = message.text,
+ isBot = message.isBot,
+ )
+ }
+ }
+
+ override fun observeConversations(): Flow> =
+ conversationsDao.observeConversations().map { conversations ->
+ conversations.map { conversation ->
+ StudioBotConversation(
+ id = conversation.conversationId,
+ title = conversation.title,
+ updatedAt = conversation.updatedAt,
+ lastMessageText = conversation.lastMessageText.orEmpty(),
+ messageCount = conversation.messageCount,
+ )
+ }
+ }
+
+ private companion object {
+ const val DEFAULT_CONVERSATION_TITLE: String = "Studio Bot"
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/model/StudioBotConversation.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/model/StudioBotConversation.kt
new file mode 100644
index 00000000..7667e9c6
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/model/StudioBotConversation.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.domain.model
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class StudioBotConversation(
+ val id: Long,
+ val title: String,
+ val updatedAt: Long,
+ val lastMessageText: String,
+ val messageCount: Int,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/repository/StudioBotConversationsRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/repository/StudioBotConversationsRepository.kt
new file mode 100644
index 00000000..1bfa1f50
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/repository/StudioBotConversationsRepository.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.domain.repository
+
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.model.StudioBotConversation
+import kotlinx.coroutines.flow.Flow
+
+interface StudioBotConversationsRepository {
+ suspend fun ensureConversation(conversationId: Long, title: String)
+ suspend fun saveMessage(conversationId: Long, message: StudioBotMessage)
+ suspend fun deleteConversation(conversationId: Long)
+ fun observeMessages(conversationId: Long): Flow>
+ fun observeConversations(): Flow>
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/DeleteConversationUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/DeleteConversationUseCase.kt
new file mode 100644
index 00000000..2ad5d301
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/DeleteConversationUseCase.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases
+
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.repository.StudioBotConversationsRepository
+
+class DeleteConversationUseCase(
+ private val repository: StudioBotConversationsRepository,
+) {
+ suspend operator fun invoke(conversationId: Long) {
+ repository.deleteConversation(conversationId = conversationId)
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/EnsureConversationUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/EnsureConversationUseCase.kt
new file mode 100644
index 00000000..2a723ab2
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/EnsureConversationUseCase.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases
+
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.repository.StudioBotConversationsRepository
+
+class EnsureConversationUseCase(
+ private val repository: StudioBotConversationsRepository,
+) {
+ suspend operator fun invoke(conversationId: Long, title: String) {
+ repository.ensureConversation(conversationId = conversationId, title = title)
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/ObserveConversationMessagesUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/ObserveConversationMessagesUseCase.kt
new file mode 100644
index 00000000..696464b9
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/ObserveConversationMessagesUseCase.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases
+
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.repository.StudioBotConversationsRepository
+import kotlinx.coroutines.flow.Flow
+
+class ObserveConversationMessagesUseCase(
+ private val repository: StudioBotConversationsRepository,
+) {
+ operator fun invoke(conversationId: Long): Flow> =
+ repository.observeMessages(conversationId = conversationId)
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/ObserveConversationsUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/ObserveConversationsUseCase.kt
new file mode 100644
index 00000000..769d400e
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/ObserveConversationsUseCase.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases
+
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.model.StudioBotConversation
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.repository.StudioBotConversationsRepository
+import kotlinx.coroutines.flow.Flow
+
+class ObserveConversationsUseCase(
+ private val repository: StudioBotConversationsRepository,
+) {
+ operator fun invoke(): Flow> = repository.observeConversations()
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/SaveConversationMessageUseCase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/SaveConversationMessageUseCase.kt
new file mode 100644
index 00000000..9edf16c8
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/domain/usecases/SaveConversationMessageUseCase.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases
+
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.model.StudioBotMessage
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.repository.StudioBotConversationsRepository
+
+class SaveConversationMessageUseCase(
+ private val repository: StudioBotConversationsRepository,
+) {
+ suspend operator fun invoke(conversationId: Long, message: StudioBotMessage) {
+ repository.saveMessage(conversationId = conversationId, message = message)
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/ConversationsScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/ConversationsScreen.kt
new file mode 100644
index 00000000..e8fab5c1
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/ConversationsScreen.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.contract.ConversationsEvent
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.contract.OpenConversationAction
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.state.ConversationsUiState
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.views.ConversationSwipeToDelete
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.views.EmptyConversationsState
+import org.koin.compose.viewmodel.koinViewModel
+
+@Composable
+fun ConversationsScreen(
+ paddingValues: PaddingValues,
+ selectedConversationId: Long? = null,
+ onConversationClick: (Long) -> Unit,
+) {
+ val viewModel: ConversationsViewModel = koinViewModel()
+ val screenState by viewModel.uiState.collectAsStateWithLifecycle()
+ val uiState = screenState.data ?: ConversationsUiState()
+
+ LaunchedEffect(viewModel) {
+ viewModel.actionEvent.collect { action ->
+ when (action) {
+ is OpenConversationAction -> onConversationClick(action.conversationId)
+ }
+ }
+ }
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.Center,
+ ) {
+
+ if (uiState.conversations.isEmpty()) {
+ item {
+ EmptyConversationsState()
+ }
+ }
+
+ items(items = uiState.conversations, key = { it.id }) { conversation ->
+ ConversationSwipeToDelete(
+ conversation = conversation,
+ isSelected = selectedConversationId == conversation.id,
+ onClick = {
+ viewModel.onEvent(ConversationsEvent.ConversationClicked(conversation.id))
+ },
+ onDelete = {
+ viewModel.onEvent(ConversationsEvent.ConversationDeleteRequested(conversation.id))
+ },
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/ConversationsViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/ConversationsViewModel.kt
new file mode 100644
index 00000000..b82512eb
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/ConversationsViewModel.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.ui
+
+import androidx.lifecycle.viewModelScope
+import com.d4rk.android.libs.apptoolkit.core.domain.repository.FirebaseController
+import com.d4rk.android.libs.apptoolkit.core.ui.base.LoggedScreenViewModel
+import com.d4rk.android.libs.apptoolkit.core.ui.state.ScreenState
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.DeleteConversationUseCase
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.ObserveConversationsUseCase
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.contract.ConversationsAction
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.contract.ConversationsEvent
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.contract.OpenConversationAction
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.state.ConversationsUiState
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+class ConversationsViewModel(
+ observeConversationsUseCase: ObserveConversationsUseCase,
+ private val deleteConversationUseCase: DeleteConversationUseCase,
+ firebaseController: FirebaseController,
+) : LoggedScreenViewModel(
+ initialState = UiStateScreen(data = ConversationsUiState()),
+ firebaseController = firebaseController,
+ screenName = "ConversationsScreen",
+) {
+
+ init {
+ observeConversationsUseCase()
+ .onEach { conversations ->
+ screenState.value = screenState.value.copy(
+ screenState = ScreenState.Success(),
+ data = ConversationsUiState(conversations = conversations),
+ )
+ }
+ .launchIn(viewModelScope)
+ }
+
+ override fun handleEvent(event: ConversationsEvent) {
+ when (event) {
+ is ConversationsEvent.ConversationClicked -> {
+ sendAction(action = OpenConversationAction(conversationId = event.conversationId))
+ }
+
+ is ConversationsEvent.ConversationDeleteRequested -> {
+ viewModelScope.launch {
+ deleteConversationUseCase(conversationId = event.conversationId)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/contract/ConversationsAction.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/contract/ConversationsAction.kt
new file mode 100644
index 00000000..1e81649e
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/contract/ConversationsAction.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.ActionEvent
+
+sealed interface ConversationsAction : ActionEvent
+
+data class OpenConversationAction(
+ val conversationId: Long,
+) : ConversationsAction
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/contract/ConversationsEvent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/contract/ConversationsEvent.kt
new file mode 100644
index 00000000..4f96a444
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/contract/ConversationsEvent.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.UiEvent
+
+sealed interface ConversationsEvent : UiEvent {
+ data class ConversationClicked(val conversationId: Long) : ConversationsEvent
+ data class ConversationDeleteRequested(val conversationId: Long) : ConversationsEvent
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/state/ConversationsUiState.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/state/ConversationsUiState.kt
new file mode 100644
index 00000000..8389e00a
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/state/ConversationsUiState.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.ui.state
+
+import androidx.compose.runtime.Immutable
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.model.StudioBotConversation
+
+@Immutable
+data class ConversationsUiState(
+ val conversations: List = emptyList(),
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/views/ConversationCard.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/views/ConversationCard.kt
new file mode 100644
index 00000000..7b4dbf4f
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/views/ConversationCard.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.ui.views
+
+import android.text.format.DateUtils
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.d4rk.androidtutorials.R
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.model.StudioBotConversation
+
+@Composable
+fun ConversationCard(
+ conversation: StudioBotConversation,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+) {
+ val dateText = DateUtils.getRelativeTimeSpanString(
+ conversation.updatedAt,
+ System.currentTimeMillis(),
+ DateUtils.MINUTE_IN_MILLIS,
+ DateUtils.FORMAT_ABBREV_RELATIVE,
+ ).toString()
+
+ val cardLabel = stringResource(id = R.string.studio_bot_open_conversation, conversation.title)
+
+ Card(
+ colors = if (isSelected) {
+ CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
+ } else {
+ CardDefaults.cardColors()
+ },
+ border = if (isSelected) {
+ BorderStroke(1.dp, MaterialTheme.colorScheme.secondary)
+ } else {
+ null
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .semantics { contentDescription = cardLabel },
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = conversation.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
+ )
+ Text(
+ text = conversation.lastMessageText.ifBlank { stringResource(id = R.string.studio_bot_no_messages_yet) },
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ Text(
+ text = pluralStringResource(
+ id = R.plurals.studio_bot_message_count,
+ count = conversation.messageCount,
+ conversation.messageCount,
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ Text(
+ text = stringResource(id = R.string.studio_bot_last_updated, dateText),
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/views/ConversationSwipeToDelete.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/views/ConversationSwipeToDelete.kt
new file mode 100644
index 00000000..29b1be69
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/views/ConversationSwipeToDelete.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.ui.views
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SwipeToDismissBox
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.material3.rememberSwipeToDismissBoxState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.d4rk.androidtutorials.R
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.model.StudioBotConversation
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ConversationSwipeToDelete(
+ conversation: StudioBotConversation,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ val dismissState = rememberSwipeToDismissBoxState()
+
+ val isSwipingToDelete = dismissState.dismissDirection == SwipeToDismissBoxValue.EndToStart
+ val backgroundColor by animateColorAsState(
+ targetValue = if (isSwipingToDelete) {
+ MaterialTheme.colorScheme.errorContainer
+ } else {
+ Color.Transparent
+ },
+ animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing),
+ label = "deleteBackground",
+ )
+
+ val iconScale = 0.9f + (dismissState.progress * 0.25f)
+
+ SwipeToDismissBox(
+ state = dismissState,
+ modifier = Modifier.fillMaxWidth(),
+ onDismiss = { dismissValue ->
+ if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
+ onDelete()
+ }
+ },
+ backgroundContent = {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(MaterialTheme.shapes.medium)
+ .background(backgroundColor)
+ .padding(end = 20.dp),
+ contentAlignment = Alignment.CenterEnd,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Delete,
+ contentDescription = stringResource(id = R.string.studio_bot_delete_conversation),
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.scale(iconScale),
+ )
+ }
+ },
+ ) {
+ ConversationCard(
+ conversation = conversation,
+ isSelected = isSelected,
+ onClick = onClick,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/views/EmptyConversationsState.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/views/EmptyConversationsState.kt
new file mode 100644
index 00000000..a9fad860
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/conversations/ui/views/EmptyConversationsState.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.conversations.ui.views
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ChatBubbleOutline
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.d4rk.androidtutorials.R
+
+@Composable
+fun EmptyConversationsState() {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.ChatBubbleOutline,
+ contentDescription = stringResource(id = R.string.studio_bot_conversations_empty_title),
+ modifier = Modifier.size(28.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = stringResource(id = R.string.studio_bot_conversations_empty_title),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ text = stringResource(id = R.string.studio_bot_conversations_empty_description),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotScreen.kt
new file mode 100644
index 00000000..694f0158
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotScreen.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.d4rk.androidtutorials.R
+import com.d4rk.androidtutorials.app.studiobot.chat.ui.StudioBotScreen
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.ConversationsScreen
+import com.d4rk.androidtutorials.app.studiobot.ui.contract.StudiobotEvent
+import com.d4rk.androidtutorials.app.studiobot.ui.state.StudiobotUiState
+import org.koin.compose.viewmodel.koinViewModel
+
+@Composable
+fun StudiobotScreenRoute(
+ paddingValues: PaddingValues,
+) {
+ val viewModel: StudiobotViewModel = koinViewModel()
+ val screenState by viewModel.uiState.collectAsStateWithLifecycle()
+ val uiState = screenState.data ?: StudiobotUiState()
+ val isTwoPane = LocalConfiguration.current.screenWidthDp >= 840 // FIXME: Using Configuration.screenWidthDp instead of LocalWindowInfo.current.containerSize
+
+ if (!uiState.hasAcceptedTerms) {
+ AlertDialog(
+ onDismissRequest = {},
+ title = { Text(text = stringResource(id = R.string.studio_bot_terms_title)) },
+ text = { Text(text = stringResource(id = R.string.studio_bot_terms_message)) },
+ confirmButton = {
+ Button(onClick = { viewModel.onEvent(StudiobotEvent.AcceptTerms) }) {
+ Text(text = stringResource(id = R.string.accept))
+ }
+ },
+ )
+ return
+ }
+
+ val selectedConversationId = uiState.selectedConversationId
+
+ if (isTwoPane) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Box(modifier = Modifier.weight(0.4f)) {
+ ConversationsScreen(
+ paddingValues = PaddingValues(0.dp),
+ selectedConversationId = selectedConversationId,
+ onConversationClick = { viewModel.onEvent(StudiobotEvent.SelectConversation(it)) },
+ )
+ }
+ Box(modifier = Modifier.weight(0.6f)) {
+ selectedConversationId?.let { conversationId ->
+ StudioBotScreen(
+ paddingValues = PaddingValues(0.dp),
+ conversationId = conversationId,
+ )
+ }
+ }
+ }
+ } else {
+ val showChat = selectedConversationId != null
+ BackHandler(enabled = showChat) {
+ viewModel.onEvent(StudiobotEvent.ShowConversations)
+ }
+
+ if (showChat) {
+ StudioBotScreen(
+ paddingValues = paddingValues,
+ conversationId = selectedConversationId,
+ onBackToConversations = {
+ viewModel.onEvent(StudiobotEvent.ShowConversations)
+ },
+ )
+ } else {
+ ConversationsScreen(
+ paddingValues = paddingValues,
+ selectedConversationId = selectedConversationId,
+ onConversationClick = { viewModel.onEvent(StudiobotEvent.SelectConversation(it)) },
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotViewModel.kt
new file mode 100644
index 00000000..0f122738
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/StudiobotViewModel.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.ui
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.lifecycle.viewModelScope
+import com.d4rk.android.libs.apptoolkit.core.domain.repository.FirebaseController
+import com.d4rk.android.libs.apptoolkit.core.ui.base.LoggedScreenViewModel
+import com.d4rk.android.libs.apptoolkit.core.ui.state.ScreenState
+import com.d4rk.android.libs.apptoolkit.core.ui.state.UiStateScreen
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.EnsureConversationUseCase
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.ObserveConversationsUseCase
+import com.d4rk.androidtutorials.app.studiobot.ui.contract.StudiobotAction
+import com.d4rk.androidtutorials.app.studiobot.ui.contract.StudiobotEvent
+import com.d4rk.androidtutorials.app.studiobot.ui.state.StudiobotUiState
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class StudiobotViewModel(
+ private val observeConversationsUseCase: ObserveConversationsUseCase,
+ private val ensureConversationUseCase: EnsureConversationUseCase,
+ private val preferences: SharedPreferences,
+ firebaseController: FirebaseController,
+) : LoggedScreenViewModel(
+ initialState = UiStateScreen(data = StudiobotUiState()),
+ firebaseController = firebaseController,
+ screenName = "StudiobotHostScreen",
+) {
+
+ init {
+ val acceptedTerms = preferences.getBoolean(KEY_ACCEPTED_TERMS, false)
+ screenState.value = screenState.value.copy(data = StudiobotUiState(hasAcceptedTerms = acceptedTerms))
+
+ observeConversationsUseCase.invoke()
+ .onEach { conversations ->
+ val currentData = screenState.value.data ?: StudiobotUiState()
+ val selectedConversation = currentData.selectedConversationId
+ ?.takeIf { selectedId -> conversations.any { it.id == selectedId } }
+
+ screenState.value = screenState.value.copy(
+ screenState = ScreenState.Success(),
+ data = currentData.copy(
+ conversations = conversations,
+ selectedConversationId = selectedConversation,
+ ),
+ )
+ }
+ .launchIn(viewModelScope)
+ }
+
+ override fun handleEvent(event: StudiobotEvent) {
+ when (event) {
+ StudiobotEvent.AcceptTerms -> acceptTerms()
+ StudiobotEvent.CreateConversation -> createConversation()
+ is StudiobotEvent.SelectConversation -> selectConversation(event.conversationId)
+ StudiobotEvent.ShowConversations -> selectConversation(conversationId = null)
+ }
+ }
+
+ private fun acceptTerms() {
+ preferences.edit { putBoolean(KEY_ACCEPTED_TERMS, true) }
+ val currentData = screenState.value.data ?: StudiobotUiState()
+ screenState.value = screenState.value.copy(data = currentData.copy(hasAcceptedTerms = true))
+ }
+
+ private fun createConversation() {
+ viewModelScope.launch {
+ val conversationId = System.currentTimeMillis()
+ ensureConversationUseCase(
+ conversationId = conversationId,
+ title = buildConversationTitle(conversationId = conversationId),
+ )
+ selectConversation(conversationId)
+ }
+ }
+
+ private fun buildConversationTitle(conversationId: Long): String {
+ val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
+ val timestamp = formatter.format(Date(conversationId))
+ return "Conversation $timestamp"
+ }
+
+ private fun selectConversation(conversationId: Long?) {
+ val currentData = screenState.value.data ?: StudiobotUiState()
+ screenState.value = screenState.value.copy(data = currentData.copy(selectedConversationId = conversationId))
+ }
+
+ private companion object {
+ const val KEY_ACCEPTED_TERMS: String = "studio_bot_accepted_terms"
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/contract/StudiobotAction.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/contract/StudiobotAction.kt
new file mode 100644
index 00000000..4fa15371
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/contract/StudiobotAction.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.ActionEvent
+
+sealed interface StudiobotAction : ActionEvent
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/contract/StudiobotEvent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/contract/StudiobotEvent.kt
new file mode 100644
index 00000000..3da2ce00
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/contract/StudiobotEvent.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.ui.contract
+
+import com.d4rk.android.libs.apptoolkit.core.ui.base.handling.UiEvent
+
+sealed interface StudiobotEvent : UiEvent {
+ data object AcceptTerms : StudiobotEvent
+ data object CreateConversation : StudiobotEvent
+ data class SelectConversation(val conversationId: Long) : StudiobotEvent
+ data object ShowConversations : StudiobotEvent
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/navigation/StudioBotEntryBuilder.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/navigation/StudioBotEntryBuilder.kt
new file mode 100644
index 00000000..ee8f2fc2
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/navigation/StudioBotEntryBuilder.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.ui.navigation
+
+import com.d4rk.android.libs.apptoolkit.core.ui.navigation.NavigationEntryBuilder
+import com.d4rk.androidtutorials.app.main.ui.views.navigation.AppNavigationEntryContext
+import com.d4rk.androidtutorials.app.main.utils.constants.AppNavKey
+import com.d4rk.androidtutorials.app.main.utils.constants.StudioBotRoute
+import com.d4rk.androidtutorials.app.studiobot.ui.StudiobotScreenRoute
+
+fun studioBotEntryBuilder(
+ context: AppNavigationEntryContext,
+): NavigationEntryBuilder = {
+ entry {
+ StudiobotScreenRoute(
+ paddingValues = context.paddingValues,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/state/StudiobotUiState.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/state/StudiobotUiState.kt
new file mode 100644
index 00000000..d84f2057
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/app/studiobot/ui/state/StudiobotUiState.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.app.studiobot.ui.state
+
+import androidx.compose.runtime.Immutable
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.model.StudioBotConversation
+
+@Immutable
+data class StudiobotUiState(
+ val hasAcceptedTerms: Boolean = false,
+ val selectedConversationId: Long? = null,
+ val conversations: List = emptyList(),
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/data/local/datastore/DataStore.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/data/local/datastore/DataStore.kt
new file mode 100644
index 00000000..5c30214d
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/data/local/datastore/DataStore.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.data.local.datastore
+
+import android.content.Context
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.DispatcherProvider
+import com.d4rk.android.libs.apptoolkit.core.data.local.datastore.CommonDataStore
+import com.d4rk.androidtutorials.BuildConfig
+
+class DataStore(
+ context: Context,
+ dispatchers: DispatcherProvider,
+ defaultAdsEnabled: Boolean = !BuildConfig.DEBUG,
+) : CommonDataStore(context, dispatchers, defaultAdsEnabled)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/KoinModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/KoinModule.kt
new file mode 100644
index 00000000..ed4389e4
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/KoinModule.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di
+
+import android.content.Context
+import com.d4rk.androidtutorials.core.di.modules.app.modules.adsModule
+import com.d4rk.androidtutorials.core.di.modules.app.modules.appModule
+import com.d4rk.androidtutorials.core.di.modules.app.modules.consentModule
+import com.d4rk.androidtutorials.core.di.modules.app.modules.lessonDetailsModule
+import com.d4rk.androidtutorials.core.di.modules.app.modules.lessonsModule
+import com.d4rk.androidtutorials.core.di.modules.app.modules.onboardingModule
+import com.d4rk.androidtutorials.core.di.modules.app.modules.studioBotModule
+import com.d4rk.androidtutorials.core.di.modules.apptoolkit.appToolkitModules
+import com.d4rk.androidtutorials.core.di.modules.core.modules.coreModule
+import com.d4rk.androidtutorials.core.di.modules.core.modules.dispatchersModule
+import com.d4rk.androidtutorials.core.di.modules.settings.modules.themeModule
+import com.d4rk.androidtutorials.core.di.modules.settings.settingsModules
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.startKoin
+
+fun initializeKoin(context: Context) {
+ startKoin {
+ androidContext(androidContext = context)
+ modules(
+ modules = buildList {
+ add(dispatchersModule)
+ add(coreModule)
+ add(appModule)
+ add(lessonsModule)
+ add(lessonDetailsModule)
+ add(studioBotModule)
+ add(consentModule)
+ addAll(settingsModules)
+ add(adsModule)
+ addAll(appToolkitModules)
+ add(themeModule)
+ add(onboardingModule)
+ }
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AdsModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AdsModule.kt
new file mode 100644
index 00000000..b07e4f4d
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AdsModule.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.app.modules
+
+import com.d4rk.android.libs.apptoolkit.app.ads.data.repository.AdsSettingsRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.ads.domain.repository.AdsSettingsRepository
+import com.d4rk.android.libs.apptoolkit.app.ads.domain.usecases.ObserveAdsEnabledUseCase
+import com.d4rk.android.libs.apptoolkit.app.ads.domain.usecases.SetAdsEnabledUseCase
+import com.d4rk.android.libs.apptoolkit.app.ads.ui.AdsSettingsViewModel
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.BuildInfoProvider
+import com.d4rk.android.libs.apptoolkit.core.ui.model.ads.AdsConfig
+import com.d4rk.androidtutorials.core.utils.constants.ads.AdsConstants
+import com.google.android.gms.ads.AdSize
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+val adsModule: Module = module {
+
+ single { AdsSettingsRepositoryImpl(dataStore = get(), buildInfoProvider = get(), firebaseController = get()) }
+ single { ObserveAdsEnabledUseCase(repo = get(), firebaseController = get()) }
+ single { SetAdsEnabledUseCase(repo = get(), firebaseController = get()) }
+
+ viewModel {
+ AdsSettingsViewModel(
+ repository = get(),
+ dispatchers = get(),
+ observeAdsEnabled = get(),
+ setAdsEnabled = get(),
+ requestConsentUseCase = get(),
+ firebaseController = get(),
+ )
+ }
+
+ single {
+ AdsConfig(bannerAdUnitId = AdsConstants.BANNER_AD_UNIT_ID)
+ }
+
+ single(named(name = "native_ad")) {
+ AdsConfig(bannerAdUnitId = AdsConstants.NATIVE_AD_UNIT_ID)
+ }
+
+ single(named(name = "apps_list_native_ad")) {
+ AdsConfig(bannerAdUnitId = AdsConstants.APPS_LIST_NATIVE_AD_UNIT_ID)
+ }
+
+ single(named(name = "app_details_native_ad")) {
+ AdsConfig(bannerAdUnitId = AdsConstants.APP_DETAILS_NATIVE_AD_UNIT_ID)
+ }
+
+ single(named(name = "no_data_native_ad")) {
+ AdsConfig(bannerAdUnitId = AdsConstants.NO_DATA_NATIVE_AD_UNIT_ID)
+ }
+
+ single(named(name = "bottom_nav_bar_native_ad")) {
+ AdsConfig(bannerAdUnitId = AdsConstants.BOTTOM_NAV_BAR_NATIVE_AD_UNIT_ID)
+ }
+
+ single(named(name = "help_large_banner_ad")) {
+ AdsConfig(
+ bannerAdUnitId = AdsConstants.HELP_LARGE_BANNER_AD_UNIT_ID,
+ adSize = AdSize.LARGE_BANNER
+ )
+ }
+
+ single(named(name = "banner_medium_rectangle")) {
+ AdsConfig(
+ bannerAdUnitId = AdsConstants.SUPPORT_MEDIUM_RECTANGLE_BANNER_AD_UNIT_ID,
+ adSize = AdSize.MEDIUM_RECTANGLE,
+ )
+ }
+
+ single(named(name = "support_native_ad")) {
+ AdsConfig(bannerAdUnitId = AdsConstants.SUPPORT_NATIVE_AD_UNIT_ID)
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AppModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AppModule.kt
new file mode 100644
index 00000000..5b5b2bfc
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/AppModule.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.app.modules
+
+import com.d4rk.android.libs.apptoolkit.app.main.data.repository.InAppUpdateRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.main.domain.repository.InAppUpdateRepository
+import com.d4rk.android.libs.apptoolkit.app.main.domain.repository.NavigationRepository
+import com.d4rk.android.libs.apptoolkit.app.main.domain.usecases.RequestInAppUpdateUseCase
+import com.d4rk.android.libs.apptoolkit.app.main.ui.factory.GmsHostFactory
+import com.d4rk.android.libs.apptoolkit.app.review.domain.usecases.RequestInAppReviewUseCase
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.api.ApiLanguages
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.boolean.toApiEnvironment
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.string.developerAppsApiUrl
+import com.d4rk.androidtutorials.BuildConfig
+import com.d4rk.androidtutorials.app.main.data.repository.MainNavigationRepositoryImpl
+import com.d4rk.androidtutorials.app.main.domain.usecases.GetNavigationDrawerItemsUseCase
+import com.d4rk.androidtutorials.app.main.ui.MainViewModel
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+val appModule: Module = module {
+ single { GmsHostFactory() }
+ single { MainNavigationRepositoryImpl(firebaseController = get()) }
+ single {
+ GetNavigationDrawerItemsUseCase(navigationRepository = get())
+ }
+ single { InAppUpdateRepositoryImpl() }
+ single { RequestInAppUpdateUseCase(repository = get()) }
+ viewModel {
+ MainViewModel(
+ getNavigationDrawerItemsUseCase = get(),
+ requestConsentUseCase = get(),
+ requestInAppReviewUseCase = get(),
+ requestInAppUpdateUseCase = get(),
+ firebaseController = get(),
+ dispatchers = get(),
+ )
+ }
+
+ single(qualifier = named(name = "developer_apps_api_url")) {
+ val environment = BuildConfig.DEBUG.toApiEnvironment()
+ environment.developerAppsApiUrl(language = ApiLanguages.DEFAULT)
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/ConsentModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/ConsentModule.kt
new file mode 100644
index 00000000..9f60cc35
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/ConsentModule.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.app.modules
+
+import com.d4rk.android.libs.apptoolkit.app.consent.data.local.ConsentPreferencesDataSource
+import com.d4rk.android.libs.apptoolkit.app.consent.data.remote.datasource.ConsentRemoteDataSource
+import com.d4rk.android.libs.apptoolkit.app.consent.data.remote.datasource.UmpConsentRemoteDataSource
+import com.d4rk.android.libs.apptoolkit.app.consent.data.repository.ConsentRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.consent.domain.repository.ConsentRepository
+import com.d4rk.android.libs.apptoolkit.app.consent.domain.usecases.ApplyConsentSettingsUseCase
+import com.d4rk.android.libs.apptoolkit.app.consent.domain.usecases.ApplyInitialConsentUseCase
+import com.d4rk.android.libs.apptoolkit.app.consent.domain.usecases.RequestConsentUseCase
+import com.d4rk.android.libs.apptoolkit.core.data.local.datastore.CommonDataStore
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+val consentModule: Module = module {
+ single { get() }
+ single { UmpConsentRemoteDataSource() }
+ single {
+ ConsentRepositoryImpl(
+ remote = get(),
+ local = get(),
+ configProvider = get(),
+ firebaseController = get(),
+ )
+ }
+ single {
+ RequestConsentUseCase(
+ repository = get(),
+ firebaseController = get()
+ )
+ }
+ single {
+ ApplyInitialConsentUseCase(
+ repository = get(),
+ firebaseController = get()
+ )
+ }
+ single {
+ ApplyConsentSettingsUseCase(
+ repository = get(),
+ firebaseController = get()
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonDetailsModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonDetailsModule.kt
new file mode 100644
index 00000000..6e7f948a
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonDetailsModule.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.app.modules
+
+import com.d4rk.androidtutorials.app.lessons.details.data.remote.LessonRemoteDataSource
+import com.d4rk.androidtutorials.app.lessons.details.data.remote.repository.LessonRepositoryImpl
+import com.d4rk.androidtutorials.app.lessons.details.domain.repository.LessonRepository
+import com.d4rk.androidtutorials.app.lessons.details.domain.usecases.GetLessonUseCase
+import com.d4rk.androidtutorials.app.lessons.details.ui.LessonViewModel
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+val lessonDetailsModule: Module = module {
+ single {
+ LessonRemoteDataSource(
+ client = get(),
+ jsonParser = get(named("lessons_json_parser")),
+ )
+ }
+
+ single {
+ LessonRepositoryImpl(
+ remoteDataSource = get(),
+ )
+ }
+
+ single { GetLessonUseCase(repository = get()) }
+
+ viewModel {
+ LessonViewModel(
+ getLessonUseCase = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonsModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonsModule.kt
new file mode 100644
index 00000000..49928671
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/LessonsModule.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.app.modules
+
+import android.content.Context
+import android.database.sqlite.SQLiteException
+import android.util.Log
+import androidx.room.Room
+import com.d4rk.android.libs.apptoolkit.core.domain.repository.FirebaseController
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.FavoritesDatabase
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.migrations.MIGRATION_1_2
+import com.d4rk.androidtutorials.app.lessons.listing.data.local.database.migrations.MIGRATION_2_3
+import com.d4rk.androidtutorials.app.lessons.listing.data.remote.KtorListingDataSource
+import com.d4rk.androidtutorials.app.lessons.listing.data.remote.ListingDataSource
+import com.d4rk.androidtutorials.app.lessons.listing.data.repository.ListingRepositoryImpl
+import com.d4rk.androidtutorials.app.lessons.listing.domain.repository.ListingRepository
+import com.d4rk.androidtutorials.app.lessons.listing.domain.usecases.GetListingLessonsUseCase
+import com.d4rk.androidtutorials.app.lessons.listing.domain.usecases.ObserveFavoriteLessonsUseCase
+import com.d4rk.androidtutorials.app.lessons.listing.domain.usecases.ToggleFavoriteLessonUseCase
+import com.d4rk.androidtutorials.app.lessons.listing.ui.ListingViewModel
+import kotlinx.serialization.json.Json
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+val lessonsModule: Module = module {
+
+ single(qualifier = named("lessons_json_parser")) {
+ Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ }
+ }
+
+ single {
+ provideFavoritesDatabase(
+ context = get(),
+ firebaseController = get(),
+ )
+ }
+
+ single { get().favoriteLessonsDao() }
+
+ single {
+ KtorListingDataSource(
+ client = get(),
+ )
+ }
+
+ single {
+ ListingRepositoryImpl(
+ remoteDataSource = get(),
+ jsonParser = get(named("lessons_json_parser")),
+ favoriteLessonsDao = get(),
+ )
+ }
+
+ single { GetListingLessonsUseCase(repository = get()) }
+ single { ObserveFavoriteLessonsUseCase(repository = get()) }
+ single { ToggleFavoriteLessonUseCase(repository = get()) }
+
+ viewModel {
+ ListingViewModel(
+ getListingLessonsUseCase = get(),
+ observeFavoriteLessonsUseCase = get(),
+ toggleFavoriteLessonUseCase = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+}
+
+private const val FAVORITES_DATABASE_NAME = "Android Studio Tutorials"
+
+/**
+ * On downgrade we intentionally reset the database to match old-app behavior and
+ * avoid startup crashes caused by incompatible schemas.
+ *
+ * For upgrades we keep migrations as the default path. We expose a controlled
+ * flag for global destructive fallback so future refactors can enable emergency
+ * recovery without changing every Room call site.
+ */
+private const val ENABLE_GLOBAL_DESTRUCTIVE_MIGRATION_FALLBACK = false
+
+private fun provideFavoritesDatabase(
+ context: Context,
+ firebaseController: FirebaseController,
+): FavoritesDatabase = openFavoritesDatabase(
+ context = context,
+ firebaseController = firebaseController,
+ allowReset = true,
+)
+
+private fun openFavoritesDatabase(
+ context: Context,
+ firebaseController: FirebaseController,
+ allowReset: Boolean,
+): FavoritesDatabase {
+ return runCatching {
+ buildFavoritesDatabase(context = context).also { database ->
+ // Forces an immediate open so migration errors can be handled centrally.
+ database.openHelper.writableDatabase
+ }
+ }.getOrElse { throwable ->
+ handleFavoritesDatabaseError(
+ context = context,
+ firebaseController = firebaseController,
+ throwable = throwable,
+ allowReset = allowReset,
+ )
+ }
+}
+
+private fun buildFavoritesDatabase(context: Context): FavoritesDatabase {
+ val builder = Room.databaseBuilder(
+ context = context,
+ klass = FavoritesDatabase::class.java,
+ name = FAVORITES_DATABASE_NAME,
+ ).addMigrations(MIGRATION_1_2, MIGRATION_2_3)
+ .fallbackToDestructiveMigrationOnDowngrade()
+
+ if (ENABLE_GLOBAL_DESTRUCTIVE_MIGRATION_FALLBACK) {
+ builder.fallbackToDestructiveMigration()
+ }
+
+ return builder.build()
+}
+
+private fun handleFavoritesDatabaseError(
+ context: Context,
+ firebaseController: FirebaseController,
+ throwable: Throwable,
+ allowReset: Boolean,
+): FavoritesDatabase {
+ val shouldReset = allowReset && isMigrationFailure(throwable = throwable)
+ if (!shouldReset) {
+ throw throwable
+ }
+
+ firebaseController.logBreadcrumb(
+ message = "favorites_database_reset",
+ attributes = mapOf(
+ "database" to FAVORITES_DATABASE_NAME,
+ "reason" to "migration_failure",
+ "exception" to (throwable::class.simpleName ?: "unknown"),
+ ),
+ )
+ Log.w(
+ "LessonsModule",
+ "Resetting favorites database after migration failure.",
+ throwable,
+ )
+
+ context.deleteDatabase(FAVORITES_DATABASE_NAME)
+ return openFavoritesDatabase(
+ context = context,
+ firebaseController = firebaseController,
+ allowReset = false,
+ )
+}
+
+private fun isMigrationFailure(throwable: Throwable): Boolean {
+ if (throwable is SQLiteException) {
+ return true
+ }
+
+ val migrationFailureMessages = listOf(
+ "Migration failed",
+ "Room cannot verify the data integrity",
+ "A migration from",
+ )
+
+ return throwable is IllegalStateException &&
+ migrationFailureMessages.any { message -> throwable.message?.contains(message) == true }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/OnboardingModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/OnboardingModule.kt
new file mode 100644
index 00000000..76f9eb4b
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/OnboardingModule.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.app.modules
+
+import com.d4rk.android.libs.apptoolkit.app.onboarding.data.local.OnboardingPreferencesDataSource
+import com.d4rk.android.libs.apptoolkit.app.onboarding.data.repository.OnboardingRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.onboarding.domain.repository.OnboardingRepository
+import com.d4rk.android.libs.apptoolkit.app.onboarding.domain.usecases.CompleteOnboardingUseCase
+import com.d4rk.android.libs.apptoolkit.app.onboarding.domain.usecases.ObserveOnboardingCompletionUseCase
+import com.d4rk.android.libs.apptoolkit.app.onboarding.ui.OnboardingViewModel
+import com.d4rk.android.libs.apptoolkit.app.onboarding.utils.interfaces.providers.OnboardingProvider
+import com.d4rk.android.libs.apptoolkit.core.data.local.datastore.CommonDataStore
+import com.d4rk.androidtutorials.app.onboarding.utils.interfaces.providers.AppOnboardingProvider
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val onboardingModule: Module = module {
+ single { AppOnboardingProvider() }
+ single { get() }
+ single { OnboardingRepositoryImpl(dataStore = get()) }
+ single { ObserveOnboardingCompletionUseCase(repository = get()) }
+ single { CompleteOnboardingUseCase(repository = get()) }
+
+ viewModel {
+ OnboardingViewModel(
+ observeOnboardingCompletionUseCase = get(),
+ completeOnboardingUseCase = get(),
+ requestConsentUseCase = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/StudioBotModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/StudioBotModule.kt
new file mode 100644
index 00000000..3ad71eda
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/app/modules/StudioBotModule.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.app.modules
+
+import androidx.room.Room
+import com.d4rk.androidtutorials.app.studiobot.chat.data.repository.FirebaseStudioBotRepository
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.repository.StudioBotRepository
+import com.d4rk.androidtutorials.app.studiobot.chat.domain.usecases.SendStudioBotMessageUseCase
+import com.d4rk.androidtutorials.app.studiobot.chat.ui.StudioBotViewModel
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.local.database.StudioBotConversationsDatabase
+import com.d4rk.androidtutorials.app.studiobot.conversations.data.repository.StudioBotConversationsRepositoryImpl
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.repository.StudioBotConversationsRepository
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.DeleteConversationUseCase
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.EnsureConversationUseCase
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.ObserveConversationMessagesUseCase
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.ObserveConversationsUseCase
+import com.d4rk.androidtutorials.app.studiobot.conversations.domain.usecases.SaveConversationMessageUseCase
+import com.d4rk.androidtutorials.app.studiobot.conversations.ui.ConversationsViewModel
+import com.d4rk.androidtutorials.app.studiobot.ui.StudiobotViewModel
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val studioBotModule: Module = module {
+ single {
+ get().getSharedPreferences("studio_bot_preferences", android.content.Context.MODE_PRIVATE)
+ }
+ single {
+ Room.databaseBuilder(
+ context = get(),
+ klass = StudioBotConversationsDatabase::class.java,
+ name = "Studio Bot Conversations",
+ ).build()
+ }
+ single { get().studioBotConversationsDao() }
+ single {
+ StudioBotConversationsRepositoryImpl(conversationsDao = get())
+ }
+ single { EnsureConversationUseCase(repository = get()) }
+ single { DeleteConversationUseCase(repository = get()) }
+ single { ObserveConversationMessagesUseCase(repository = get()) }
+ single { ObserveConversationsUseCase(repository = get()) }
+ single { SaveConversationMessageUseCase(repository = get()) }
+
+ single { FirebaseStudioBotRepository() }
+ single { SendStudioBotMessageUseCase(repository = get()) }
+
+ viewModel {
+ StudioBotViewModel(
+ sendStudioBotMessageUseCase = get(),
+ observeConversationMessagesUseCase = get(),
+ saveConversationMessageUseCase = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+ viewModel {
+ ConversationsViewModel(
+ observeConversationsUseCase = get(),
+ deleteConversationUseCase = get(),
+ firebaseController = get(),
+ )
+ }
+ viewModel {
+ StudiobotViewModel(
+ observeConversationsUseCase = get(),
+ ensureConversationUseCase = get(),
+ preferences = get(),
+ firebaseController = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/AppToolkitModules.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/AppToolkitModules.kt
new file mode 100644
index 00000000..5ac507fe
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/AppToolkitModules.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.apptoolkit
+
+import com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules.appToolkitCoreModule
+import com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules.helpModule
+import com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules.issueReporterModule
+import com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules.reviewModule
+import com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules.supportModule
+import org.koin.core.module.Module
+
+val appToolkitModules: List = listOf(
+ appToolkitCoreModule,
+ supportModule,
+ helpModule,
+ issueReporterModule,
+ reviewModule,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/AppToolkitCoreModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/AppToolkitCoreModule.kt
new file mode 100644
index 00000000..2e6801f3
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/AppToolkitCoreModule.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules
+
+import com.d4rk.android.libs.apptoolkit.app.startup.ui.StartupViewModel
+import com.d4rk.android.libs.apptoolkit.app.startup.utils.interfaces.providers.StartupProvider
+import com.d4rk.android.libs.apptoolkit.core.ui.model.AppVersionInfo
+import com.d4rk.androidtutorials.BuildConfig
+import com.d4rk.androidtutorials.app.startup.utils.interfaces.providers.AppStartupProvider
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val appToolkitCoreModule: Module = module {
+ single { AppStartupProvider() }
+ single { AppVersionInfo(versionName = BuildConfig.VERSION_NAME, versionCode = BuildConfig.VERSION_CODE.toLong()) }
+
+ viewModel {
+ StartupViewModel(
+ requestConsentUseCase = get(),
+ dispatchers = get(),
+ firebaseController = get()
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/HelpModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/HelpModule.kt
new file mode 100644
index 00000000..8822997e
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/HelpModule.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules
+
+import com.d4rk.android.libs.apptoolkit.app.help.data.local.HelpLocalDataSource
+import com.d4rk.android.libs.apptoolkit.app.help.data.remote.HelpRemoteDataSource
+import com.d4rk.android.libs.apptoolkit.app.help.data.repository.FaqRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.help.domain.repository.FaqRepository
+import com.d4rk.android.libs.apptoolkit.app.help.domain.usecases.GetFaqUseCase
+import com.d4rk.android.libs.apptoolkit.app.help.ui.HelpViewModel
+import com.d4rk.android.libs.apptoolkit.app.review.domain.usecases.ForceInAppReviewUseCase
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.DispatcherProvider
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.string.faqCatalogUrl
+import com.d4rk.androidtutorials.BuildConfig
+import com.d4rk.androidtutorials.core.utils.constants.api.HelpConstants
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val helpModule: Module = module {
+ single { HelpLocalDataSource(context = get()) }
+ single { HelpRemoteDataSource(client = get()) }
+ single {
+ FaqRepositoryImpl(
+ localDataSource = get(),
+ remoteDataSource = get(),
+ catalogUrl = com.d4rk.android.libs.apptoolkit.core.utils.constants.help.HelpConstants.FAQ_BASE_URL.faqCatalogUrl(
+ isDebugBuild = BuildConfig.DEBUG
+ ),
+ productId = HelpConstants.FAQ_PRODUCT_ID,
+ firebaseController = get(),
+ )
+ }
+ single { GetFaqUseCase(repository = get()) }
+
+ viewModel {
+ HelpViewModel(
+ getFaqUseCase = get(),
+ forceInAppReviewUseCase = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/IssueReporterModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/IssueReporterModule.kt
new file mode 100644
index 00000000..345de1b9
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/IssueReporterModule.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules
+
+import com.d4rk.android.libs.apptoolkit.app.issuereporter.data.local.DeviceInfoLocalDataSource
+import com.d4rk.android.libs.apptoolkit.app.issuereporter.data.remote.IssueReporterRemoteDataSource
+import com.d4rk.android.libs.apptoolkit.app.issuereporter.data.repository.IssueReporterRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.issuereporter.domain.model.github.GithubTarget
+import com.d4rk.android.libs.apptoolkit.app.issuereporter.domain.providers.DeviceInfoProvider
+import com.d4rk.android.libs.apptoolkit.app.issuereporter.domain.repository.IssueReporterRepository
+import com.d4rk.android.libs.apptoolkit.app.issuereporter.domain.usecases.SendIssueReportUseCase
+import com.d4rk.android.libs.apptoolkit.app.issuereporter.ui.IssueReporterViewModel
+import com.d4rk.android.libs.apptoolkit.core.di.GithubToken
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.github.GithubConstants
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.string.toToken
+import com.d4rk.androidtutorials.BuildConfig
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.core.qualifier.named
+import org.koin.core.qualifier.qualifier
+import org.koin.dsl.module
+
+private val githubTokenQualifier = qualifier()
+
+val issueReporterModule: Module = module {
+ single { IssueReporterRemoteDataSource(client = get()) }
+ single { DeviceInfoLocalDataSource(get(), get()) }
+ single { IssueReporterRepositoryImpl(get(), get(), get()) }
+ single { SendIssueReportUseCase(get(), get(), get()) }
+ single(qualifier = named(name = "github_repository")) { "English-with-Lidia-for-Android" }
+ single { GithubTarget(username = GithubConstants.GITHUB_USER, repository = get(qualifier = named("github_repository"))) }
+ single(qualifier = named("github_changelog")) { GithubConstants.githubChangelog(get(named("github_repository"))) }
+ single(githubTokenQualifier) { BuildConfig.GITHUB_TOKEN.toToken() }
+
+ viewModel {
+ IssueReporterViewModel(
+ sendIssueReport = get(),
+ githubTarget = get(),
+ githubToken = get(githubTokenQualifier),
+ deviceInfoProvider = get(),
+ firebaseController = get(),
+ dispatchers = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/ReviewModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/ReviewModule.kt
new file mode 100644
index 00000000..b0d8f906
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/ReviewModule.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules
+
+import com.d4rk.android.libs.apptoolkit.app.review.data.repository.ReviewRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.review.domain.repository.ReviewRepository
+import com.d4rk.android.libs.apptoolkit.app.review.domain.usecases.ForceInAppReviewUseCase
+import com.d4rk.android.libs.apptoolkit.app.review.domain.usecases.RequestInAppReviewUseCase
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+val reviewModule: Module = module {
+ single { ReviewRepositoryImpl(dataStore = get()) }
+ single { RequestInAppReviewUseCase(reviewRepository = get()) }
+ single { ForceInAppReviewUseCase(reviewRepository = get()) }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/SupportModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/SupportModule.kt
new file mode 100644
index 00000000..50f864e4
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/apptoolkit/modules/SupportModule.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.apptoolkit.modules
+
+import com.d4rk.android.libs.apptoolkit.app.support.billing.BillingRepository
+import com.d4rk.android.libs.apptoolkit.app.support.ui.SupportViewModel
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.DispatcherProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val supportModule: Module = module {
+ single(createdAtStart = true) {
+ val dispatchers = get()
+ BillingRepository.getInstance(
+ context = get(),
+ dispatchers = dispatchers,
+ firebaseController = get(),
+ externalScope = CoroutineScope(SupervisorJob() + dispatchers.io)
+ )
+ }
+
+ viewModel {
+ SupportViewModel(billingRepository = get(), firebaseController = get())
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/core/modules/CorePlatformModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/core/modules/CorePlatformModule.kt
new file mode 100644
index 00000000..fbe99ac4
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/core/modules/CorePlatformModule.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.core.modules
+
+import com.d4rk.android.libs.apptoolkit.core.data.local.datastore.CommonDataStore
+import com.d4rk.android.libs.apptoolkit.core.data.remote.ads.AdsCoreManager
+import com.d4rk.android.libs.apptoolkit.core.data.remote.client.KtorClient
+import com.d4rk.android.libs.apptoolkit.core.data.remote.firebase.FirebaseControllerImpl
+import com.d4rk.android.libs.apptoolkit.core.domain.repository.FirebaseController
+import com.d4rk.androidtutorials.BuildConfig
+import com.d4rk.androidtutorials.core.data.local.datastore.DataStore
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+val coreModule: Module = module {
+ single { DataStore(context = get(), dispatchers = get()) }
+ single { get() }
+ single { AdsCoreManager(context = get(), buildInfoProvider = get(), dispatchers = get()) }
+ single { FirebaseControllerImpl() }
+ single { KtorClient.createClient(enableLogging = BuildConfig.DEBUG) }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/core/modules/DispatchersModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/core/modules/DispatchersModule.kt
new file mode 100644
index 00000000..a878fcd6
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/core/modules/DispatchersModule.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.core.modules
+
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.DispatcherProvider
+import com.d4rk.android.libs.apptoolkit.core.coroutines.dispatchers.StandardDispatchers
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+val dispatchersModule: Module = module {
+ single { StandardDispatchers() }
+}
+
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/SettingsModules.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/SettingsModules.kt
new file mode 100644
index 00000000..eb02b61a
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/SettingsModules.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.settings
+
+import com.d4rk.androidtutorials.core.di.modules.settings.modules.aboutModule
+import com.d4rk.androidtutorials.core.di.modules.settings.modules.advancedSettingsModule
+import com.d4rk.androidtutorials.core.di.modules.settings.modules.generalSettingsModule
+import com.d4rk.androidtutorials.core.di.modules.settings.modules.permissionsModule
+import com.d4rk.androidtutorials.core.di.modules.settings.modules.settingsRootModule
+import com.d4rk.androidtutorials.core.di.modules.settings.modules.usageAndDiagnosticsModule
+import org.koin.core.module.Module
+
+val settingsModules: List = listOf(
+ settingsRootModule,
+ aboutModule,
+ advancedSettingsModule,
+ generalSettingsModule,
+ permissionsModule,
+ usageAndDiagnosticsModule,
+)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/AboutModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/AboutModule.kt
new file mode 100644
index 00000000..a0b6ea0c
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/AboutModule.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.settings.modules
+
+import com.d4rk.android.libs.apptoolkit.app.about.data.repository.AboutRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.about.domain.repository.AboutRepository
+import com.d4rk.android.libs.apptoolkit.app.about.domain.usecases.CopyDeviceInfoUseCase
+import com.d4rk.android.libs.apptoolkit.app.about.domain.usecases.GetAboutInfoUseCase
+import com.d4rk.android.libs.apptoolkit.app.about.ui.AboutViewModel
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.AboutSettingsProvider
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.BuildInfoProvider
+import com.d4rk.androidtutorials.app.settings.settings.utils.providers.AppAboutSettingsProvider
+import com.d4rk.androidtutorials.app.settings.settings.utils.providers.AppBuildInfoProvider
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val aboutModule: Module = module {
+ single { AppAboutSettingsProvider(context = get()) }
+ single { AppBuildInfoProvider() }
+ single { AboutRepositoryImpl(deviceProvider = get(), buildInfoProvider = get(), context = get(), firebaseController = get()) }
+ single { GetAboutInfoUseCase(repository = get(), firebaseController = get()) }
+ single { CopyDeviceInfoUseCase(repository = get(), firebaseController = get()) }
+
+ viewModel {
+ AboutViewModel(
+ getAboutInfo = get(),
+ copyDeviceInfo = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/AdvancedSettingsModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/AdvancedSettingsModule.kt
new file mode 100644
index 00000000..83e75063
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/AdvancedSettingsModule.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.settings.modules
+
+import com.d4rk.android.libs.apptoolkit.app.advanced.data.repository.CacheRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.advanced.domain.repository.CacheRepository
+import com.d4rk.android.libs.apptoolkit.app.advanced.ui.AdvancedSettingsViewModel
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.AdvancedSettingsProvider
+import com.d4rk.androidtutorials.app.settings.settings.utils.providers.AppAdvancedSettingsProvider
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val advancedSettingsModule: Module = module {
+ single { AppAdvancedSettingsProvider(context = get()) }
+ single { CacheRepositoryImpl(context = get(), firebaseController = get()) }
+
+ viewModel {
+ AdvancedSettingsViewModel(
+ repository = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/GeneralSettingsModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/GeneralSettingsModule.kt
new file mode 100644
index 00000000..b9fc066e
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/GeneralSettingsModule.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.settings.modules
+
+import com.d4rk.android.libs.apptoolkit.app.settings.general.data.repository.GeneralSettingsRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.settings.general.domain.repository.GeneralSettingsRepository
+import com.d4rk.android.libs.apptoolkit.app.settings.general.ui.GeneralSettingsViewModel
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.DisplaySettingsProvider
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.GeneralSettingsContentProvider
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.providers.PrivacySettingsProvider
+import com.d4rk.androidtutorials.app.settings.settings.utils.providers.AppDisplaySettingsProvider
+import com.d4rk.androidtutorials.app.settings.settings.utils.providers.AppPrivacySettingsProvider
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val generalSettingsModule: Module = module {
+ single { AppDisplaySettingsProvider(context = get()) }
+ single { GeneralSettingsContentProvider() }
+ single { AppPrivacySettingsProvider(context = get()) }
+ single { GeneralSettingsRepositoryImpl(firebaseController = get()) }
+
+ viewModel {
+ GeneralSettingsViewModel(
+ repository = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/PermissionsModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/PermissionsModule.kt
new file mode 100644
index 00000000..aa15c03f
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/PermissionsModule.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.settings.modules
+
+import com.d4rk.android.libs.apptoolkit.app.permissions.data.repository.PermissionsRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.permissions.domain.repository.PermissionsRepository
+import com.d4rk.android.libs.apptoolkit.app.permissions.ui.PermissionsViewModel
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val permissionsModule: Module = module {
+ single { PermissionsRepositoryImpl(context = get(), dispatchers = get(), firebaseController = get()) }
+
+ viewModel {
+ PermissionsViewModel(
+ permissionsRepository = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/SettingsRootModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/SettingsRootModule.kt
new file mode 100644
index 00000000..a8fe8bb3
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/SettingsRootModule.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.settings.modules
+
+import com.d4rk.android.libs.apptoolkit.app.settings.settings.ui.SettingsViewModel
+import com.d4rk.android.libs.apptoolkit.app.settings.utils.interfaces.SettingsProvider
+import com.d4rk.androidtutorials.app.settings.settings.utils.providers.AppSettingsProvider
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val settingsRootModule: Module = module {
+ single { AppSettingsProvider() }
+
+ viewModel {
+ SettingsViewModel(
+ settingsProvider = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/ThemeModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/ThemeModule.kt
new file mode 100644
index 00000000..d488c4d3
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/ThemeModule.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.settings.modules
+
+import com.d4rk.android.libs.apptoolkit.app.theme.ui.style.colors.ColorPalette
+import com.d4rk.android.libs.apptoolkit.app.theme.ui.style.colors.google.android.androidPalette
+import org.koin.core.module.Module
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+val themeModule: Module = module {
+ single(named("androidPalette")) { androidPalette }
+
+ single { get(named("androidPalette")) }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/UsageAndDiagnosticsModule.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/UsageAndDiagnosticsModule.kt
new file mode 100644
index 00000000..673ef840
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/di/modules/settings/modules/UsageAndDiagnosticsModule.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.di.modules.settings.modules
+
+import com.d4rk.android.libs.apptoolkit.app.diagnostics.data.repository.UsageAndDiagnosticsRepositoryImpl
+import com.d4rk.android.libs.apptoolkit.app.diagnostics.domain.repository.UsageAndDiagnosticsRepository
+import com.d4rk.android.libs.apptoolkit.app.diagnostics.ui.UsageAndDiagnosticsViewModel
+import com.d4rk.android.libs.apptoolkit.core.data.local.datastore.CommonDataStore
+import org.koin.core.module.Module
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+
+val usageAndDiagnosticsModule: Module = module {
+ single {
+ UsageAndDiagnosticsRepositoryImpl(
+ dataSource = get(),
+ configProvider = get(),
+ dispatchers = get(),
+ firebaseController = get(),
+ )
+ }
+
+ viewModel {
+ UsageAndDiagnosticsViewModel(
+ repository = get(),
+ firebaseController = get(),
+ dispatchers = get(),
+ applyConsentSettingsUseCase = get(),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/domain/model/network/AppErrors.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/domain/model/network/AppErrors.kt
new file mode 100644
index 00000000..bee7ccfe
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/domain/model/network/AppErrors.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.domain.model.network
+
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.Error
+import com.d4rk.android.libs.apptoolkit.core.domain.model.network.Errors
+
+/**
+ * App-specific error surface.
+ *
+ * The app can emit its own errors (e.g., developer apps list) while still allowing shared
+ * toolkit errors to flow through unchanged via [Common]. This keeps the app extensible without
+ * duplicating the shared error taxonomy.
+ */
+
+/**
+ * App-specific error surface.
+ *
+ * - [Common] wraps AppToolkit [Errors] unchanged.
+ * - [UseCase] covers app/feature-specific situations where toolkit taxonomy is too generic.
+ */
+sealed interface AppErrors : Error {
+
+ data class Common(val value: Errors) : AppErrors
+
+ enum class UseCase : AppErrors {
+ // Lessons list
+ FAILED_TO_LOAD_LESSONS,
+ FAILED_TO_PARSE_LESSONS,
+
+ // Lesson details
+ FAILED_TO_LOAD_LESSON,
+ INVALID_LESSON_ID,
+ LESSON_NOT_FOUND,
+ LESSON_EMPTY,
+ INVALID_LESSON_RESPONSE,
+ FAILED_TO_PARSE_LESSON,
+
+ // Audio
+ AUDIO_PLAYBACK,
+ AUDIO_NOT_AVAILABLE,
+ FAILED_TO_CACHE_AUDIO,
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/ads/AdsConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/ads/AdsConstants.kt
new file mode 100644
index 00000000..ee5ae1cf
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/ads/AdsConstants.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.utils.constants.ads
+
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.ads.DebugAdsConstants
+import com.d4rk.androidtutorials.BuildConfig
+
+object AdsConstants { // REMINDER: Check which of them are used and bring back the deleted ad unit id's
+
+ private fun bannerAdUnitId(releaseId: String): String =
+ if (BuildConfig.DEBUG) {
+ DebugAdsConstants.BANNER_AD_UNIT_ID
+ } else {
+ releaseId
+ }
+
+ private fun nativeAdUnitId(releaseId: String): String =
+ if (BuildConfig.DEBUG) {
+ DebugAdsConstants.NATIVE_AD_UNIT_ID
+ } else {
+ releaseId
+ }
+
+ val APP_OPEN_UNIT_ID: String
+ get() = if (BuildConfig.DEBUG) {
+ DebugAdsConstants.APP_OPEN_AD_UNIT_ID
+ } else {
+ "ca-app-pub-5294151573817700/2885662643"
+ }
+
+ val BANNER_AD_UNIT_ID: String
+ get() = bannerAdUnitId("ca-app-pub-5294151573817700/8479403125")
+
+ val HELP_LARGE_BANNER_AD_UNIT_ID: String
+ get() = bannerAdUnitId("ca-app-pub-5294151573817700/4295246186")
+
+ val SUPPORT_MEDIUM_RECTANGLE_BANNER_AD_UNIT_ID: String
+ get() = bannerAdUnitId("ca-app-pub-5294151573817700/4767671864")
+
+ val NATIVE_AD_UNIT_ID: String
+ get() = nativeAdUnitId("ca-app-pub-5294151573817700/5578142927")
+
+ val APP_DETAILS_NATIVE_AD_UNIT_ID: String
+ get() = nativeAdUnitId("ca-app-pub-5294151573817700/8490774272")
+
+ val APPS_LIST_NATIVE_AD_UNIT_ID: String
+ get() = nativeAdUnitId("ca-app-pub-5294151573817700/4743100951")
+
+ val NO_DATA_NATIVE_AD_UNIT_ID: String
+ get() = nativeAdUnitId("ca-app-pub-5294151573817700/3430019286")
+
+ val BOTTOM_NAV_BAR_NATIVE_AD_UNIT_ID: String
+ get() = nativeAdUnitId("ca-app-pub-5294151573817700/6982251485")
+
+ val HELP_NATIVE_AD_UNIT_ID: String
+ get() = nativeAdUnitId("ca-app-pub-5294151573817700/7512912137")
+
+ val SUPPORT_NATIVE_AD_UNIT_ID: String
+ get() = nativeAdUnitId("ca-app-pub-5294151573817700/9755754484")
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/api/ApiConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/api/ApiConstants.kt
new file mode 100644
index 00000000..85cf43eb
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/api/ApiConstants.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.utils.constants.api
+
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.api.ApiEnvironments
+import com.d4rk.android.libs.apptoolkit.core.utils.constants.github.GithubConstants
+
+object AndroidStudioTutorialsApiHost {
+ const val API_REPO: String = "com.d4rk.apis"
+ const val API_BASE_PATH: String = "api/android_studio_tutorials"
+}
+
+object AndroidStudioTutorialsApiVersions {
+ const val V1: String = "v1" // TODO: v2
+}
+
+object AndroidStudioTutorialsApiLanguages {
+ const val EN: String = "en"
+}
+
+object AndroidStudioTutorialsApiPaths {
+ const val HOME_LESSONS: String = "home/api_get_lessons.json"
+
+ const val LESSONS_DIR: String = "lessons"
+ const val LESSON_DETAILS_PREFIX: String = "api_get_"
+ const val JSON_SUFFIX: String = ".json"
+}
+
+object AndroidStudioTutorialsApiConstants {
+ const val BASE_REPOSITORY_URL: String =
+ "${GithubConstants.GITHUB_PAGES}/${AndroidStudioTutorialsApiHost.API_REPO}/${AndroidStudioTutorialsApiHost.API_BASE_PATH}/${AndroidStudioTutorialsApiVersions.V1}"
+}
+
+object AndroidStudioTutorialsApiEndpoints {
+
+ val HOME_LESSONS_RELEASE_EN: String = homeLessons(
+ environment = ApiEnvironments.ENV_RELEASE,
+ language = AndroidStudioTutorialsApiLanguages.EN,
+ )
+
+ fun homeLessons(environment: String, language: String = AndroidStudioTutorialsApiLanguages.EN): String {
+ return "${AndroidStudioTutorialsApiConstants.BASE_REPOSITORY_URL}/$environment/$language/${AndroidStudioTutorialsApiPaths.HOME_LESSONS}"
+ }
+
+ fun lessonDetails(
+ environment: String,
+ lessonId: String,
+ language: String = AndroidStudioTutorialsApiLanguages.EN,
+ ): String {
+ return buildString {
+ append(AndroidStudioTutorialsApiConstants.BASE_REPOSITORY_URL)
+ append("/")
+ append(environment)
+ append("/")
+ append(language)
+ append("/")
+ append(AndroidStudioTutorialsApiPaths.LESSONS_DIR)
+ append("/")
+ append(AndroidStudioTutorialsApiPaths.LESSON_DETAILS_PREFIX)
+ append(lessonId)
+ append(AndroidStudioTutorialsApiPaths.JSON_SUFFIX)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/api/HelpConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/api/HelpConstants.kt
new file mode 100644
index 00000000..6960208e
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/api/HelpConstants.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.utils.constants.api
+
+object HelpConstants {
+ const val FAQ_PRODUCT_ID: String = "com.d4rk.androidtutorials"
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/ui/lessons/LessonConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/ui/lessons/LessonConstants.kt
new file mode 100644
index 00000000..17034ebe
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/ui/lessons/LessonConstants.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.utils.constants.ui.lessons
+
+object LessonConstants {
+ const val TYPE_FULL_IMAGE_BANNER = "full_banner"
+ const val TYPE_SQUARE_IMAGE = "square_image"
+ const val TYPE_AD_BANNER = "ad_view_banner"
+ const val TYPE_AD_FULL_BANNER = "ad_view_banner_full"
+ const val TYPE_AD_LARGE_BANNER = "ad_view_banner_large"
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/ui/lessons/LessonContentTypes.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/ui/lessons/LessonContentTypes.kt
new file mode 100644
index 00000000..94ba8280
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/constants/ui/lessons/LessonContentTypes.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.utils.constants.ui.lessons
+
+object LessonContentTypes {
+ const val TEXT = "content_text"
+ const val HEADER = "header"
+ const val CODE = "content_code"
+ const val IMAGE = "image"
+ const val CONTENT_PLAYER = "content_player"
+ const val AD_BANNER = "ad_banner"
+ const val TYPE_DIVIDER = "content_divider"
+ const val AD_BANNER_FULL = "ad_banner_full"
+ const val AD_LARGE_BANNER = "ad_large_banner"
+ const val FULL_IMAGE_BANNER = "full_image_banner"
+}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/extensions/Errors.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/extensions/Errors.kt
new file mode 100644
index 00000000..e3e9e577
--- /dev/null
+++ b/app/src/main/kotlin/com/d4rk/androidtutorials/core/utils/extensions/Errors.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (©) 2026 Mihai-Cristian Condrea
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.d4rk.androidtutorials.core.utils.extensions
+
+import com.d4rk.android.libs.apptoolkit.core.utils.extensions.errors.asUiText
+import com.d4rk.android.libs.apptoolkit.core.utils.platform.UiTextHelper
+import com.d4rk.androidtutorials.R
+import com.d4rk.androidtutorials.core.domain.model.network.AppErrors
+
+fun AppErrors.toErrorMessage(): UiTextHelper = when (this) {
+ is AppErrors.Common -> value.asUiText()
+
+ // Lessons list
+ //AppErrors.UseCase.FAILED_TO_LOAD_LESSONS -> UiTextHelper.StringResource(R.string.error_failed_to_load_lessons)
+ //AppErrors.UseCase.FAILED_TO_PARSE_LESSONS -> UiTextHelper.StringResource(R.string.error_failed_to_parse_lessons)
+ //AppErrors.UseCase.FAILED_TO_LOAD_LESSON -> UiTextHelper.StringResource(R.string.error_failed_to_load_lesson)
+ //AppErrors.UseCase.INVALID_LESSON_ID -> UiTextHelper.StringResource(R.string.error_invalid_lesson_id)
+ //AppErrors.UseCase.LESSON_NOT_FOUND -> UiTextHelper.StringResource(R.string.error_lesson_not_found)
+ //AppErrors.UseCase.LESSON_EMPTY -> UiTextHelper.StringResource(R.string.error_lesson_empty)
+ //AppErrors.UseCase.INVALID_LESSON_RESPONSE -> UiTextHelper.StringResource(R.string.error_invalid_lesson_response)
+ //AppErrors.UseCase.FAILED_TO_PARSE_LESSON -> UiTextHelper.StringResource(R.string.error_failed_to_parse_lesson)
+
+ // Audio
+ //AppErrors.UseCase.AUDIO_PLAYBACK -> UiTextHelper.StringResource(R.string.error_audio_playback)
+ //AppErrors.UseCase.AUDIO_NOT_AVAILABLE -> UiTextHelper.StringResource(R.string.error_audio_not_available)
+ //AppErrors.UseCase.FAILED_TO_CACHE_AUDIO -> UiTextHelper.StringResource(R.string.error_failed_to_cache_audio)
+ else -> UiTextHelper.StringResource(R.string.app_name) // TODO WIP
+
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/core/AppCoreManager.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/core/AppCoreManager.kt
deleted file mode 100644
index 6c419d7e..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/core/AppCoreManager.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-@file:Suppress("DEPRECATION")
-
-package com.d4rk.androidtutorials.data.core
-
-import android.annotation.SuppressLint
-import android.app.Activity
-import android.database.sqlite.SQLiteException
-import android.os.Bundle
-import android.util.Log
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.OnLifecycleEvent
-import androidx.lifecycle.ProcessLifecycleOwner
-import androidx.room.Room
-import androidx.room.migration.Migration
-import com.d4rk.android.libs.apptoolkit.data.core.BaseCoreManager
-import com.d4rk.android.libs.apptoolkit.data.core.ads.AdsCoreManager
-import com.d4rk.android.libs.apptoolkit.utils.error.ErrorHandler
-import com.d4rk.android.libs.apptoolkit.utils.error.ErrorHandler.handleInitializationFailure
-import com.d4rk.androidtutorials.data.database.AppDatabase
-import com.d4rk.androidtutorials.data.database.migrations.MIGRATION_1_2
-import com.d4rk.androidtutorials.data.database.migrations.MIGRATION_2_3
-import com.d4rk.androidtutorials.data.datastore.DataStore
-import com.d4rk.androidtutorials.utils.constants.ads.AdsConstants
-import com.d4rk.androidtutorials.utils.error.CrashlyticsErrorReporter
-import io.ktor.client.HttpClient
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.async
-import kotlinx.coroutines.supervisorScope
-
-class AppCoreManager : BaseCoreManager() {
-
- private var currentActivity : Activity? = null
-
- companion object {
- @SuppressLint("StaticFieldLeak")
- lateinit var instance : AppCoreManager
- private set
-
- lateinit var dataStore : DataStore
- private set
-
- lateinit var database : AppDatabase
- private set
-
- val adsCoreManager : AdsCoreManager by lazy {
- AdsCoreManager(context = instance)
- }
-
- val ktorClient : HttpClient
- get() = BaseCoreManager.ktorClient
-
- val isAppLoaded : Boolean
- get() = BaseCoreManager.isAppLoaded
- }
-
- override fun onCreate() {
- super.onCreate()
- instance = this
-
- val crashlyticsReporter = CrashlyticsErrorReporter()
- ErrorHandler.init(reporter = crashlyticsReporter)
-
- registerActivityLifecycleCallbacks(this)
- ProcessLifecycleOwner.get().lifecycle.addObserver(observer = this)
- }
-
- override suspend fun onInitializeApp() = supervisorScope {
- val dataStoreInitialization : Deferred = async { initializeDataStore() }
- val databaseInitialization : Deferred = async { initializeDatabase() }
- val adsInitialization : Deferred = async { initializeAds() }
-
- dataStoreInitialization.await()
- databaseInitialization.await()
- adsInitialization.await()
- }
-
- private fun initializeDataStore() {
- runCatching {
- dataStore = DataStore.getInstance(context = this@AppCoreManager)
- }.onFailure {
- handleInitializationFailure(
- message = "DataStore initialization failed" , exception = it as Exception , applicationContext = applicationContext
- )
- }
- }
-
- private suspend fun initializeDatabase() {
- runCatching {
- database = Room.databaseBuilder(
- context = this@AppCoreManager , klass = AppDatabase::class.java , name = "Android Studio Tutorials"
- ).addMigrations(migrations = getMigrations()).fallbackToDestructiveMigration().fallbackToDestructiveMigrationOnDowngrade().build()
-
- database.openHelper.writableDatabase
- }.onFailure {
- handleDatabaseError(exception = it as Exception)
- }
- }
-
- private suspend fun handleDatabaseError(exception : Exception) {
- if (exception is SQLiteException || (exception is IllegalStateException && exception.message?.contains(other = "Migration failed") == true)) {
- eraseDatabase()
- }
- }
-
- private suspend fun eraseDatabase() {
- runCatching {
- deleteDatabase("Android Studio Tutorials")
- }.onSuccess {
- initializeDatabase()
- }.onFailure {
- logDatabaseError(exception = it as Exception)
- }
- }
-
- private fun logDatabaseError(exception : Exception) {
- Log.e("AppCoreManager" , "Database error: ${exception.message}" , exception)
- }
-
- private fun initializeAds() {
- adsCoreManager.initializeAds(AdsConstants.APP_OPEN_UNIT_ID)
- }
-
- private fun getMigrations() : Array {
- return arrayOf(
- MIGRATION_1_2 , MIGRATION_2_3
- )
- }
-
- fun isAppLoaded() : Boolean {
- return isAppLoaded
- }
-
- @OnLifecycleEvent(Lifecycle.Event.ON_START)
- fun onMoveToForeground() {
- currentActivity?.let { adsCoreManager.showAdIfAvailable(it) }
- }
-
- override fun onActivityCreated(activity : Activity , savedInstanceState : Bundle?) {}
-
- override fun onActivityStarted(activity : Activity) {
- currentActivity = activity
- }
-
- override fun onActivityResumed(activity : Activity) {}
- override fun onActivityPaused(activity : Activity) {}
- override fun onActivityStopped(activity : Activity) {}
- override fun onActivitySaveInstanceState(activity : Activity , outState : Bundle) {}
- override fun onActivityDestroyed(activity : Activity) {}
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/AppDatabase.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/AppDatabase.kt
deleted file mode 100644
index 439b7a89..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/AppDatabase.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.d4rk.androidtutorials.data.database
-
-import androidx.room.Database
-import androidx.room.RoomDatabase
-import com.d4rk.androidtutorials.data.database.dao.FavoriteLessonsDao
-import com.d4rk.androidtutorials.data.database.table.FavoriteLessonTable
-
-@Database(entities = [FavoriteLessonTable::class] , version = 3 , exportSchema = false)
-abstract class AppDatabase : RoomDatabase() {
- abstract fun favoriteLessonsDao() : FavoriteLessonsDao
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/converters/Converters.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/converters/Converters.kt
deleted file mode 100644
index e035fc15..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/converters/Converters.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.d4rk.androidtutorials.data.database.converters
-
-import androidx.room.TypeConverter
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.encodeToString
-
-class Converters {
- @TypeConverter
- fun fromStringList(value : List) : String {
- return Json.encodeToString(value)
- }
-
- @TypeConverter
- fun toStringList(value : String) : List {
- return Json.decodeFromString(value)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/dao/FavoriteLessonDao.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/dao/FavoriteLessonDao.kt
deleted file mode 100644
index 9ac32e99..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/dao/FavoriteLessonDao.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.d4rk.androidtutorials.data.database.dao
-
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Query
-import com.d4rk.androidtutorials.data.database.table.FavoriteLessonTable
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-interface FavoriteLessonsDao {
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(favoriteLesson : FavoriteLessonTable)
-
- @Delete
- suspend fun delete(favoriteLesson : FavoriteLessonTable)
-
- @Query("SELECT * FROM `favorite lessons`")
- suspend fun getAllFavorites() : List
-
-
- @Query("SELECT * FROM `Favorite Lessons`")
- fun getAllFavoritesFlow() : Flow>
-
- @Query("SELECT COUNT(*) FROM `favorite lessons` WHERE lessonId = :lessonId")
- suspend fun isFavorite(lessonId : String) : Int
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/migrations/Migrations.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/migrations/Migrations.kt
deleted file mode 100644
index 8a3d3ac1..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/migrations/Migrations.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-package com.d4rk.androidtutorials.data.database.migrations
-
-import androidx.room.migration.Migration
-import androidx.sqlite.db.SupportSQLiteDatabase
-
-val MIGRATION_1_2 : Migration = object : Migration(startVersion = 1 , endVersion = 2) {
- override fun migrate(db : SupportSQLiteDatabase) {
- db.execSQL(
- sql = """
- CREATE TABLE IF NOT EXISTS `Favorite Lessons_new` (
- `lessonId` TEXT PRIMARY KEY NOT NULL,
- `lessonTitle` TEXT NOT NULL,
- `lessonDescription` TEXT NOT NULL,
- `lessonType` TEXT NOT NULL,
- `lessonTags` TEXT NOT NULL,
- `thumbnailImageUrl` TEXT NOT NULL,
- `squareImageUrl` TEXT NOT NULL,
- `deepLinkPath` TEXT NOT NULL,
- `isFavorite` INTEGER NOT NULL
- )
- """.trimIndent()
- )
-
- db.execSQL(
- sql = """
- INSERT INTO `Favorite Lessons_new` (
- `lessonId`, `lessonTitle`, `lessonDescription`, `lessonType`,
- `lessonTags`, `thumbnailImageUrl`, `squareImageUrl`, `deepLinkPath`, `isFavorite`
- )
- SELECT
- `lessonId`, -- or whatever your original PK column was
- `title`,
- `description`,
- `type`,
- `tags`,
- `bannerImageUrl`, -- this becomes thumbnailImageUrl
- `squareImageUrl`,
- `deepLinkPath`,
- `isFavorite`
- FROM `Favorite Lessons`
- """.trimIndent()
- )
-
- db.execSQL(sql = "DROP TABLE `Favorite Lessons`")
-
- db.execSQL(sql = "ALTER TABLE `Favorite Lessons_new` RENAME TO `Favorite Lessons`")
- }
-}
-
-val MIGRATION_2_3 : Migration = object : Migration(startVersion = 2 , endVersion = 3) {
- override fun migrate(db : SupportSQLiteDatabase) {
- db.execSQL(
- sql = """
- CREATE TABLE IF NOT EXISTS `Favorite Lessons_new` (
- `lessonId` TEXT PRIMARY KEY NOT NULL,
- `lessonTitle` TEXT NOT NULL,
- `lessonDescription` TEXT NOT NULL,
- `lessonType` TEXT NOT NULL,
- `lessonTags` TEXT NOT NULL,
- `thumbnailImageUrl` TEXT NOT NULL,
- `squareImageUrl` TEXT NOT NULL,
- `deepLinkPath` TEXT NOT NULL,
- `isFavorite` INTEGER NOT NULL
- )
- """.trimIndent()
- )
-
- db.execSQL(
- sql = """
- INSERT INTO `Favorite Lessons_new` (
- `lessonId`, `lessonTitle`, `lessonDescription`, `lessonType`,
- `lessonTags`, `thumbnailImageUrl`, `squareImageUrl`, `deepLinkPath`, `isFavorite`
- )
- SELECT
- `lessonId`, `lessonTitle`, `lessonDescription`, `lessonType`,
- `lessonTags`, `thumbnailImageUrl`, `squareImageUrl`, `deepLinkPath`, `isFavorite`
- FROM `Favorite Lessons`
- """.trimIndent()
- )
-
- db.execSQL(sql = "DROP TABLE `Favorite Lessons`")
-
- db.execSQL(sql = "ALTER TABLE `Favorite Lessons_new` RENAME TO `Favorite Lessons`")
- }
-}
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/table/FavoriteLessonTable.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/table/FavoriteLessonTable.kt
deleted file mode 100644
index a58689b1..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/database/table/FavoriteLessonTable.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.d4rk.androidtutorials.data.database.table
-
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-import androidx.room.TypeConverters
-import com.d4rk.androidtutorials.data.database.converters.Converters
-
-@Entity(tableName = "Favorite Lessons")
-@TypeConverters(Converters::class)
-data class FavoriteLessonTable(
- @PrimaryKey val lessonId : String ,
- val lessonTitle : String ,
- val lessonDescription : String ,
- val lessonType : String ,
- val lessonTags : List ,
- val thumbnailImageUrl : String ,
- val squareImageUrl : String ,
- val deepLinkPath : String ,
- val isFavorite : Boolean
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/datastore/DataStore.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/datastore/DataStore.kt
deleted file mode 100644
index 01e53165..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/datastore/DataStore.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.d4rk.androidtutorials.data.datastore
-
-import android.content.Context
-import androidx.datastore.preferences.core.booleanPreferencesKey
-import androidx.datastore.preferences.core.edit
-import androidx.datastore.preferences.core.stringPreferencesKey
-import com.d4rk.android.libs.apptoolkit.data.datastore.CommonDataStore
-import com.d4rk.androidtutorials.utils.constants.datastore.AppDataStoreConstants
-import com.d4rk.androidtutorials.utils.constants.ui.bottombar.BottomBarRoutes
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
-
-class DataStore(context : Context) : CommonDataStore(context) {
-
- companion object {
- @Volatile
- private var instance : DataStore? = null
-
- fun getInstance(context : Context) : DataStore {
- return instance ?: synchronized(lock = this) {
- instance ?: DataStore(context.applicationContext).also { instance = it }
- }
- }
- }
-
- fun getStartupPage() : Flow {
- return dataStore.data.map { preferences ->
- preferences[stringPreferencesKey(name = AppDataStoreConstants.DATA_STORE_STARTUP_PAGE)]
- ?: BottomBarRoutes.HOME
- }
- }
-
- suspend fun saveStartupPage(startupPage : String) {
- dataStore.edit { preferences ->
- preferences[stringPreferencesKey(name = AppDataStoreConstants.DATA_STORE_STARTUP_PAGE)] =
- startupPage
- }
- }
-
- fun getShowBottomBarLabels() : Flow {
- return dataStore.data.map { preferences ->
- preferences[booleanPreferencesKey(name = AppDataStoreConstants.DATA_STORE_SHOW_BOTTOM_BAR_LABELS)] != false
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/api/ApiHomeResponse.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/api/ApiHomeResponse.kt
deleted file mode 100644
index a10b3e3d..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/api/ApiHomeResponse.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.d4rk.androidtutorials.data.model.api
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class ApiHomeResponse(
- @SerialName("data") val data: List = ArrayList()
-)
-
-@Serializable
-data class ApiHomeLessons(
- @SerialName("lesson_id") val lessonId: String = "",
- @SerialName("lesson_title") var lessonTitle: String = "",
- @SerialName("lesson_description") var lessonDescription: String = "",
- @SerialName("lesson_type") var lessonType: String = "",
- @SerialName("lesson_tags") var lessonTags: List = emptyList(),
- @SerialName("thumbnail_image_url") var thumbnailImageUrl: String = "",
- @SerialName("square_image_url") var squareImageUrl: String = "",
- @SerialName("deep_link_path") var deepLinkPath: String = ""
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/api/ApiLessonResponse.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/api/ApiLessonResponse.kt
deleted file mode 100644
index 974ba0d9..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/api/ApiLessonResponse.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.d4rk.androidtutorials.data.model.api
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class ApiLessonResponse(
- @SerialName("data") val data : List = ArrayList()
-)
-
-@Serializable
-data class ApiLesson(
- @SerialName("lesson_title") val lessonTitle : String = "" ,
- @SerialName("lesson_content") val lessonContent : List = emptyList()
-)
-
-@Serializable
-data class ApiLessonContent(
- @SerialName("content_id") val contentId : String = "" ,
- @SerialName("content_type") val contentType : String = "" ,
- @SerialName("content_text") val contentText : String = "" ,
- @SerialName("content_code") val contentCode : String = "" ,
- @SerialName("content_code_programming_language") val programmingLanguage : String = "" ,
- @SerialName("content_image_url") val contentImageUrl : String = "" ,
-)
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/api/ApiMessageData.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/api/ApiMessageData.kt
deleted file mode 100644
index 2532dcc6..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/api/ApiMessageData.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.d4rk.androidtutorials.data.model.api
-
-import java.util.UUID
-
-data class ApiMessageData(
- val id : UUID ,
- val text : String ,
- val isBot : Boolean ,
- val firstTimeMessage : Boolean = false ,
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/navigation/BottomNavigationScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/navigation/BottomNavigationScreen.kt
deleted file mode 100644
index 096661a3..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/navigation/BottomNavigationScreen.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.d4rk.androidtutorials.data.model.ui.navigation
-
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Home
-import androidx.compose.material.icons.outlined.Home
-import androidx.compose.material.icons.rounded.AutoAwesome
-import androidx.compose.material.icons.rounded.Favorite
-import androidx.compose.material.icons.sharp.AutoAwesome
-import androidx.compose.material.icons.sharp.FavoriteBorder
-import androidx.compose.ui.graphics.vector.ImageVector
-import com.d4rk.androidtutorials.R
-import com.d4rk.androidtutorials.utils.constants.ui.bottombar.BottomBarRoutes
-
-sealed class BottomNavigationScreen(
- val route : String , val icon : ImageVector , val selectedIcon : ImageVector , val title : Int
-) {
- data object Home : BottomNavigationScreen(
- route = BottomBarRoutes.HOME ,
- icon = Icons.Outlined.Home ,
- selectedIcon = Icons.Filled.Home ,
- title = com.d4rk.android.libs.apptoolkit.R.string.home
- )
-
- data object StudioBot : BottomNavigationScreen(
- route = BottomBarRoutes.STUDIO_BOT ,
- icon = Icons.Sharp.AutoAwesome ,
- selectedIcon = Icons.Rounded.AutoAwesome ,
- title = R.string.studio_bot
- )
-
- data object Favorites : BottomNavigationScreen(
- route = BottomBarRoutes.FAVORITES ,
- icon = Icons.Sharp.FavoriteBorder ,
- selectedIcon = Icons.Rounded.Favorite ,
- title = R.string.favorites
- )
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/MainScreenState.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/MainScreenState.kt
deleted file mode 100644
index 77dc3128..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/MainScreenState.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.d4rk.androidtutorials.data.model.ui.screens
-
-import android.content.Context
-import android.view.View
-import androidx.compose.material3.DrawerState
-import androidx.navigation.NavHostController
-import com.d4rk.androidtutorials.data.datastore.DataStore
-import com.d4rk.androidtutorials.ui.screens.main.MainViewModel
-
-data class MainScreenState(
- val context: Context ,
- val view: View ,
- val drawerState: DrawerState ,
- val navHostController: NavHostController ,
- val dataStore: DataStore ,
- val viewModel: MainViewModel
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiHelpScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiHelpScreen.kt
deleted file mode 100644
index f66c5eae..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiHelpScreen.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.d4rk.androidtutorials.data.model.ui.screens
-
-import com.google.android.play.core.review.ReviewInfo
-
-data class UiHelpScreen(
- var reviewInfo : ReviewInfo? = null ,
- val questions : ArrayList = ArrayList()
-)
-
-data class UiHelpQuestion(
- val question : String = "" ,
- val answer : String = "" ,
- val isExpanded : Boolean = false
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiHomeScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiHomeScreen.kt
deleted file mode 100644
index 7e2ad2de..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiHomeScreen.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.d4rk.androidtutorials.data.model.ui.screens
-
-data class UiHomeScreen(
- val lessons: ArrayList = ArrayList()
-)
-
-data class UiHomeLesson(
- val lessonId: String = "",
- val lessonTitle: String = "",
- val lessonDescription: String = "",
- val lessonType: String = "",
- val lessonTags: List = emptyList(),
- val thumbnailImageUrl: String = "",
- val squareImageUrl: String = "",
- val deepLinkPath: String = "",
- var isFavorite: Boolean = false
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiLessonScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiLessonScreen.kt
deleted file mode 100644
index 0ddfd9a9..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiLessonScreen.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.d4rk.androidtutorials.data.model.ui.screens
-
-data class UiLessonScreen(
- val lessonTitle : String = "" , val lessonContent : ArrayList = ArrayList()
-)
-
-data class UiLessonContent(
- val contentId : String = "" ,
- val contentType : String = "" ,
- val contentText : String = "" ,
- val contentCode : String = "" ,
- val programmingLanguage : String = "" ,
- val contentImageUrl : String = ""
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiMainScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiMainScreen.kt
deleted file mode 100644
index 606f46c4..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/data/model/ui/screens/UiMainScreen.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.d4rk.androidtutorials.data.model.ui.screens
-
-import com.d4rk.android.libs.apptoolkit.data.model.ui.navigation.NavigationDrawerItem
-import com.d4rk.androidtutorials.data.model.ui.navigation.BottomNavigationScreen
-
-data class UiMainScreen(
- val navigationDrawerItems : List = listOf() ,
- val bottomNavigationItems : List = listOf() ,
- val currentBottomNavigationScreen : BottomNavigationScreen = BottomNavigationScreen.Home ,
-)
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/ads/BannerAds.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/ads/BannerAds.kt
deleted file mode 100644
index 0cb0d9dd..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/ads/BannerAds.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.ads
-
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
-import com.d4rk.androidtutorials.data.core.AppCoreManager
-import com.d4rk.androidtutorials.utils.constants.ads.AdsConstants
-import com.google.android.gms.ads.AdRequest
-import com.google.android.gms.ads.AdSize
-import com.google.android.gms.ads.AdView
-
-@Composable
-fun AdBanner(modifier : Modifier = Modifier , adSize : AdSize = AdSize.BANNER) {
- val showAds : Boolean by AppCoreManager.dataStore.ads.collectAsState(initial = true)
-
- if (showAds) {
- AndroidView(modifier = modifier
- .fillMaxWidth()
- .height(height = adSize.height.dp) , factory = { context ->
- AdView(context).apply {
- setAdSize(adSize)
- adUnitId = AdsConstants.BANNER_AD_UNIT_ID
- loadAd(AdRequest.Builder().build())
- }
- })
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/dialogs/SelectLanguageAlertDialog.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/dialogs/SelectLanguageAlertDialog.kt
deleted file mode 100644
index 91f38e0c..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/dialogs/SelectLanguageAlertDialog.kt
+++ /dev/null
@@ -1,114 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.dialogs
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Info
-import androidx.compose.material.icons.outlined.Language
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.RadioButton
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringArrayResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.MediumVerticalSpacer
-import com.d4rk.androidtutorials.R
-import com.d4rk.androidtutorials.data.datastore.DataStore
-import kotlinx.coroutines.flow.firstOrNull
-
-@Composable
-fun SelectLanguageAlertDialog(
- dataStore : DataStore , onDismiss : () -> Unit , onLanguageSelected : (String) -> Unit
-) {
- val selectedLanguage : MutableState = remember { mutableStateOf(value = "") }
- val languageEntries : List =
- stringArrayResource(id = R.array.preference_language_entries).toList()
- val languageValues : List =
- stringArrayResource(id = R.array.preference_language_values).toList()
-
- AlertDialog(onDismissRequest = onDismiss , text = {
- SelectLanguageAlertDialogContent(
- selectedLanguage , dataStore , languageEntries , languageValues
- )
- } , icon = {
- Icon(Icons.Outlined.Language , contentDescription = null)
- } , confirmButton = {
- TextButton(onClick = {
- onLanguageSelected(selectedLanguage.value)
- onDismiss()
- }) {
- Text(text = stringResource(id = android.R.string.ok))
- }
- } , dismissButton = {
- TextButton(onClick = onDismiss) {
- Text(text = stringResource(id = android.R.string.cancel))
- }
- })
-}
-
-@Composable
-fun SelectLanguageAlertDialogContent(
- selectedLanguage : MutableState ,
- dataStore : DataStore ,
- languageEntries : List ,
- languageValues : List
-) {
- LaunchedEffect(key1 = Unit) {
- selectedLanguage.value = dataStore.getLanguage().firstOrNull() ?: ""
- }
-
- Column {
- Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.dialog_language_subtitle))
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .weight(weight = 1f)
- ) {
- LazyColumn {
- items(languageEntries.size) { index ->
- Row(
- Modifier.fillMaxWidth() ,
- verticalAlignment = Alignment.CenterVertically ,
- horizontalArrangement = Arrangement.Start
- ) {
- RadioButton(
- selected = selectedLanguage.value == languageValues[index] ,
- onClick = {
- selectedLanguage.value = languageValues[index]
- })
- Text(
- modifier = Modifier.padding(start = 8.dp) ,
- text = languageEntries[index] ,
- style = MaterialTheme.typography.bodyMedium.merge()
- )
- }
- }
- }
- }
- Spacer(modifier = Modifier.height(height = 24.dp))
- Icon(imageVector = Icons.Outlined.Info , contentDescription = null)
- MediumVerticalSpacer()
- Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.dialog_info_language))
- }
-
- LaunchedEffect(key1 = selectedLanguage.value) {
- dataStore.saveLanguage(language = selectedLanguage.value)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/dialogs/SelectStartupScreenAlertDialog.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/dialogs/SelectStartupScreenAlertDialog.kt
deleted file mode 100644
index 6d6ff6f6..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/dialogs/SelectStartupScreenAlertDialog.kt
+++ /dev/null
@@ -1,111 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.dialogs
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Home
-import androidx.compose.material.icons.outlined.Info
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.RadioButton
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringArrayResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.MediumVerticalSpacer
-import com.d4rk.androidtutorials.R
-import com.d4rk.androidtutorials.utils.constants.ui.bottombar.BottomBarRoutes
-import com.d4rk.androidtutorials.data.datastore.DataStore
-import kotlinx.coroutines.flow.firstOrNull
-
-@Composable
-fun SelectStartupScreenAlertDialog(
- dataStore : DataStore , onDismiss : () -> Unit , onStartupSelected : (String) -> Unit
-) {
- val defaultPage : MutableState = remember { mutableStateOf(BottomBarRoutes.HOME) }
- val startupEntries : List =
- stringArrayResource(id = R.array.preference_startup_entries).toList()
- val startupValues : List =
- stringArrayResource(id = R.array.preference_startup_values).toList()
- AlertDialog(onDismissRequest = onDismiss , text = {
- SelectStartupScreenAlertDialogContent(
- defaultPage , dataStore , startupEntries , startupValues
- )
- } , icon = {
- Icon(Icons.Outlined.Home , contentDescription = null)
- } , confirmButton = {
- TextButton(onClick = {
- onStartupSelected(defaultPage.value)
- onDismiss()
- }) {
- Text(text = stringResource(id = android.R.string.ok))
- }
- } , dismissButton = {
- TextButton(onClick = onDismiss) {
- Text(text = stringResource(id = android.R.string.cancel))
- }
- })
-}
-
-@Composable
-fun SelectStartupScreenAlertDialogContent(
- selectedPage : MutableState ,
- dataStore : DataStore ,
- startupEntries : List ,
- startupValues : List
-) {
- LaunchedEffect(Unit) {
- selectedPage.value = dataStore.getStartupPage().firstOrNull() ?: BottomBarRoutes.HOME
- }
-
- Column {
- Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.dialog_startup_subtitle))
- Box(
- modifier = Modifier.fillMaxWidth()
- ) {
- LazyColumn {
- items(startupEntries.size) { index ->
- Row(
- Modifier.fillMaxWidth() ,
- verticalAlignment = Alignment.CenterVertically ,
- horizontalArrangement = Arrangement.Start
- ) {
- RadioButton(selected = selectedPage.value == startupValues[index] ,
- onClick = {
- selectedPage.value = startupValues[index]
- })
- Text(
- modifier = Modifier.padding(start = 8.dp) ,
- text = startupEntries[index] ,
- style = MaterialTheme.typography.bodyMedium.merge()
- )
- }
- }
- }
- }
- Spacer(modifier = Modifier.height(24.dp))
- Icon(imageVector = Icons.Outlined.Info , contentDescription = null)
- MediumVerticalSpacer()
- Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.dialog_info_startup))
- }
-
- LaunchedEffect(selectedPage.value) {
- dataStore.saveStartupPage(selectedPage.value)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/layouts/LessonContentLayout.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/layouts/LessonContentLayout.kt
deleted file mode 100644
index 12e770db..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/layouts/LessonContentLayout.kt
+++ /dev/null
@@ -1,376 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.layouts
-
-import android.content.Context
-import android.os.Build.VERSION.SDK_INT
-import android.widget.Toast
-import androidx.compose.foundation.ScrollState
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.text.selection.SelectionContainer
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.CopyAll
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Card
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.fromHtml
-import androidx.compose.ui.text.input.TextFieldValue
-import androidx.compose.ui.unit.dp
-import coil3.ImageLoader
-import coil3.compose.AsyncImage
-import coil3.gif.AnimatedImageDecoder
-import coil3.gif.GifDecoder
-import coil3.request.crossfade
-import coil3.svg.SvgDecoder
-import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.bounceClick
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.ButtonIconSpacer
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.LargeVerticalSpacer
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.SmallVerticalSpacer
-import com.d4rk.android.libs.apptoolkit.utils.helpers.ClipboardHelper
-import com.d4rk.androidtutorials.data.model.ui.screens.UiLessonScreen
-import com.d4rk.androidtutorials.ui.components.ads.AdBanner
-import com.d4rk.androidtutorials.ui.screens.settings.display.theme.style.Colors
-import com.d4rk.androidtutorials.ui.screens.settings.display.theme.style.TextStyles
-import com.d4rk.androidtutorials.utils.constants.ui.lessons.LessonCodeConstants
-import com.d4rk.androidtutorials.utils.constants.ui.lessons.LessonContentTypes
-import com.google.android.gms.ads.AdSize
-import com.wakaztahir.codeeditor.highlight.model.CodeLang
-import com.wakaztahir.codeeditor.highlight.prettify.PrettifyParser
-import com.wakaztahir.codeeditor.highlight.theme.CodeTheme
-import com.wakaztahir.codeeditor.highlight.theme.CodeThemeType
-import com.wakaztahir.codeeditor.highlight.utils.parseCodeAsAnnotatedString
-
-@Composable
-fun LessonContentLayout(
- paddingValues : PaddingValues ,
- scrollState : ScrollState ,
- lesson : UiLessonScreen ,
-) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues = paddingValues)
- .padding(horizontal = 16.dp)
- .verticalScroll(state = scrollState)
- ) {
- lesson.lessonContent.forEachIndexed { index , contentItem ->
- when (contentItem.contentType) {
- LessonContentTypes.HEADER -> {
- StyledText(
- text = contentItem.contentText , style = TextStyles.header() , color = Colors.primaryText()
- )
- }
-
- LessonContentTypes.TEXT -> {
- StyledText(
- text = contentItem.contentText , style = TextStyles.body() , color = Colors.secondaryText()
- )
- }
-
- LessonContentTypes.IMAGE -> {
- StyledImage(
- imageUrl = contentItem.contentImageUrl , contentDescription = "Lesson Image"
- )
- }
-
- LessonContentTypes.CODE -> {
- CodeBlock(
- code = contentItem.contentCode , language = contentItem.programmingLanguage
- )
- }
-
- LessonContentTypes.AD_BANNER -> {
- AdBanner()
- }
-
- LessonContentTypes.AD_BANNER_FULL -> {
- AdBanner(adSize = AdSize.FULL_BANNER)
- }
-
- LessonContentTypes.AD_LARGE_BANNER -> {
- AdBanner(adSize = AdSize.LARGE_BANNER)
- }
-
- else -> {
- Text(text = "Unsupported content type: ${contentItem.contentType}")
- }
- }
- if (index < lesson.lessonContent.lastIndex) {
- SmallVerticalSpacer()
- }
- }
- }
-}
-
-@Composable
-fun StyledText(
- text : String ,
- style : TextStyle = TextStyles.body() ,
- color : Color = Colors.primaryText() ,
-) {
- val annotatedString : AnnotatedString = AnnotatedString.fromHtml(htmlString = text)
-
- Text(
- text = annotatedString , style = style , color = color
- )
-}
-
-@Composable
-fun StyledImage(
- imageUrl : String ,
- contentDescription : String? = null ,
- modifier : Modifier = Modifier ,
-) {
- val context : Context = LocalContext.current
- val imageLoader: ImageLoader = ImageLoader.Builder(context)
- .components {
- if (SDK_INT >= 28) {
- add(AnimatedImageDecoder.Factory())
- } else {
- add(GifDecoder.Factory())
- }
- add(SvgDecoder.Factory())
- }
- .crossfade(enable = true)
- .build()
- Card(
- modifier = modifier.fillMaxWidth() ,
- ) {
- AsyncImage(
- model = imageUrl ,
- contentScale = ContentScale.FillWidth ,
- contentDescription = contentDescription ,
- modifier = Modifier.fillMaxWidth() ,
- imageLoader = imageLoader ,
- )
- }
-}
-
-@Composable
-fun CodeBlock(code : String , language : String?) {
- val context = LocalContext.current
- val lang = when (language) {
- LessonCodeConstants.C -> {
- CodeLang.C
- }
-
- LessonCodeConstants.CPP -> {
- CodeLang.CPP
- }
-
- LessonCodeConstants.OBJECTIVEC -> {
- CodeLang.ObjectiveC
- }
-
- LessonCodeConstants.CSHARP -> {
- CodeLang.CSharp
- }
-
- LessonCodeConstants.JAVA -> {
- CodeLang.Java
- }
-
- LessonCodeConstants.BASH -> {
- CodeLang.Bash
- }
-
- LessonCodeConstants.PYTHON -> {
- CodeLang.Python
- }
-
- LessonCodeConstants.PERL -> {
- CodeLang.Perl
- }
-
- LessonCodeConstants.RUBY -> {
- CodeLang.Ruby
- }
-
- LessonCodeConstants.JAVASCRIPT -> {
- CodeLang.JavaScript
- }
-
- LessonCodeConstants.COFFEESCRIPT -> {
- CodeLang.CoffeeScript
- }
-
- LessonCodeConstants.RUST -> {
- CodeLang.Rust
- }
-
- LessonCodeConstants.BASIC -> {
- CodeLang.Basic
- }
-
- LessonCodeConstants.CLOJURE -> {
- CodeLang.Clojure
- }
-
- LessonCodeConstants.CSS -> {
- CodeLang.CSS
- }
-
- LessonCodeConstants.DART -> {
- CodeLang.Dart
- }
-
- LessonCodeConstants.ERLANG -> {
- CodeLang.Erlang
- }
-
- LessonCodeConstants.GO -> {
- CodeLang.Go
- }
-
- LessonCodeConstants.HASKELL -> {
- CodeLang.Haskell
- }
-
- LessonCodeConstants.LISP -> {
- CodeLang.Lisp
- }
-
- LessonCodeConstants.LUA -> {
- CodeLang.Lua
- }
-
- LessonCodeConstants.MATLAB -> {
- CodeLang.Matlab
- }
-
- LessonCodeConstants.ML -> {
- CodeLang.ML
- }
-
- LessonCodeConstants.SML -> {
- CodeLang.SML
- }
-
- LessonCodeConstants.MUMPS -> {
- CodeLang.Mumps
- }
-
- LessonCodeConstants.PASCAL -> {
- CodeLang.Pascal
- }
-
- LessonCodeConstants.SCALA -> {
- CodeLang.Scala
- }
-
- LessonCodeConstants.SQL -> {
- CodeLang.SQL
- }
-
- LessonCodeConstants.VHDL -> {
- CodeLang.VHDL
- }
-
- LessonCodeConstants.TCL -> {
- CodeLang.Tcl
- }
-
- LessonCodeConstants.WIKI -> {
- CodeLang.Wiki
- }
-
- LessonCodeConstants.XQUERY -> {
- CodeLang.XQuery
- }
-
- LessonCodeConstants.YAML -> {
- CodeLang.YAML
- }
-
- LessonCodeConstants.MARKDOWN -> {
- CodeLang.Markdown
- }
-
- LessonCodeConstants.JSON -> {
- CodeLang.JSON
- }
-
- LessonCodeConstants.XML -> {
- CodeLang.XML
- }
-
- else -> {
- CodeLang.Java
- }
- }
-
- val parser : PrettifyParser = remember { PrettifyParser() }
- val themeState : CodeThemeType by remember { mutableStateOf(value = CodeThemeType.Default) }
- val theme : CodeTheme = remember(key1 = themeState) { themeState.theme() }
-
- val textFieldValue : TextFieldValue by remember {
- mutableStateOf(
- value = TextFieldValue(
- annotatedString = parseCodeAsAnnotatedString(
- parser = parser , theme = theme , lang = lang , code = code
- )
- )
- )
- }
-
- Card(
- modifier = Modifier.fillMaxWidth()
- ) {
- Column {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp) , horizontalArrangement = Arrangement.SpaceBetween , verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = language ?: "unknown" , style = MaterialTheme.typography.bodyMedium , modifier = Modifier.padding(end = 8.dp)
- )
- TextButton(modifier = Modifier.bounceClick() , onClick = {
- ClipboardHelper.copyTextToClipboard(context = context , label = "Code" , text = code , onShowSnackbar = {
- Toast.makeText(
- context , "Code copied to clipboard" , Toast.LENGTH_SHORT
- ).show()
- })
- } , contentPadding = PaddingValues(horizontal = 8.dp)) {
- Icon(
- imageVector = Icons.Outlined.CopyAll , contentDescription = "Copy Code" , modifier = Modifier.size(size = ButtonDefaults.IconSize)
- )
- ButtonIconSpacer()
- Text(text = stringResource(id = android.R.string.copy))
- }
- }
- Spacer(modifier = Modifier.height(height = 2.dp))
- SelectionContainer {
- Text(
- text = textFieldValue.annotatedString , modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp)
- )
- }
- LargeVerticalSpacer()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/layouts/LessonListLayout.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/layouts/LessonListLayout.kt
deleted file mode 100644
index c3f68e1a..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/layouts/LessonListLayout.kt
+++ /dev/null
@@ -1,299 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.layouts
-
-import android.content.Context
-import android.view.SoundEffectConstants
-import android.view.View
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
-import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
-import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Favorite
-import androidx.compose.material.icons.outlined.FavoriteBorder
-import androidx.compose.material.icons.outlined.Share
-import androidx.compose.material3.AssistChip
-import androidx.compose.material3.Card
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.viewmodel.compose.viewModel
-import coil3.ImageLoader
-import coil3.compose.AsyncImage
-import coil3.request.crossfade
-import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.animateVisibility
-import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.bounceClick
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.MediumHorizontalSpacer
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.MediumVerticalSpacer
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.SmallHorizontalSpacer
-import com.d4rk.android.libs.apptoolkit.utils.helpers.ScreenHelper
-import com.d4rk.androidtutorials.R
-import com.d4rk.androidtutorials.data.core.AppCoreManager
-import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeLesson
-import com.d4rk.androidtutorials.ui.components.ads.AdBanner
-import com.d4rk.androidtutorials.ui.components.navigation.openLessonDetailActivity
-import com.d4rk.androidtutorials.ui.screens.home.HomeViewModel
-import com.d4rk.androidtutorials.utils.constants.ui.lessons.LessonConstants
-import com.google.android.gms.ads.AdSize
-
-@Composable
-fun LessonListLayout(
- lessons : List , visibilityStates : List , context : Context
-) {
- val showAds : Boolean by AppCoreManager.dataStore.ads.collectAsState(initial = true)
-
- val filteredLessons : List = if (showAds) {
- lessons
- }
- else {
- lessons.filterNot { lesson ->
- lesson.lessonType == LessonConstants.TYPE_AD_BANNER || lesson.lessonType == LessonConstants.TYPE_AD_FULL_BANNER || lesson.lessonType == LessonConstants.TYPE_AD_LARGE_BANNER
- }
- }
-
- val showGrid : Boolean = ScreenHelper.isLandscapeOrTablet(context)
-
- if (showGrid) {
- LazyVerticalStaggeredGrid(
- columns = StaggeredGridCells.Fixed(count = 3) , contentPadding = PaddingValues(all = 16.dp) , verticalItemSpacing = 16.dp , horizontalArrangement = Arrangement.spacedBy(space = 16.dp) , modifier = Modifier.fillMaxSize()
- ) {
- itemsIndexed(items = filteredLessons) { index , lesson ->
- val isVisible = visibilityStates.getOrElse(index = index) { false }
- LessonItem(
- lesson = lesson , context = context , modifier = Modifier
- .animateVisibility(visible = isVisible)
- .animateItem()
- )
- }
- }
- }
- else {
- LazyColumn(
- contentPadding = PaddingValues(all = 16.dp) , verticalArrangement = Arrangement.spacedBy(space = 16.dp) , modifier = Modifier.fillMaxSize()
- ) {
- itemsIndexed(filteredLessons) { index , lesson ->
- val isVisible = visibilityStates.getOrElse(index) { false }
- LessonItem(
- lesson = lesson , context = context , modifier = Modifier
- .animateVisibility(visible = isVisible)
- .animateItem()
- )
- }
- }
- }
-}
-
-@Composable
-fun LessonItem(lesson : UiHomeLesson , context : Context , modifier : Modifier = Modifier) {
- val viewModel : HomeViewModel = viewModel()
- val imageLoader : ImageLoader = remember {
- ImageLoader.Builder(context = context).crossfade(enable = true).build()
- }
-
- Card(
- modifier = modifier.fillMaxWidth()
- ) {
- when (lesson.lessonType) {
- LessonConstants.TYPE_FULL_IMAGE_BANNER -> {
- FullImageBannerLessonItem(
- lesson = lesson , context = context , viewModel = viewModel , imageLoader = imageLoader
- )
- }
-
- LessonConstants.TYPE_SQUARE_IMAGE -> {
- SquareImageLessonItem(
- lesson = lesson , context = context , viewModel = viewModel , imageLoader = imageLoader
- )
- SmallHorizontalSpacer()
- }
- }
- }
-
- when (lesson.lessonType) {
- LessonConstants.TYPE_AD_BANNER -> {
- AdBanner()
- }
-
- LessonConstants.TYPE_AD_FULL_BANNER -> {
- AdBanner(adSize = AdSize.FULL_BANNER)
- }
-
- LessonConstants.TYPE_AD_LARGE_BANNER -> {
- AdBanner(adSize = AdSize.LARGE_BANNER)
- }
- }
-}
-
-@Composable
-fun FullImageBannerLessonItem(
- lesson : UiHomeLesson , context : Context , viewModel : HomeViewModel , imageLoader : ImageLoader
-) {
- Card(modifier = Modifier
- .fillMaxWidth()
- .clickable {
- openLessonDetailActivity(context = context , lesson = lesson)
- }) {
- Column {
- AsyncImage(
- model = lesson.thumbnailImageUrl ,
- contentDescription = null ,
- modifier = Modifier
- .fillMaxWidth()
- .aspectRatio(ratio = 16f / 9f) ,
- contentScale = ContentScale.Crop ,
- imageLoader = imageLoader ,
- )
- MediumVerticalSpacer()
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp) , horizontalArrangement = Arrangement.SpaceBetween
- ) {
- TitleAndDescriptionColumn(
- title = lesson.lessonTitle , description = lesson.lessonDescription
- )
- }
- if (lesson.lessonTags.isNotEmpty()) {
- MediumVerticalSpacer()
- TagRow(lesson.lessonTags)
- }
- MediumVerticalSpacer()
- ButtonsRow(lesson = lesson , viewModel = viewModel)
- MediumVerticalSpacer()
- }
- }
-}
-
-@Composable
-fun SquareImageLessonItem(
- lesson : UiHomeLesson , context : Context , viewModel : HomeViewModel , imageLoader : ImageLoader
-) {
- Card(modifier = Modifier
- .fillMaxWidth()
- .clickable {
- openLessonDetailActivity(context = context , lesson = lesson)
- }) {
- Column {
- MediumVerticalSpacer()
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp) , horizontalArrangement = Arrangement.SpaceBetween
- ) {
- AsyncImage(
- model = lesson.squareImageUrl ,
- contentDescription = null ,
- modifier = Modifier
- .size(size = 98.dp)
- .aspectRatio(ratio = 1f / 1f)
- .clip(RoundedCornerShape(size = 12.dp)) ,
- contentScale = ContentScale.Crop ,
- imageLoader = imageLoader ,
- error = painterResource(id = R.drawable.il_square_image_error)
- )
- MediumHorizontalSpacer()
- Column(
- modifier = Modifier.weight(weight = 1f) , verticalArrangement = Arrangement.SpaceBetween
- ) {
- TitleAndDescriptionColumn(
- title = lesson.lessonTitle ,
- description = lesson.lessonDescription ,
- maxLines = 3 ,
- )
- }
- }
- if (lesson.lessonTags.isNotEmpty()) {
- MediumVerticalSpacer()
- TagRow(lesson.lessonTags)
- }
- MediumVerticalSpacer()
- ButtonsRow(lesson = lesson , viewModel = viewModel)
- MediumVerticalSpacer()
- }
- }
-}
-
-@Composable
-fun TitleAndDescriptionColumn(title : String , description : String , maxLines : Int = 2) {
- Column {
- if (title.isNotEmpty()) {
- Text(
- text = title , style = MaterialTheme.typography.titleMedium , maxLines = 1 , overflow = TextOverflow.Ellipsis
- )
- }
- if (description.isNotEmpty()) {
- Text(
- text = description , style = MaterialTheme.typography.bodyMedium , maxLines = maxLines , overflow = TextOverflow.Ellipsis
- )
- }
- }
-}
-
-@Composable
-fun ButtonsRow(lesson : UiHomeLesson , viewModel : HomeViewModel) {
- val view : View = LocalView.current
- val isFavorite = lesson.isFavorite
-
- Row(
- modifier = Modifier.fillMaxWidth() , horizontalArrangement = Arrangement.End
- ) {
- IconButton(modifier = Modifier.bounceClick() , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- viewModel.shareLesson(lesson)
- }) {
- Icon(
- imageVector = Icons.Outlined.Share , contentDescription = null
- )
- }
-
- IconButton(modifier = Modifier.bounceClick() , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- viewModel.toggleFavorite(lesson)
- }) {
- Icon(
- imageVector = if (isFavorite) Icons.Outlined.Favorite else Icons.Outlined.FavoriteBorder , contentDescription = null , tint = if (isFavorite) MaterialTheme.colorScheme.error else LocalContentColor.current
- )
- }
- }
-}
-
-
-@Composable
-fun TagRow(tags : List) {
- LazyRow(
- modifier = Modifier.fillMaxWidth() , contentPadding = PaddingValues(horizontal = 16.dp) , horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- items(tags) { tag ->
- AssistChip(
- onClick = { } ,
- label = { Text(text = tag) } ,
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/layouts/NoLessonsScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/layouts/NoLessonsScreen.kt
deleted file mode 100644
index 6499b077..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/layouts/NoLessonsScreen.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.layouts
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.wrapContentSize
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Info
-import androidx.compose.material3.Button
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import com.d4rk.androidtutorials.R
-
-@Composable
-fun NoLessonsScreen(
- text: Int = R.string.no_lessons_found,
- icon: ImageVector = Icons.Default.Info,
- iconDescription: String = "No lessons icon",
- showRetry: Boolean = false,
- onRetry: () -> Unit = {}
-) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .wrapContentSize(align = Alignment.Center)
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Icon(
- imageVector = icon,
- contentDescription = iconDescription,
- modifier = Modifier
- .size(58.dp)
- .padding(bottom = 16.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- Text(
- text = stringResource(id = text),
- style = MaterialTheme.typography.displaySmall.copy(textAlign = TextAlign.Center),
- color = MaterialTheme.colorScheme.onBackground
- )
- if (showRetry) {
- Spacer(modifier = Modifier.height(height = 16.dp))
- Button(onClick = onRetry) {
- Text(text = stringResource(id = R.string.try_again))
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/BottomNavigationBar.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/BottomNavigationBar.kt
deleted file mode 100644
index 54b87e4e..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/BottomNavigationBar.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.navigation
-
-import android.view.SoundEffectConstants
-import android.view.View
-import androidx.compose.foundation.basicMarquee
-import androidx.compose.foundation.layout.Column
-import androidx.compose.material3.Icon
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.navigation.NavBackStackEntry
-import androidx.navigation.NavController
-import androidx.navigation.compose.currentBackStackEntryAsState
-import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.bounceClick
-import com.d4rk.androidtutorials.data.datastore.DataStore
-import com.d4rk.androidtutorials.data.model.ui.navigation.BottomNavigationScreen
-import com.d4rk.androidtutorials.data.model.ui.screens.UiMainScreen
-import com.d4rk.androidtutorials.ui.components.ads.AdBanner
-import com.d4rk.androidtutorials.ui.screens.main.MainViewModel
-import com.google.android.gms.ads.AdSize
-
-@Composable
-fun BottomNavigationBar(
- navController : NavController ,
- viewModel : MainViewModel ,
- dataStore : DataStore ,
- view : View ,
-) {
- val uiState : UiMainScreen by viewModel.uiState.collectAsState()
- val bottomBarItems : List = uiState.bottomNavigationItems
- val showLabels : Boolean = dataStore.getShowBottomBarLabels().collectAsState(initial = true).value
-
- Column {
- AdBanner(adSize = AdSize.FULL_BANNER)
- NavigationBar {
- val navBackStackEntry : NavBackStackEntry? by navController.currentBackStackEntryAsState()
- val currentRoute : String? = navBackStackEntry?.destination?.route
- bottomBarItems.forEach { screen ->
- NavigationBarItem(icon = {
- val iconResource : ImageVector =
- if (currentRoute == screen.route) screen.selectedIcon else screen.icon
- Icon(
- imageVector = iconResource ,
- modifier = Modifier.bounceClick() ,
- contentDescription = null
- )
- } , label = {
- if (showLabels) Text(
- text = stringResource(id = screen.title) ,
- overflow = TextOverflow.Ellipsis ,
- modifier = Modifier.basicMarquee()
- )
- } , selected = currentRoute == screen.route , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- if (currentRoute != screen.route) {
- navController.navigate(route = screen.route) {
- popUpTo(id = navController.graph.startDestinationId) {
- saveState = false
- }
- launchSingleTop = true
- }
- viewModel.updateBottomNavigationScreen(newScreen = screen)
- }
- })
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/LeftNavigationRail.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/LeftNavigationRail.kt
deleted file mode 100644
index 6721d297..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/LeftNavigationRail.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.navigation
-
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Icon
-import androidx.compose.material3.NavigationRail
-import androidx.compose.material3.NavigationRailItem
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
-import androidx.navigation.NavBackStackEntry
-import androidx.navigation.compose.currentBackStackEntryAsState
-import com.d4rk.android.libs.apptoolkit.data.model.ui.navigation.NavigationDrawerItem
-import com.d4rk.androidtutorials.data.model.ui.navigation.BottomNavigationScreen
-import com.d4rk.androidtutorials.data.model.ui.screens.MainScreenState
-import com.d4rk.androidtutorials.data.model.ui.screens.UiMainScreen
-import kotlinx.coroutines.CoroutineScope
-
-@Composable
-fun LeftNavigationRail(
- mainScreenState : MainScreenState ,
- paddingValues : PaddingValues ,
- coroutineScope : CoroutineScope ,
- isRailExpanded : Boolean ,
-) {
- val uiState : UiMainScreen by mainScreenState.viewModel.uiState.collectAsState()
-
- val bottomBarItems : List = uiState.bottomNavigationItems
- val drawerItems : List = uiState.navigationDrawerItems
-
- val navBackStackEntry : NavBackStackEntry? by mainScreenState.navHostController.currentBackStackEntryAsState()
- val currentRoute : String? = navBackStackEntry?.destination?.route
-
- val railWidth : Dp by animateDpAsState(
- targetValue = if (isRailExpanded) 200.dp else 72.dp , animationSpec = tween(durationMillis = 300)
- )
-
- Row(modifier = Modifier.padding(top = paddingValues.calculateTopPadding())) {
- NavigationRail(
- modifier = Modifier.width(width = railWidth)
- ) {
- bottomBarItems.forEach { screen ->
- NavigationRailItem(selected = currentRoute == screen.route , onClick = {
- mainScreenState.navHostController.navigate(screen.route) {
- popUpTo(mainScreenState.navHostController.graph.startDestinationId) {
- saveState = true
- }
- launchSingleTop = true
- }
- } , icon = {
- Icon(
- imageVector = if (currentRoute == screen.route) screen.selectedIcon else screen.icon , contentDescription = stringResource(id = screen.title)
- )
- } , label = if (isRailExpanded) {
- { Text(text = stringResource(id = screen.title)) }
- }
- else null)
- }
-
- Spacer(modifier = Modifier.weight(weight = 1f))
-
- drawerItems.forEach { item ->
- NavigationRailItem(selected = false , onClick = {
- handleNavigationItemClick(
- context = mainScreenState.context , item = item , drawerState = mainScreenState.drawerState , coroutineScope = coroutineScope
- )
- } , icon = {
- Icon(
- imageVector = item.selectedIcon , contentDescription = stringResource(id = item.title)
- )
- } , label = if (isRailExpanded) {
- { Text(text = stringResource(id = item.title)) }
- }
- else null)
- }
- }
-
- Box(modifier = Modifier.weight(weight = 1f)) {
- NavigationHost(
- navHostController = mainScreenState.navHostController , dataStore = mainScreenState.dataStore , paddingValues = paddingValues
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/NavigationDrawer.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/NavigationDrawer.kt
deleted file mode 100644
index 4099c68b..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/NavigationDrawer.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.navigation
-
-import android.content.Context
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.DrawerState
-import androidx.compose.material3.Icon
-import androidx.compose.material3.ModalDrawerSheet
-import androidx.compose.material3.ModalNavigationDrawer
-import androidx.compose.material3.NavigationDrawerItem
-import androidx.compose.material3.NavigationDrawerItemDefaults
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import com.d4rk.android.libs.apptoolkit.data.model.ui.navigation.NavigationDrawerItem
-import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.bounceClick
-import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.hapticDrawerSwipe
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.LargeVerticalSpacer
-import com.d4rk.androidtutorials.data.model.ui.screens.MainScreenState
-import com.d4rk.androidtutorials.ui.screens.main.MainScaffoldContent
-import kotlinx.coroutines.CoroutineScope
-
-@Composable
-fun NavigationDrawer(
- mainScreenState : MainScreenState
-) {
- val uiState by mainScreenState.viewModel.uiState.collectAsState()
- val drawerItems = uiState.navigationDrawerItems
- val coroutineScope : CoroutineScope = rememberCoroutineScope()
-
- ModalNavigationDrawer(modifier = Modifier.hapticDrawerSwipe(drawerState = mainScreenState.drawerState) , drawerState = mainScreenState.drawerState , drawerContent = {
- ModalDrawerSheet {
- LargeVerticalSpacer()
- drawerItems.forEach { item ->
- NavigationDrawerItemContent(
- item = item , coroutineScope = coroutineScope , drawerState = mainScreenState.drawerState , context = mainScreenState.context
- )
- }
- }
- }) {
- MainScaffoldContent(
- mainScreenState = mainScreenState , coroutineScope = coroutineScope
- )
- }
-}
-
-
-@Composable
-private fun NavigationDrawerItemContent(
- item : NavigationDrawerItem , coroutineScope : CoroutineScope , drawerState : DrawerState , context : Context
-) {
- val title = stringResource(id = item.title)
- NavigationDrawerItem(label = { Text(text = title) } , selected = false , onClick = {
- handleNavigationItemClick(
- context = context , item = item , drawerState = drawerState , coroutineScope = coroutineScope
- )
- } , icon = {
- Icon(item.selectedIcon , contentDescription = title)
- } , badge = {
- if (item.badgeText.isNotBlank()) {
- Text(text = item.badgeText)
- }
- } , modifier = Modifier
- .padding(paddingValues = NavigationDrawerItemDefaults.ItemPadding)
- .bounceClick())
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/NavigationHost.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/NavigationHost.kt
deleted file mode 100644
index e66885a1..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/NavigationHost.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.navigation
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.DrawerState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import com.d4rk.android.libs.apptoolkit.data.model.ui.navigation.NavigationDrawerItem
-import com.d4rk.android.libs.apptoolkit.utils.helpers.IntentsHelper
-import com.d4rk.android.libs.apptoolkit.utils.helpers.ScreenHelper
-import com.d4rk.androidtutorials.utils.constants.ui.bottombar.BottomBarRoutes
-import com.d4rk.androidtutorials.data.datastore.DataStore
-import com.d4rk.androidtutorials.data.model.ui.navigation.BottomNavigationScreen
-import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeLesson
-import com.d4rk.androidtutorials.ui.screens.favorites.FavoritesScreen
-import com.d4rk.androidtutorials.ui.screens.help.HelpActivity
-import com.d4rk.androidtutorials.ui.screens.home.HomeScreen
-import com.d4rk.androidtutorials.ui.screens.settings.SettingsActivity
-import com.d4rk.androidtutorials.ui.screens.studiobot.StudioBotScreen
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-
-@Composable
-fun NavigationHost(
- navHostController : NavHostController , dataStore : DataStore , paddingValues : PaddingValues
-) {
- val context : Context = LocalContext.current
- val startupPage : String = dataStore.getStartupPage().collectAsState(initial = BottomBarRoutes.HOME).value
- val isTabletOrLandscape : Boolean = ScreenHelper.isLandscapeOrTablet(context = context)
-
- val finalPaddingValues : PaddingValues = if (isTabletOrLandscape) {
- PaddingValues(bottom = paddingValues.calculateBottomPadding())
- }
- else {
- paddingValues
- }
-
- NavHost(navController = navHostController , startDestination = startupPage) {
- composable(route = BottomNavigationScreen.Home.route) {
- Box(modifier = Modifier.padding(paddingValues = finalPaddingValues)) {
- HomeScreen()
- }
- }
-
- composable(route = BottomNavigationScreen.StudioBot.route) {
- Box(modifier = Modifier.padding(paddingValues = finalPaddingValues)) {
- StudioBotScreen()
- }
- }
-
- composable(route = BottomNavigationScreen.Favorites.route) {
- Box(modifier = Modifier.padding(paddingValues = finalPaddingValues)) {
- FavoritesScreen()
- }
- }
- }
-}
-
-fun openLessonDetailActivity(context : Context , lesson : UiHomeLesson) {
- Intent(Intent.ACTION_VIEW , Uri.parse(lesson.deepLinkPath)).let { intent ->
- intent.resolveActivity(context.packageManager)?.let {
- context.startActivity(intent)
- }
- }
-}
-
-fun handleNavigationItemClick(
- context : Context , item : NavigationDrawerItem , drawerState : DrawerState , coroutineScope : CoroutineScope
-) {
- when (item.title) {
- com.d4rk.android.libs.apptoolkit.R.string.settings -> IntentsHelper.openActivity(
- context = context , activityClass = SettingsActivity::class.java
- )
-
- com.d4rk.android.libs.apptoolkit.R.string.help_and_feedback -> IntentsHelper.openActivity(
- context = context , activityClass = HelpActivity::class.java
- )
-
- com.d4rk.android.libs.apptoolkit.R.string.updates -> IntentsHelper.openUrl(
- context = context , url = "https://github.com/D4rK7355608/${context.packageName}/blob/master/CHANGELOG.md"
- )
-
- com.d4rk.android.libs.apptoolkit.R.string.share -> IntentsHelper.shareApp(
- context = context , shareMessageFormat = com.d4rk.android.libs.apptoolkit.R.string.summary_share_message
- )
- }
- coroutineScope.launch { drawerState.close() }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/TopAppBar.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/TopAppBar.kt
deleted file mode 100644
index e08bb47a..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/components/navigation/TopAppBar.kt
+++ /dev/null
@@ -1,179 +0,0 @@
-package com.d4rk.androidtutorials.ui.components.navigation
-
-import android.app.Activity
-import android.content.Context
-import android.view.SoundEffectConstants
-import android.view.View
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.outlined.VolunteerActivism
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LargeTopAppBar
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
-import androidx.compose.material3.rememberTopAppBarState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.res.stringResource
-import com.d4rk.android.libs.apptoolkit.ui.components.dialogs.VersionInfoAlertDialog
-import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.bounceClick
-import com.d4rk.android.libs.apptoolkit.utils.helpers.IntentsHelper
-import com.d4rk.androidtutorials.BuildConfig
-import com.d4rk.androidtutorials.R
-import com.d4rk.androidtutorials.ui.screens.support.SupportActivity
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun TopAppBarMain(
- view : View ,
- context : Context ,
- navigationIcon : ImageVector ,
- onNavigationIconClick : () -> Unit ,
-) {
- TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) } , navigationIcon = {
- IconButton(modifier = Modifier.bounceClick() , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- onNavigationIconClick()
- }) {
- Icon(
- imageVector = navigationIcon , contentDescription = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.navigation_drawer_open)
- )
- }
- } , actions = {
- IconButton(modifier = Modifier.bounceClick() , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- IntentsHelper.openActivity(context , SupportActivity::class.java)
- }) {
- Icon(
- Icons.Outlined.VolunteerActivism , contentDescription = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.support_us)
- )
- }
- })
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun TopAppBarScaffoldWithBackButton(
- title : String , onBackClicked : () -> Unit , content : @Composable (PaddingValues) -> Unit
-) {
- val scrollBehaviorState : TopAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
- val view : View = LocalView.current
-
- Scaffold(modifier = Modifier.nestedScroll(scrollBehaviorState.nestedScrollConnection) , topBar = {
- LargeTopAppBar(title = { Text(text = title) } , navigationIcon = {
- IconButton(modifier = Modifier.bounceClick() , onClick = {
- onBackClicked()
- view.playSoundEffect(SoundEffectConstants.CLICK)
- }) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack , contentDescription = null)
- }
- } , scrollBehavior = scrollBehaviorState)
- }) { paddingValues ->
- content(paddingValues)
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun TopAppBarScaffoldWithBackButtonAndActions(
- context : Context , activity : Activity , showDialog : MutableState , eulaHtmlString : String? , changelogHtmlString : String? , scrollBehavior : TopAppBarScrollBehavior , view : View
-) {
- var showMenu : Boolean by remember { mutableStateOf(value = false) }
-
- LargeTopAppBar(
- title = { Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.help)) } ,
- navigationIcon = {
- IconButton(modifier = Modifier.bounceClick() , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- activity.finish()
- }) {
- Icon(
- Icons.AutoMirrored.Filled.ArrowBack , contentDescription = null
- )
- }
- } ,
- actions = {
- IconButton(modifier = Modifier.bounceClick() , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- showMenu = true
- }) {
- Icon(
- Icons.Default.MoreVert , contentDescription = "Localized description"
- )
- }
- DropdownMenu(expanded = showMenu , onDismissRequest = {
- showMenu = false
- }) {
- DropdownMenuItem(modifier = Modifier.bounceClick() , text = { Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.view_in_google_play_store)) } , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- IntentsHelper.openUrl(
- context , url = "https://play.google.com/store/apps/details?id=${activity.packageName}"
- )
- })
- DropdownMenuItem(modifier = Modifier.bounceClick() , text = { Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.version_info)) } , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- showDialog.value = true
- })
- DropdownMenuItem(modifier = Modifier.bounceClick() , text = { Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.beta_program)) } , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- IntentsHelper.openUrl(
- context , url = "https://play.google.com/apps/testing/${activity.packageName}"
- )
- })
- DropdownMenuItem(modifier = Modifier.bounceClick() , text = { Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.terms_of_service)) } , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- IntentsHelper.openUrl(
- context , url = "https://sites.google.com/view/d4rk7355608/more/apps/terms-of-service"
- )
- })
- DropdownMenuItem(modifier = Modifier.bounceClick() , text = { Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.privacy_policy)) } , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- IntentsHelper.openUrl(
- context , url = "https://sites.google.com/view/d4rk7355608/more/apps/privacy-policy"
- )
- })
- DropdownMenuItem(modifier = Modifier.bounceClick() , text = { Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.oss_license_title)) } , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- IntentsHelper.openLicensesScreen(
- context = context , eulaHtmlString = eulaHtmlString , changelogHtmlString = changelogHtmlString , appName = context.getString(R.string.app_name) , appVersion = BuildConfig.VERSION_NAME , appVersionCode = BuildConfig.VERSION_CODE , appShortDescription = R.string.app_short_description
- )
- })
- }
- if (showDialog.value) {
- VersionInfoAlertDialog(onDismiss = { showDialog.value = false } , copyrightString = R.string.copyright , appName = R.string.app_name , versionName = BuildConfig.VERSION_NAME , versionString = com.d4rk.android.libs.apptoolkit.R.string.version)
- }
- } ,
- scrollBehavior = scrollBehavior ,
- )
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun TopAppBarScaffold(
- title : String , content : @Composable (PaddingValues) -> Unit
-) {
- val scrollBehaviorState : TopAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
-
- Scaffold(modifier = Modifier.nestedScroll(scrollBehaviorState.nestedScrollConnection) , topBar = {
- LargeTopAppBar(title = { Text(text = title) } , scrollBehavior = scrollBehaviorState)
- }) { paddingValues ->
- content(paddingValues)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/favorites/FavoritesScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/favorites/FavoritesScreen.kt
deleted file mode 100644
index bf252300..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/favorites/FavoritesScreen.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.d4rk.androidtutorials.ui.screens.favorites
-
-import android.content.Context
-import androidx.compose.animation.core.Transition
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.updateTransition
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.ErrorOutline
-import androidx.compose.material.icons.outlined.HeartBroken
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.platform.LocalContext
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.d4rk.android.libs.apptoolkit.data.model.ui.error.UiErrorModel
-import com.d4rk.android.libs.apptoolkit.ui.components.dialogs.ErrorAlertDialog
-import com.d4rk.android.libs.apptoolkit.ui.components.layouts.LoadingScreen
-import com.d4rk.androidtutorials.R
-import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeScreen
-import com.d4rk.androidtutorials.ui.components.layouts.LessonListLayout
-import com.d4rk.androidtutorials.ui.components.layouts.NoLessonsScreen
-
-@Composable
-fun FavoritesScreen() {
- val viewModel : FavoritesViewModel = viewModel()
- val uiErrorModel : UiErrorModel by viewModel.uiErrorModel.collectAsState()
- val isLoading : Boolean by viewModel.isLoading.collectAsState()
- val favoriteLessons : List by viewModel.lessons.collectAsState()
-
- val transition : Transition =
- updateTransition(targetState = ! isLoading , label = "LoadingTransition")
- val progressAlpha : Float by transition.animateFloat(label = "Progress Alpha") {
- if (it) 0f else 1f
- }
-
- val visibilityStates : List by viewModel.visibilityStates.collectAsState()
-
- val context : Context = LocalContext.current
-
- if (uiErrorModel.showErrorDialog) {
- ErrorAlertDialog(errorMessage = uiErrorModel.errorMessage ,
- onDismiss = { viewModel.dismissErrorDialog() })
- }
-
- when {
- isLoading -> {
- LoadingScreen(progressAlpha)
- }
-
- else -> {
- when (val lessons = favoriteLessons.firstOrNull()?.lessons) {
- null -> NoLessonsScreen(
- text = R.string.error_loading_favorites,
- icon = Icons.Outlined.ErrorOutline
- )
-
- emptyList() -> NoLessonsScreen(
- text = R.string.no_favorite_lessons_found,
- icon = Icons.Outlined.HeartBroken
- )
-
- else -> LessonListLayout(
- lessons = lessons,
- context = context,
- visibilityStates = visibilityStates
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/favorites/FavoritesViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/favorites/FavoritesViewModel.kt
deleted file mode 100644
index 540fcfd8..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/favorites/FavoritesViewModel.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.d4rk.androidtutorials.ui.screens.favorites
-
-import android.app.Application
-import androidx.lifecycle.viewModelScope
-import com.d4rk.androidtutorials.data.datastore.DataStore
-import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeScreen
-import com.d4rk.androidtutorials.ui.screens.home.repository.HomeRepository
-import com.d4rk.androidtutorials.ui.viewmodel.LessonsViewModel
-import com.d4rk.androidtutorials.utils.extensions.toUiLesson
-import kotlinx.coroutines.launch
-
-class FavoritesViewModel(application : Application) : LessonsViewModel(application) {
- private val repository = HomeRepository(DataStore(application) , application)
-
- init {
- loadFavorites()
- observeFavorites()
- }
-
- private fun loadFavorites() {
- viewModelScope.launch(context = coroutineExceptionHandler) {
- showLoading()
- repository.loadFavoritesRepository { favorites ->
- val favoriteLessons = favorites.map { lesson ->
- lesson.toUiLesson()
- }
- listOf(UiHomeScreen(lessons = ArrayList(favoriteLessons))).also {
- _lessons.value = it
- }
- }
- hideLoading()
- initializeVisibilityStates()
- }
- }
-
- private fun observeFavorites() {
- viewModelScope.launch(context = coroutineExceptionHandler) {
- repository.observeFavoritesChanges { favorites ->
- val favoriteLessons = favorites.map { lesson ->
- lesson.toUiLesson()
- }
- _lessons.value =
- listOf(element = UiHomeScreen(lessons = ArrayList(favoriteLessons)))
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/HelpActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/HelpActivity.kt
deleted file mode 100644
index dc9686eb..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/HelpActivity.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.d4rk.androidtutorials.ui.screens.help
-
-import android.os.Bundle
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.ui.Modifier
-import com.d4rk.androidtutorials.ui.screens.settings.display.theme.style.AppTheme
-
-class HelpActivity : AppCompatActivity() {
- private val viewModel : HelpViewModel by viewModels()
-
- override fun onCreate(savedInstanceState : Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContent {
- AppTheme {
- Surface(
- modifier = Modifier.fillMaxSize() , color = MaterialTheme.colorScheme.background
- ) {
- HelpScreen(activity = this@HelpActivity , viewModel)
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/HelpScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/HelpScreen.kt
deleted file mode 100644
index 4a0af55b..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/HelpScreen.kt
+++ /dev/null
@@ -1,228 +0,0 @@
-package com.d4rk.androidtutorials.ui.screens.help
-
-import android.app.Activity
-import android.content.Context
-import android.view.SoundEffectConstants
-import android.view.View
-import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ExpandLess
-import androidx.compose.material.icons.filled.ExpandMore
-import androidx.compose.material.icons.outlined.QuestionAnswer
-import androidx.compose.material.icons.outlined.RateReview
-import androidx.compose.material.icons.outlined.Support
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
-import androidx.compose.material3.rememberTopAppBarState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.State
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.snapshots.SnapshotStateMap
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.d4rk.android.libs.apptoolkit.ui.components.buttons.AnimatedExtendedFloatingActionButton
-import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.bounceClick
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.LargeHorizontalSpacer
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.MediumVerticalSpacer
-import com.d4rk.android.libs.apptoolkit.ui.components.spacers.SmallVerticalSpacer
-import com.d4rk.android.libs.apptoolkit.utils.helpers.IntentsHelper
-import com.d4rk.android.libs.apptoolkit.utils.rememberHtmlData
-import com.d4rk.androidtutorials.BuildConfig
-import com.d4rk.androidtutorials.R
-import com.d4rk.androidtutorials.data.model.ui.screens.UiHelpQuestion
-import com.d4rk.androidtutorials.data.model.ui.screens.UiHelpScreen
-import com.d4rk.androidtutorials.ui.components.navigation.TopAppBarScaffoldWithBackButtonAndActions
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun HelpScreen(activity : Activity , viewModel : HelpViewModel) {
- val scrollBehavior : TopAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
- val context : Context = LocalContext.current
- val view : View = LocalView.current
- val isFabVisible : Boolean by viewModel.isFabVisible.collectAsState()
- val showDialog : MutableState = remember { mutableStateOf(value = false) }
-
- val uiState : UiHelpScreen by viewModel.uiState.collectAsState()
-
- val htmlData : State> = rememberHtmlData(
- context = context , currentVersionName = BuildConfig.VERSION_NAME , packageName = BuildConfig.APPLICATION_ID
- )
-
- val changelogHtmlString : String? = htmlData.value.first
- val eulaHtmlString : String? = htmlData.value.second
-
- val isFabExtended : MutableState = remember { mutableStateOf(value = true) }
- LaunchedEffect(key1 = scrollBehavior.state.contentOffset) {
- isFabExtended.value = scrollBehavior.state.contentOffset >= 0f
- }
-
- Scaffold(
- modifier = Modifier.nestedScroll(connection = scrollBehavior.nestedScrollConnection) ,
- topBar = {
- TopAppBarScaffoldWithBackButtonAndActions(
- context = context , activity = activity , showDialog = showDialog , eulaHtmlString = eulaHtmlString , changelogHtmlString = changelogHtmlString , scrollBehavior = scrollBehavior , view = view
- )
- } ,
- floatingActionButton = {
- AnimatedExtendedFloatingActionButton(visible = isFabVisible , onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- uiState.reviewInfo?.let { safeReviewInfo ->
- viewModel.launchReviewFlow(
- activity = activity , reviewInfo = safeReviewInfo
- )
- viewModel.requestReviewFlow()
- }
- } , text = { Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.feedback)) } , icon = {
- Icon(
- Icons.Outlined.RateReview , contentDescription = null
- )
- } , expanded = isFabExtended.value , modifier = Modifier.bounceClick())
- } ,
- ) { paddingValues ->
- LazyColumn(
- modifier = Modifier
- .padding(paddingValues = paddingValues)
- .fillMaxSize()
- .padding(horizontal = 16.dp) , state = rememberLazyListState()
- ) {
- item {
- Text(
- text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.popular_help_resources)
- )
-
- MediumVerticalSpacer()
-
- Card(modifier = Modifier.fillMaxWidth()) {
- FAQComposable(questions = uiState.questions)
- }
-
- MediumVerticalSpacer()
- ContactUsCard(onClick = {
- view.playSoundEffect(SoundEffectConstants.CLICK)
- IntentsHelper.sendEmailToDeveloper(context = activity , applicationNameRes = R.string.app_name)
- })
- Spacer(modifier = Modifier.height(height = 64.dp))
- }
- }
- }
-}
-
-@Composable
-fun FAQComposable(questions : List) {
- val expandedStates : SnapshotStateMap = remember { mutableStateMapOf() }
-
- Column {
- questions.forEachIndexed { index , question ->
- val isExpanded = expandedStates[index] ?: false
- QuestionComposable(title = question.question , summary = question.answer , isExpanded = isExpanded , onToggleExpand = {
- expandedStates[index] = ! isExpanded
- })
- }
- }
-}
-
-
-@Composable
-fun QuestionComposable(
- title : String , summary : String , isExpanded : Boolean , onToggleExpand : () -> Unit
-) {
- Card(modifier = Modifier
- .clip(shape = RoundedCornerShape(size = 12.dp))
- .clickable { onToggleExpand() }
- .padding(all = 16.dp)
- .animateContentSize()
- .fillMaxWidth()) {
- Column(
- modifier = Modifier.fillMaxSize()
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically , modifier = Modifier.fillMaxWidth()
- ) {
- Icon(
- imageVector = Icons.Outlined.QuestionAnswer , contentDescription = null , tint = MaterialTheme.colorScheme.primary , modifier = Modifier
- .size(size = 48.dp)
- .background(
- color = MaterialTheme.colorScheme.primaryContainer , shape = CircleShape
- )
- .padding(all = 8.dp)
- )
- LargeHorizontalSpacer()
-
- Text(
- text = title , style = MaterialTheme.typography.titleMedium , modifier = Modifier.weight(weight = 1f)
- )
-
- Icon(
- imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore , contentDescription = null , tint = MaterialTheme.colorScheme.primary , modifier = Modifier.size(size = 24.dp)
- )
- }
- if (isExpanded) {
- SmallVerticalSpacer()
- Text(
- text = summary ,
- style = MaterialTheme.typography.bodyMedium ,
- )
- }
- }
- }
-}
-
-@Composable
-fun ContactUsCard(onClick : () -> Unit) {
- Card(modifier = Modifier
- .fillMaxWidth()
- .clip(shape = RoundedCornerShape(size = 12.dp))
- .clickable {
- onClick()
- }) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(all = 16.dp) , verticalAlignment = Alignment.CenterVertically , horizontalArrangement = Arrangement.Center
- ) {
- Icon(Icons.Outlined.Support , contentDescription = null , modifier = Modifier.padding(end = 16.dp))
- Column(
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight()
- ) {
- Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.contact_us))
- Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.contact_us_description))
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/HelpViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/HelpViewModel.kt
deleted file mode 100644
index f5e77fd1..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/HelpViewModel.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.d4rk.androidtutorials.ui.screens.help
-
-import android.app.Activity
-import android.app.Application
-import androidx.lifecycle.viewModelScope
-import com.d4rk.androidtutorials.data.model.ui.screens.UiHelpScreen
-import com.d4rk.androidtutorials.ui.screens.help.repository.HelpRepository
-import com.d4rk.androidtutorials.ui.viewmodel.BaseViewModel
-import com.google.android.play.core.review.ReviewInfo
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.launch
-
-class HelpViewModel(application : Application) : BaseViewModel(application) {
-
- private val repository : HelpRepository = HelpRepository(application = application)
-
- private val _uiState : MutableStateFlow = MutableStateFlow(UiHelpScreen())
- val uiState : StateFlow = _uiState
-
- init {
- initializeVisibilityStates()
- getFAQs()
- requestReviewFlow()
- }
-
- private fun initializeVisibilityStates() {
- viewModelScope.launch(context = coroutineExceptionHandler) {
- delay(timeMillis = 100L)
- showFab()
- }
- }
-
- private fun getFAQs() {
- viewModelScope.launch(context = coroutineExceptionHandler) {
- repository.getFAQsRepository { faqList ->
- _uiState.value = _uiState.value.copy(questions = faqList)
- }
- }
- }
-
- fun requestReviewFlow() {
- viewModelScope.launch(context = coroutineExceptionHandler) {
- repository.requestReviewFlowRepository(onSuccess = { reviewInfo ->
- _uiState.value = _uiState.value.copy(reviewInfo = reviewInfo)
- } , onFailure = {})
- }
- }
-
- fun launchReviewFlow(activity : Activity , reviewInfo : ReviewInfo) {
- viewModelScope.launch(context = coroutineExceptionHandler) {
- repository.launchReviewFlowRepository(activity = activity , reviewInfo = reviewInfo)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/repository/HelpRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/repository/HelpRepository.kt
deleted file mode 100644
index 089f18c3..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/repository/HelpRepository.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.d4rk.androidtutorials.ui.screens.help.repository
-
-import android.app.Activity
-import android.app.Application
-import com.d4rk.androidtutorials.data.model.ui.screens.UiHelpQuestion
-import com.google.android.play.core.review.ReviewInfo
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-
-class HelpRepository(application : Application) : HelpRepositoryImplementation(application) {
-
- suspend fun getFAQsRepository(onSuccess : (ArrayList) -> Unit) {
- withContext(Dispatchers.IO) {
- val questions = getFAQsImplementation().map { (questionRes , summaryRes) ->
- UiHelpQuestion(
- question = application.getString(questionRes) , answer = application.getString(summaryRes)
- )
- }.toCollection(destination = ArrayList())
-
- withContext(Dispatchers.Main) {
- onSuccess(questions)
- }
- }
- }
-
- suspend fun requestReviewFlowRepository(
- onSuccess : (ReviewInfo) -> Unit , onFailure : () -> Unit
- ) {
- withContext(Dispatchers.IO) {
- requestReviewFlowImplementation(onSuccess = onSuccess , onFailure = onFailure)
- }
- }
-
- suspend fun launchReviewFlowRepository(activity : Activity , reviewInfo : ReviewInfo) {
- withContext(Dispatchers.IO) {
- launchReviewFlowImplementation(activity = activity , reviewInfo = reviewInfo)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/repository/HelpRepositoryImplementation.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/repository/HelpRepositoryImplementation.kt
deleted file mode 100644
index 84eebf97..00000000
--- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/help/repository/HelpRepositoryImplementation.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package com.d4rk.androidtutorials.ui.screens.help.repository
-
-import android.app.Activity
-import android.app.Application
-import com.d4rk.android.libs.apptoolkit.utils.helpers.IntentsHelper
-import com.d4rk.androidtutorials.R
-import com.google.android.gms.tasks.Task
-import com.google.android.play.core.review.ReviewInfo
-import com.google.android.play.core.review.ReviewManager
-import com.google.android.play.core.review.ReviewManagerFactory
-
-abstract class HelpRepositoryImplementation(
- val application : Application
-) {
- fun getFAQsImplementation() : List> {
- return listOf(
- R.string.question_1 to R.string.summary_preference_faq_1 ,
- R.string.question_2 to R.string.summary_preference_faq_2 ,
- R.string.question_3 to R.string.summary_preference_faq_3 ,
- R.string.question_4 to R.string.summary_preference_faq_4 ,
- R.string.question_5 to R.string.summary_preference_faq_5 ,
- R.string.question_6 to R.string.summary_preference_faq_6 ,
- R.string.question_7 to R.string.summary_preference_faq_7 ,
- R.string.question_8 to R.string.summary_preference_faq_8 ,
- R.string.question_9 to R.string.summary_preference_faq_9
- )
- }
-
- open suspend fun requestReviewFlowImplementation(
- onSuccess : (ReviewInfo) -> Unit , onFailure : () -> Unit
- ) {
- val reviewManager : ReviewManager = ReviewManagerFactory.create(application)
- val request : Task