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 @@ - + 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 = reviewManager.requestReviewFlow() - val packageName : String = application.packageName - - request.addOnCompleteListener { task -> - if (task.isSuccessful) { - onSuccess(task.result) - } - else { - onFailure() - } - }.addOnFailureListener { - IntentsHelper.openUrl( - context = application , url = "https://play.google.com/store/apps/details?id=$packageName&showAllReviews=true" - ) - } - } - - fun launchReviewFlowImplementation(activity : Activity , reviewInfo : ReviewInfo) { - val reviewManager : ReviewManager = ReviewManagerFactory.create(activity) - reviewManager.launchReviewFlow(activity , reviewInfo) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/HomeScreen.kt deleted file mode 100644 index 1191030d..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/HomeScreen.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.home - -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.foundation.layout.fillMaxSize -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -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.data.model.ui.screens.UiHomeScreen -import com.d4rk.androidtutorials.ui.components.layouts.LessonListLayout -import com.d4rk.androidtutorials.ui.components.layouts.NoLessonsScreen - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun HomeScreen() { - val viewModel : HomeViewModel = viewModel() - val uiErrorModel : UiErrorModel by viewModel.uiErrorModel.collectAsState() - val lessons : List by viewModel.lessons.collectAsState() - val isLoading : Boolean by viewModel.isLoading.collectAsState() - - val context : Context = LocalContext.current - - val transition : Transition = updateTransition(targetState = ! isLoading , label = "LoadingTransition") - val progressAlpha : Float by transition.animateFloat(label = "Progress Alpha") { - if (it) 0f else 1f - } - - if (uiErrorModel.showErrorDialog) { - ErrorAlertDialog(errorMessage = uiErrorModel.errorMessage) { - viewModel.dismissErrorDialog() - } - } - - val visibilityStates : List by viewModel.visibilityStates.collectAsState() - - if (lessons.firstOrNull()?.lessons.isNullOrEmpty() && isLoading) { - LoadingScreen(progressAlpha = progressAlpha) - } - else { - PullToRefreshBox(isRefreshing = isLoading , onRefresh = { viewModel.getHomeLessons() } , modifier = Modifier.fillMaxSize()) { - when { - lessons.firstOrNull()?.lessons?.isNotEmpty() == true -> { - LessonListLayout(lessons = lessons.first().lessons , context = context , visibilityStates = visibilityStates) - } - - else -> { - NoLessonsScreen(showRetry = true , onRetry = { viewModel.getHomeLessons() }) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/HomeViewModel.kt deleted file mode 100644 index 48d1598b..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/HomeViewModel.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.home - -import android.app.Application -import androidx.lifecycle.viewModelScope -import com.d4rk.androidtutorials.data.datastore.DataStore -import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeLesson -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.toFavoriteLessonTable -import kotlinx.coroutines.launch - -class HomeViewModel(application : Application) : LessonsViewModel(application) { - private val repository : HomeRepository = HomeRepository(DataStore(application) , application) - - init { - getHomeLessons() - observeFavorites() - } - - fun getHomeLessons() { - viewModelScope.launch(context = coroutineExceptionHandler) { - showLoading() - repository.getHomeLessonsRepository { fetchedLessons -> - _lessons.value = listOf(element = fetchedLessons) - } - hideLoading() - initializeVisibilityStates() - } - } - - private fun observeFavorites() { - viewModelScope.launch(context = coroutineExceptionHandler) { - repository.observeFavoritesChanges { favorites -> - val updatedLessons = _lessons.value.firstOrNull()?.lessons?.map { lesson -> - lesson.copy(isFavorite = favorites.any { it.lessonId == lesson.lessonId }) - } ?: emptyList() - - _lessons.value = listOf(element = UiHomeScreen(lessons = ArrayList(updatedLessons))) - } - } - } - - fun toggleFavorite(lesson : UiHomeLesson) { - viewModelScope.launch(context = coroutineExceptionHandler) { - val updatedLesson = lesson.copy(isFavorite = ! lesson.isFavorite) - val updatedLessons = _lessons.value.firstOrNull()?.lessons?.map { - if (it.lessonId == updatedLesson.lessonId) updatedLesson else it - } ?: emptyList() - - _lessons.value = listOf(element = UiHomeScreen(lessons = ArrayList(updatedLessons))) - - if (updatedLesson.isFavorite) { - addLessonToFavorites(lesson = updatedLesson) - } - else { - removeLessonFromFavorites(lesson = updatedLesson) - } - } - } - - private fun addLessonToFavorites(lesson : UiHomeLesson) { - viewModelScope.launch(context = coroutineExceptionHandler) { - val favoriteLesson = lesson.toFavoriteLessonTable() - repository.addLessonToFavoritesRepository(lesson = favoriteLesson) {} - } - } - - private fun removeLessonFromFavorites(lesson : UiHomeLesson) { - viewModelScope.launch(context = coroutineExceptionHandler) { - val favoriteLesson = lesson.toFavoriteLessonTable() - repository.removeLessonFromFavoritesRepository(lesson = favoriteLesson) {} - } - } - - fun shareLesson(lesson : UiHomeLesson) { - viewModelScope.launch(context = coroutineExceptionHandler) { - repository.shareLessonRepository(lesson = lesson) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/repository/HomeRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/repository/HomeRepository.kt deleted file mode 100644 index 00c38866..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/repository/HomeRepository.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.home.repository - -import android.app.Application -import android.content.Intent -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.data.database.table.FavoriteLessonTable -import com.d4rk.androidtutorials.data.datastore.DataStore -import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeLesson -import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeScreen -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * Repository class for handling home screen data and operations. - * - * @param dataStore The DataStore instance for accessing user preferences. - * @param application The application instance for accessing resources and external files directory. - * @author Mihai-Cristian Condrea - */ -class HomeRepository( - dataStore : DataStore , application : Application , -) : HomeRepositoryImplementation(application , dataStore) { - - suspend fun getHomeLessonsRepository(onSuccess : (UiHomeScreen) -> Unit) { - withContext(Dispatchers.IO) { - val lessons = getHomeLessonsImplementation() - withContext(Dispatchers.Main) { - onSuccess(lessons) - } - } - } - - suspend fun addLessonToFavoritesRepository( - lesson : FavoriteLessonTable , - onSuccess : () -> Unit , - ) { - withContext(Dispatchers.IO) { - addLessonToFavoritesImplementation(lesson) - withContext(Dispatchers.Main) { - onSuccess() - } - } - } - - suspend fun loadFavoritesRepository(onSuccess : (List) -> Unit) { - withContext(Dispatchers.IO) { - val favorites = loadFavoritesImplementation() - withContext(Dispatchers.Main) { - onSuccess(favorites) - } - } - } - - suspend fun observeFavoritesChanges(onFavoritesChanged : (List) -> Unit) { - AppCoreManager.database.favoriteLessonsDao().getAllFavoritesFlow().collect { favorites -> - withContext(Dispatchers.Main) { - onFavoritesChanged(favorites) - } - } - } - - suspend fun removeLessonFromFavoritesRepository( - lesson : FavoriteLessonTable , - onSuccess : () -> Unit , - ) { - withContext(Dispatchers.IO) { - removeLessonFromFavoritesImplementation(lesson = lesson) - withContext(Dispatchers.Main) { - onSuccess() - } - } - } - - suspend fun shareLessonRepository(lesson : UiHomeLesson) { - withContext(Dispatchers.IO) { - val shareIntent : Intent = shareLessonImplementation(lesson) - withContext(Dispatchers.Main) { - val chooserIntent : Intent = Intent.createChooser( - shareIntent , application.getString(com.d4rk.android.libs.apptoolkit.R.string.share) - ) - chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - application.startActivity(chooserIntent) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/repository/HomeRepositoryImplementation.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/repository/HomeRepositoryImplementation.kt deleted file mode 100644 index e4e7cdd8..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/home/repository/HomeRepositoryImplementation.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.home.repository - -import android.app.Application -import android.content.Intent -import com.d4rk.androidtutorials.BuildConfig -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.utils.constants.api.ApiConstants -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.data.database.table.FavoriteLessonTable -import com.d4rk.androidtutorials.data.datastore.DataStore -import com.d4rk.androidtutorials.data.model.api.ApiHomeResponse -import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeLesson -import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeScreen -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.contentType -import kotlinx.serialization.json.Json - -abstract class HomeRepositoryImplementation( - val application : Application , - val dataStore : DataStore , -) { - - private val client : HttpClient = AppCoreManager.ktorClient - - private val baseUrl : String = BuildConfig.DEBUG.let { isDebug -> - val environment : String = if (isDebug) "debug" else "release" - "${ApiConstants.BASE_REPOSITORY_URL}/$environment/en/home/api_get_lessons.json" - } - - suspend fun getHomeLessonsImplementation() : UiHomeScreen { - return runCatching { - val response : HttpResponse = client.get(urlString = baseUrl) { - contentType(type = ContentType.Application.Json) - } - - val lessons : List = - response.bodyAsText().takeUnless { text -> text.isBlank() } - ?.let { Json.decodeFromString(it) }?.data?.map { apiLesson -> - UiHomeLesson(lessonId = apiLesson.lessonId , - lessonTitle = apiLesson.lessonTitle , - lessonDescription = apiLesson.lessonDescription , - lessonType = apiLesson.lessonType , - lessonTags = apiLesson.lessonTags , - thumbnailImageUrl = apiLesson.thumbnailImageUrl , - squareImageUrl = apiLesson.squareImageUrl , - deepLinkPath = apiLesson.deepLinkPath , - isFavorite = loadFavoritesImplementation().any { it.lessonId == apiLesson.lessonId }) - } ?: emptyList() - UiHomeScreen(lessons = ArrayList(lessons)) - }.getOrElse { - UiHomeScreen() - } - } - - suspend fun loadFavoritesImplementation() : List { - return AppCoreManager.database.favoriteLessonsDao().getAllFavorites() - } - - suspend fun addLessonToFavoritesImplementation(lesson : FavoriteLessonTable) { - AppCoreManager.database.favoriteLessonsDao().insert(favoriteLesson = lesson) - } - - suspend fun removeLessonFromFavoritesImplementation(lesson : FavoriteLessonTable) { - AppCoreManager.database.favoriteLessonsDao().delete(favoriteLesson = lesson) - } - - fun shareLessonImplementation(lesson : UiHomeLesson) : Intent { - return Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT , buildString { - append(lesson.lessonDescription) - append("\n\n") - append( - application.getString( - R.string.get_the_app_to_watch , - "https://play.google.com/store/apps/details?id=${application.packageName}" - ) - ) - }) - putExtra( - Intent.EXTRA_SUBJECT , - application.getString(R.string.share_lesson_subject , lesson.lessonTitle) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/LessonActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/LessonActivity.kt deleted file mode 100644 index 6f6bc025..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/LessonActivity.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.lessons - -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.runtime.collectAsState -import androidx.compose.ui.Modifier -import com.d4rk.androidtutorials.ui.screens.settings.display.theme.style.AppTheme - -class LessonActivity : AppCompatActivity() { - private val viewModel : LessonViewModel by viewModels() - - override fun onCreate(savedInstanceState : Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - val lessonId = intent?.data?.lastPathSegment - lessonId?.let { id -> - viewModel.getLesson(lessonId = id) - } - AppTheme { - Surface( - modifier = Modifier.fillMaxSize() , color = MaterialTheme.colorScheme.background - ) { - val lesson = viewModel.lesson.collectAsState() - lesson.value?.let { content -> - LessonScreen( - lesson = content , - activity = this@LessonActivity , - viewModel = viewModel - ) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/LessonScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/LessonScreen.kt deleted file mode 100644 index 4d45acf4..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/LessonScreen.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.lessons - -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.rememberScrollState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -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.data.model.ui.screens.UiLessonScreen -import com.d4rk.androidtutorials.ui.components.layouts.LessonContentLayout -import com.d4rk.androidtutorials.ui.components.navigation.TopAppBarScaffoldWithBackButton - -@Composable -fun LessonScreen( - lesson : UiLessonScreen , - activity : LessonActivity , - viewModel : LessonViewModel , -) { - val uiErrorModel : UiErrorModel by viewModel.uiErrorModel.collectAsState() - val isLoading : Boolean by viewModel.isLoading.collectAsState() - val scrollState = rememberScrollState() - val transition : Transition = - updateTransition(targetState = ! isLoading , label = "LoadingTransition") - val progressAlpha : Float by transition.animateFloat(label = "Progress Alpha") { - if (it) 0f else 1f - } - - if (uiErrorModel.showErrorDialog) { - ErrorAlertDialog(errorMessage = uiErrorModel.errorMessage , - onDismiss = { viewModel.dismissErrorDialog() }) - } - - TopAppBarScaffoldWithBackButton(title = lesson.lessonTitle , - onBackClicked = { activity.finish() }) { paddingValues -> - when { - isLoading -> { - LoadingScreen(progressAlpha = progressAlpha) - } - - else -> { - LessonContentLayout( - lesson = lesson , - paddingValues = paddingValues , - scrollState = scrollState , - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/LessonViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/LessonViewModel.kt deleted file mode 100644 index c47af23b..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/LessonViewModel.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.lessons - -import android.app.Application -import androidx.lifecycle.viewModelScope -import com.d4rk.androidtutorials.data.model.ui.screens.UiLessonScreen -import com.d4rk.androidtutorials.ui.screens.lessons.repository.LessonRepository -import com.d4rk.androidtutorials.ui.viewmodel.LessonsViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -class LessonViewModel(application : Application) : LessonsViewModel(application) { - private val repository : LessonRepository = LessonRepository() - private val _lesson : MutableStateFlow = MutableStateFlow(value = null) - val lesson : StateFlow = _lesson.asStateFlow() - - fun getLesson(lessonId : String) { - viewModelScope.launch(context = coroutineExceptionHandler) { - showLoading() - repository.getLessonRepository(lessonId = lessonId) { fetchedLesson -> - _lesson.value = fetchedLesson - } - hideLoading() - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/repository/LessonRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/repository/LessonRepository.kt deleted file mode 100644 index 24642107..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/repository/LessonRepository.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.lessons.repository - -import com.d4rk.androidtutorials.data.model.ui.screens.UiLessonScreen -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * Repository class for handling home screen data and operations. - * - * @param dataStore The DataStore instance for accessing user preferences. - * @param application The application instance for accessing resources and external files directory. - * @author Mihai-Cristian Condrea - */ -class LessonRepository : LessonRepositoryImplementation() { - - suspend fun getLessonRepository( - lessonId : String , onSuccess : (UiLessonScreen?) -> Unit - ) { - withContext(Dispatchers.IO) { - val lesson = getLessonImplementation(lessonId = lessonId) - withContext(Dispatchers.Main) { - onSuccess(lesson) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/repository/LessonRepositoryImplementation.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/repository/LessonRepositoryImplementation.kt deleted file mode 100644 index 7a8b7b09..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/lessons/repository/LessonRepositoryImplementation.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.lessons.repository - -import com.d4rk.androidtutorials.BuildConfig -import com.d4rk.androidtutorials.utils.constants.api.ApiConstants -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.data.model.api.ApiLessonResponse -import com.d4rk.androidtutorials.data.model.ui.screens.UiLessonContent -import com.d4rk.androidtutorials.data.model.ui.screens.UiLessonScreen -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.contentType -import kotlinx.serialization.json.Json - -abstract class LessonRepositoryImplementation { - - private val client : HttpClient = AppCoreManager.ktorClient - - private val baseUrl = BuildConfig.DEBUG.let { isDebug -> - val environment = if (isDebug) "debug" else "release" - "${ApiConstants.BASE_REPOSITORY_URL}/$environment/en/lessons" - } - - suspend fun getLessonImplementation(lessonId : String) : UiLessonScreen { - val url = "$baseUrl/api_get_$lessonId.json" - - return runCatching { - val response : HttpResponse = client.get(urlString = url) { - contentType(type = ContentType.Application.Json) - } - - val lessonScreen : UiLessonScreen = response.bodyAsText().takeUnless { text -> - text.isBlank() - }?.let { - Json.decodeFromString(it) - }?.data?.firstOrNull()?.let { apiLesson -> - UiLessonScreen( - lessonTitle = apiLesson.lessonTitle , - lessonContent = ArrayList(apiLesson.lessonContent.map { apiContent -> - UiLessonContent( - contentId = apiContent.contentId , - contentType = apiContent.contentType , - contentText = apiContent.contentText , - contentImageUrl = apiContent.contentImageUrl , - contentCode = apiContent.contentCode , - programmingLanguage = apiContent.programmingLanguage - ) - }) - ) - } ?: UiLessonScreen() - lessonScreen - }.getOrElse { - UiLessonScreen() - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/MainActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/MainActivity.kt deleted file mode 100644 index f4d0fd56..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/MainActivity.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.main - -import android.content.Intent -import android.os.Build -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.activity.viewModels -import androidx.annotation.RequiresApi -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 androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import com.d4rk.android.libs.apptoolkit.notifications.managers.AppUpdateNotificationsManager -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.ui.screens.settings.display.theme.style.AppTheme -import com.google.android.gms.ads.MobileAds -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.google.android.play.core.appupdate.AppUpdateManager -import com.google.android.play.core.appupdate.AppUpdateManagerFactory -import com.google.android.play.core.install.model.ActivityResult - -class MainActivity : AppCompatActivity() { - private val viewModel : MainViewModel by viewModels() - private lateinit var appUpdateManager : AppUpdateManager - private lateinit var appUpdateNotificationsManager : AppUpdateNotificationsManager - private lateinit var updateResultLauncher : ActivityResultLauncher - - override fun onCreate(savedInstanceState : Bundle?) { - super.onCreate(savedInstanceState) - installSplashScreen().apply { - setKeepOnScreenCondition { - ! (application as AppCoreManager).isAppLoaded() - } - } - enableEdgeToEdge() - initializeActivityComponents() - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize() , color = MaterialTheme.colorScheme.background - ) { - MainScreen(viewModel = viewModel) - } - } - } - } - - @RequiresApi(Build.VERSION_CODES.O) - override fun onResume() { - super.onResume() - with(receiver = viewModel) { - checkAndHandleStartup() - configureSettings() - viewModel.checkForUpdates( - appUpdateManager = appUpdateManager , updateResultLauncher = updateResultLauncher - ) - checkAndScheduleUpdateNotifications(appUpdateNotificationsManager = appUpdateNotificationsManager) - checkAppUsageNotifications(context = this@MainActivity) - } - } - - /** - * This method overrides the `onBackPressed()` method **(deprecated in Java)** to display a confirmation dialog - * before closing the activity. While this method might work, it's recommended to use more modern approaches - * for handling back button presses, such as using Navigation components or Activity lifecycles. - * - * This method is annotated with `@Deprecated` and `@Suppress("DEPRECATION")` to explicitly mark it as deprecated - * and suppress compiler warnings during its usage. - * - * Consider utilizing alternative approaches for handling back button events. - */ - @Deprecated("Deprecated in Java") - @Suppress("DEPRECATION") - override fun onBackPressed() { - MaterialAlertDialogBuilder(this).setTitle(com.d4rk.android.libs.apptoolkit.R.string.close).setMessage(com.d4rk.android.libs.apptoolkit.R.string.summary_close).setPositiveButton(android.R.string.yes) { _ , _ -> - super.onBackPressed() - moveTaskToBack(true) - }.setNegativeButton(android.R.string.no , null).apply { show() } - } - - /** - * Overrides the `onActivityResult` method to handle the result of an activity launched for result. - * - * This function is specifically designed to handle the result of a request code (1) - * which is used for in-app updates. It checks the `resultCode` to determine the outcome of the update process. - * Depending on the `resultCode`, it either displays a Snackbar message indicating a successful update or - * calls a function to show a Snackbar message indicating that the update failed. - * - * @param requestCode The integer request code originally supplied to startActivityForResult(), - * allowing you to identify who this result came from. - * @param resultCode The integer result code returned by the child activity through its setResult(). - * @param data An Intent, which can return result data to the caller (various data can be attached to Intent "extras"). - */ - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode : Int , resultCode : Int , data : Intent?) { - super.onActivityResult(requestCode , resultCode , data) - if (requestCode == 1) { - when (resultCode) { - RESULT_OK -> { - showUpdateSuccessfulSnackbar() - } - - ActivityResult.RESULT_IN_APP_UPDATE_FAILED -> { - showUpdateFailedSnackbar() - } - } - } - } - - private fun initializeActivityComponents() { - MobileAds.initialize(this@MainActivity) - updateResultLauncher = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - when (result.resultCode) { - RESULT_OK -> showUpdateSuccessfulSnackbar() - else -> showUpdateFailedSnackbar() - } - } - appUpdateManager = AppUpdateManagerFactory.create(this@MainActivity) - appUpdateNotificationsManager = AppUpdateNotificationsManager(context = this , channelId = "update_channel") - } - - private fun showUpdateSuccessfulSnackbar() { - val snackbar : Snackbar = Snackbar.make( - findViewById(android.R.id.content) , com.d4rk.android.libs.apptoolkit.R.string.snack_app_updated , Snackbar.LENGTH_LONG - ).setAction(android.R.string.ok , null) - snackbar.show() - } - - /** - * Displays a Snackbar message indicating that the update process has failed. - * - * This function creates a Snackbar with a message indicating that the update process has failed. - * The Snackbar includes a "Try Again" action which, when clicked, triggers the `checkForFlexibleUpdate` function - * to check for updates and initiate the appropriate update flow if conditions are met. - */ - private fun showUpdateFailedSnackbar() { - val snackbar : Snackbar = Snackbar.make( - findViewById(android.R.id.content) , com.d4rk.android.libs.apptoolkit.R.string.snack_update_failed , Snackbar.LENGTH_LONG - ).setAction(com.d4rk.android.libs.apptoolkit.R.string.try_again) { - viewModel.checkForUpdates( - appUpdateManager = appUpdateManager , updateResultLauncher = updateResultLauncher - ) - } - snackbar.show() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/MainScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/MainScreen.kt deleted file mode 100644 index 5c9b214d..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/MainScreen.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.main - -import android.content.Context -import androidx.compose.foundation.layout.fillMaxSize -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.DrawerValue -import androidx.compose.material3.Scaffold -import androidx.compose.material3.rememberDrawerState -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.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.navigation.compose.rememberNavController -import com.d4rk.android.libs.apptoolkit.utils.helpers.ScreenHelper -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.data.model.ui.screens.MainScreenState -import com.d4rk.androidtutorials.ui.components.navigation.BottomNavigationBar -import com.d4rk.androidtutorials.ui.components.navigation.LeftNavigationRail -import com.d4rk.androidtutorials.ui.components.navigation.NavigationDrawer -import com.d4rk.androidtutorials.ui.components.navigation.NavigationHost -import com.d4rk.androidtutorials.ui.components.navigation.TopAppBarMain -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -@Composable -fun MainScreen(viewModel : MainViewModel) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - val navController = rememberNavController() - val context = LocalContext.current - val view = LocalView.current - val dataStore = AppCoreManager.dataStore - - val isTabletOrLandscape : Boolean = ScreenHelper.isLandscapeOrTablet(context = context) - - val mainScreenState = remember { - MainScreenState( - context = context , view = view , drawerState = drawerState , navHostController = navController , dataStore = dataStore , viewModel = viewModel - ) - } - - if (isTabletOrLandscape) { - MainScaffoldTabletContent(mainScreenState = mainScreenState) - } - else { - NavigationDrawer( - mainScreenState = mainScreenState - ) - } -} - -@Composable -fun MainScaffoldContent( - mainScreenState : MainScreenState , coroutineScope : CoroutineScope -) { - Scaffold(modifier = Modifier.imePadding() , topBar = { - - TopAppBarMain(view = mainScreenState.view , context = mainScreenState.context , navigationIcon = if (mainScreenState.drawerState.isOpen) Icons.AutoMirrored.Outlined.MenuOpen else Icons.Default.Menu , onNavigationIconClick = { - coroutineScope.launch { - mainScreenState.drawerState.apply { - if (isClosed) open() else close() - } - } - }) - } , bottomBar = { - BottomNavigationBar( - navController = mainScreenState.navHostController , dataStore = mainScreenState.dataStore , view = mainScreenState.view , viewModel = mainScreenState.viewModel - ) - }) { paddingValues -> - NavigationHost( - navHostController = mainScreenState.navHostController , dataStore = mainScreenState.dataStore , paddingValues = paddingValues - ) - } -} - -@Composable -fun MainScaffoldTabletContent(mainScreenState : MainScreenState) { - var isRailExpanded : Boolean by remember { mutableStateOf(value = false) } - val coroutineScope : CoroutineScope = rememberCoroutineScope() - val context : Context = LocalContext.current - - Scaffold(modifier = Modifier.fillMaxSize() , topBar = { - TopAppBarMain(view = mainScreenState.view , context = context , navigationIcon = if (isRailExpanded) Icons.AutoMirrored.Outlined.MenuOpen else Icons.Default.Menu , onNavigationIconClick = { - isRailExpanded = ! isRailExpanded - }) - }) { paddingValues -> - LeftNavigationRail( - coroutineScope = coroutineScope , - mainScreenState = mainScreenState , - paddingValues = paddingValues , - isRailExpanded = isRailExpanded , - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/MainViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/MainViewModel.kt deleted file mode 100644 index 469f6b7c..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/MainViewModel.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.main - -import android.app.Application -import android.content.Context -import android.os.Build -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.IntentSenderRequest -import androidx.annotation.RequiresApi -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 androidx.lifecycle.viewModelScope -import com.d4rk.android.libs.apptoolkit.data.model.ui.navigation.NavigationDrawerItem -import com.d4rk.android.libs.apptoolkit.notifications.managers.AppUpdateNotificationsManager -import com.d4rk.android.libs.apptoolkit.utils.helpers.IntentsHelper -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.data.model.ui.navigation.BottomNavigationScreen -import com.d4rk.androidtutorials.data.model.ui.screens.UiMainScreen -import com.d4rk.androidtutorials.ui.screens.main.repository.MainRepository -import com.d4rk.androidtutorials.ui.screens.startup.StartupActivity -import com.d4rk.androidtutorials.ui.viewmodel.BaseViewModel -import com.google.android.play.core.appupdate.AppUpdateManager -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch - -class MainViewModel(application : Application) : BaseViewModel(application) { - private val repository = MainRepository( - dataStore = AppCoreManager.dataStore , application = application - ) - private val _uiState : MutableStateFlow = MutableStateFlow(initializeUiState()) - val uiState : StateFlow = _uiState - - fun checkForUpdates(updateResultLauncher : ActivityResultLauncher , appUpdateManager : AppUpdateManager) { - viewModelScope.launch(context = coroutineExceptionHandler) { - repository.checkForUpdates( - appUpdateManager = appUpdateManager , updateResultLauncher = updateResultLauncher - ) - } - } - - private fun initializeUiState() : UiMainScreen { - return UiMainScreen( - navigationDrawerItems = listOf( - NavigationDrawerItem( - title = com.d4rk.android.libs.apptoolkit.R.string.settings , - selectedIcon = Icons.Outlined.Settings , - ) , NavigationDrawerItem( - title = com.d4rk.android.libs.apptoolkit.R.string.help_and_feedback , - selectedIcon = Icons.AutoMirrored.Outlined.HelpOutline , - ) , NavigationDrawerItem( - title = com.d4rk.android.libs.apptoolkit.R.string.updates , - selectedIcon = Icons.AutoMirrored.Outlined.EventNote , - ) , NavigationDrawerItem( - title = com.d4rk.android.libs.apptoolkit.R.string.share , - selectedIcon = Icons.Outlined.Share , - ) - ) , bottomNavigationItems = listOf( - BottomNavigationScreen.Home , BottomNavigationScreen.StudioBot , BottomNavigationScreen.Favorites - ) , currentBottomNavigationScreen = BottomNavigationScreen.Home - ) - } - - @RequiresApi(Build.VERSION_CODES.O) - fun checkAndScheduleUpdateNotifications(appUpdateNotificationsManager : AppUpdateNotificationsManager) { - viewModelScope.launch(context = coroutineExceptionHandler) { - repository.checkAndScheduleUpdateNotificationsRepository(appUpdateNotificationsManager = appUpdateNotificationsManager) - } - } - - fun checkAppUsageNotifications(context : Context) { - viewModelScope.launch(context = coroutineExceptionHandler) { - repository.checkAppUsageNotificationsRepository(context = context) - } - } - - fun checkAndHandleStartup() { - viewModelScope.launch(context = coroutineExceptionHandler) { - repository.checkAndHandleStartupRepository { isFirstTime -> - if (isFirstTime) { - IntentsHelper.openActivity( - context = getApplication() , activityClass = StartupActivity::class.java - ) - } - } - } - } - - fun configureSettings() { - viewModelScope.launch(context = coroutineExceptionHandler) { - repository.setupSettingsRepository() - } - } - - fun updateBottomNavigationScreen(newScreen : BottomNavigationScreen) { - viewModelScope.launch(context = coroutineExceptionHandler) { - _uiState.value = _uiState.value.copy(currentBottomNavigationScreen = newScreen) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/repository/MainRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/repository/MainRepository.kt deleted file mode 100644 index 01ed0a52..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/repository/MainRepository.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.main.repository - -import android.app.Application -import android.content.Context -import android.os.Build -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.IntentSenderRequest -import androidx.annotation.RequiresApi -import com.d4rk.android.libs.apptoolkit.notifications.managers.AppUpdateNotificationsManager -import com.d4rk.androidtutorials.data.datastore.DataStore -import com.google.android.play.core.appupdate.AppUpdateManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withContext - -/** - * Concrete implementation of the main repository for managing application settings and startup state. - * - * @property dataStore The data store used to persist settings and startup information. - * @property application The application context. - */ -class MainRepository(dataStore : DataStore , application : Application) : MainRepositoryImplementation(application = application , dataStore = dataStore) { - - suspend fun checkForUpdates( - updateResultLauncher : ActivityResultLauncher , - appUpdateManager : AppUpdateManager , - ) { - withContext(Dispatchers.IO) { - checkForUpdatesImplementation(updateResultLauncher = updateResultLauncher , appUpdateManager = appUpdateManager) - } - } - - /** - * Checks the application startup state and performs actions based on whether it's the first launch. - * - * This function checks if the app is launched for the first time and invokes the `onSuccess` callback - * with the result on the main thread. - * - * @param onSuccess A callback function that receives a boolean indicating if it's the first launch. - */ - suspend fun checkAndHandleStartupRepository(onSuccess : (Boolean) -> Unit) { - withContext(Dispatchers.IO) { - val isFirstTime : Boolean = checkStartupImplementation() - withContext(Dispatchers.Main) { - onSuccess(isFirstTime) - } - } - } - - @RequiresApi(Build.VERSION_CODES.O) - suspend fun checkAndScheduleUpdateNotificationsRepository(appUpdateNotificationsManager : AppUpdateNotificationsManager) { - withContext(Dispatchers.IO) { - checkAndScheduleUpdateNotificationsImplementation(appUpdateNotificationsManager = appUpdateNotificationsManager) - } - } - - suspend fun checkAppUsageNotificationsRepository(context : Context) { - withContext(Dispatchers.IO) { - checkAppUsageNotificationsManagerImplementation(context = context) - } - } - - /** - * Sets up Firebase Analytics and Crashlytics based on stored settings. - * - * This function retrieves the "usageAndDiagnostics" setting from the data store and configures - * Firebase Analytics and Crashlytics accordingly. - */ - suspend fun setupSettingsRepository() { - withContext(Dispatchers.IO) { - val isEnabled : Boolean = dataStore.usageAndDiagnostics.first() - withContext(Dispatchers.Main) { - setupDiagnosticSettingsImplementation(isEnabled = isEnabled) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/repository/MainRepositoryImplementation.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/repository/MainRepositoryImplementation.kt deleted file mode 100644 index 97be8ba8..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/main/repository/MainRepositoryImplementation.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.main.repository - -import android.app.Activity -import android.app.Application -import android.content.Context -import android.os.Build -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.IntentSenderRequest -import androidx.annotation.RequiresApi -import com.d4rk.android.libs.apptoolkit.notifications.managers.AppUpdateNotificationsManager -import com.d4rk.android.libs.apptoolkit.notifications.managers.AppUsageNotificationsManager -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.data.datastore.DataStore -import com.google.android.play.core.appupdate.AppUpdateInfo -import com.google.android.play.core.appupdate.AppUpdateManager -import com.google.android.play.core.appupdate.AppUpdateOptions -import com.google.android.play.core.install.model.ActivityResult -import com.google.android.play.core.install.model.AppUpdateType -import com.google.android.play.core.install.model.UpdateAvailability -import com.google.firebase.analytics.FirebaseAnalytics -import com.google.firebase.crashlytics.FirebaseCrashlytics -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.tasks.await - -/** - * Abstract base class for repository implementations related to main application functionality. - * - * This class provides common functionality for managing application startup state. - * - * @property application The application context. - */ -abstract class MainRepositoryImplementation(val application : Application , val dataStore : DataStore) { - - /** - * Checks if the application is being launched for the first time. - * - * This function retrieves the startup state from a data store and updates it if it's the first launch. - * - * @return `true` if it's the first launch, `false` otherwise. - */ - suspend fun checkStartupImplementation() : Boolean { - val isFirstTime : Boolean = dataStore.startup.first() - if (isFirstTime) { - dataStore.saveStartup(isFirstTime = false) - } - return isFirstTime - } - - /** - * Configures Firebase Analytics and Crashlytics data collection. - * - * Enables or disables data collection for both Firebase Analytics and Crashlytics - * based on the provided flag. - * - * @param isEnabled `true` to enable data collection, `false` to disable. - */ - fun setupDiagnosticSettingsImplementation(isEnabled : Boolean) { - FirebaseAnalytics.getInstance(application).setAnalyticsCollectionEnabled(isEnabled) - FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = isEnabled - } - - suspend fun checkForUpdatesImplementation( - appUpdateManager : AppUpdateManager , updateResultLauncher : ActivityResultLauncher - ) : Int { - return runCatching { - val appUpdateInfo : AppUpdateInfo = appUpdateManager.appUpdateInfo.await() - if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { - val stalenessDays = appUpdateInfo.clientVersionStalenessDays() ?: 0 - val updateType = if (stalenessDays > 90) { - AppUpdateType.IMMEDIATE - } - else { - AppUpdateType.FLEXIBLE - } - - val appUpdateOptions : AppUpdateOptions = AppUpdateOptions.newBuilder(updateType).build() - - val didStart : Boolean = appUpdateManager.startUpdateFlowForResult( - appUpdateInfo , updateResultLauncher , appUpdateOptions - ) - - if (didStart) return@runCatching Activity.RESULT_OK - } - Activity.RESULT_CANCELED - }.getOrElse { - ActivityResult.RESULT_IN_APP_UPDATE_FAILED - } - } - - - @RequiresApi(Build.VERSION_CODES.O) - fun checkAndScheduleUpdateNotificationsImplementation(appUpdateNotificationsManager : AppUpdateNotificationsManager) { - appUpdateNotificationsManager.checkAndSendUpdateNotification() - } - - fun checkAppUsageNotificationsManagerImplementation(context : Context) { - val appUsageNotificationsManager = AppUsageNotificationsManager(context = context) - appUsageNotificationsManager.scheduleAppUsageCheck(notificationSummary = R.string.summary_notification_last_time_used) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/SettingsActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/SettingsActivity.kt deleted file mode 100644 index 1b53efe6..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/SettingsActivity.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings - -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -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 SettingsActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState : Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize() , color = MaterialTheme.colorScheme.background - ) { - SettingsComposable( - activity = this@SettingsActivity - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/SettingsComposable.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/SettingsComposable.kt deleted file mode 100644 index c8b4b404..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/SettingsComposable.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings - -import android.content.Context -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.core.TweenSpec -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.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.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ContactSupport -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.SafetyCheck -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.bounceClick -import com.d4rk.android.libs.apptoolkit.ui.components.preferences.SettingsPreferenceItem -import com.d4rk.android.libs.apptoolkit.ui.components.spacers.ButtonIconSpacer -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.about.AboutSettingsList -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.advanced.AdvancedSettingsList -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.display.DisplaySettingsList -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.privacy.PrivacySettingsList -import com.d4rk.android.libs.apptoolkit.utils.helpers.IntentsHelper -import com.d4rk.android.libs.apptoolkit.utils.helpers.ScreenHelper -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.ui.components.navigation.TopAppBarScaffoldWithBackButton -import com.d4rk.androidtutorials.ui.screens.help.HelpActivity -import com.d4rk.androidtutorials.ui.screens.settings.general.GeneralSettingsActivity -import com.d4rk.androidtutorials.ui.screens.settings.general.SettingsContent -import com.d4rk.androidtutorials.utils.providers.AppAboutSettingsProvider -import com.d4rk.androidtutorials.utils.providers.AppAdvancedSettingsProvider -import com.d4rk.androidtutorials.utils.providers.AppDisplaySettingsProvider -import com.d4rk.androidtutorials.utils.providers.AppPrivacySettingsProvider - -@Composable -fun SettingsComposable(activity : SettingsActivity) { - val context : Context = LocalContext.current - - TopAppBarScaffoldWithBackButton(title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.settings) , onBackClicked = { activity.finish() }) { paddingValues -> - val isTabletOrLandscape : Boolean = ScreenHelper.isLandscapeOrTablet(context = context) - if (isTabletOrLandscape) { - TabletSettingsScreen(paddingValues = paddingValues , context = context) - } - else { - PhoneSettingsScreen(paddingValues = paddingValues , context = context) - } - } -} - -@Composable -fun PhoneSettingsScreen(paddingValues : PaddingValues , context : Context) { - SettingsList(paddingValues = paddingValues , onPreferenceClick = { preference -> - when (preference) { - "notifications" -> IntentsHelper.openAppNotificationSettings(context) - "display" -> GeneralSettingsActivity.start( - context , title = context.getString(com.d4rk.android.libs.apptoolkit.R.string.display) , content = SettingsContent.DISPLAY - ) - - "privacy" -> GeneralSettingsActivity.start( - context , title = context.getString(com.d4rk.android.libs.apptoolkit.R.string.security_and_privacy) , content = SettingsContent.PRIVACY - ) - - "advanced" -> GeneralSettingsActivity.start( - context , title = context.getString(com.d4rk.android.libs.apptoolkit.R.string.advanced) , content = SettingsContent.ADVANCED - ) - - "about" -> GeneralSettingsActivity.start( - context , title = context.getString(com.d4rk.android.libs.apptoolkit.R.string.about) , content = SettingsContent.ABOUT - ) - } - }) -} - -@Composable -fun TabletSettingsScreen(paddingValues : PaddingValues , context : Context) { - var selectedPreference : String? by remember { mutableStateOf(null) } - - Row(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .weight(weight = 1f) - .fillMaxHeight() - ) { - SettingsList(paddingValues = paddingValues , onPreferenceClick = { preference -> - selectedPreference = preference - }) - } - - Box( - modifier = Modifier - .weight(weight = 2f) - .fillMaxHeight() - ) { - AnimatedContent(targetState = selectedPreference , transitionSpec = { - if (targetState != initialState) { - val animationSpec : TweenSpec = tween(durationMillis = 300) - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Left , animationSpec = animationSpec - ) togetherWith slideOutOfContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Left , animationSpec = animationSpec - ) - } - else { - val fadeSpec : TweenSpec = tween(durationMillis = 300) - fadeIn(animationSpec = fadeSpec) togetherWith fadeOut(animationSpec = fadeSpec) - } - }) { preference -> - preference?.let { - SettingsDetail( - preference = it , context = context , paddingValues = paddingValues - ) - } ?: SettingsDetailPlaceholder(paddingValues = paddingValues) - } - } - } -} - -@Composable -fun SettingsDetailPlaceholder(paddingValues : PaddingValues) { - val context : Context = LocalContext.current - - Box(modifier = Modifier.padding(paddingValues = paddingValues)) { - LazyColumn( - modifier = Modifier.fillMaxHeight() - ) { - item { - Card( - modifier = Modifier - .padding(top = 16.dp, end = 16.dp) - .fillMaxSize() - .wrapContentHeight() , - shape = RoundedCornerShape(size = 28.dp) , - ) { - Column( - modifier = Modifier.padding(all = 24.dp) , horizontalAlignment = Alignment.CenterHorizontally , verticalArrangement = Arrangement.Center - ) { - AsyncImage( - model = R.drawable.il_settings , contentDescription = null , modifier = Modifier - .size(size = 258.dp) - .fillMaxWidth() - ) - Spacer(modifier = Modifier.height(height = 16.dp)) - Text( - modifier = Modifier.fillMaxWidth() , text = stringResource(id = R.string.app_name) , style = MaterialTheme.typography.titleMedium , color = MaterialTheme.colorScheme.onSurface , textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(height = 8.dp)) - Text( - modifier = Modifier.fillMaxWidth() , text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.settings_placeholder_description) , style = MaterialTheme.typography.bodyMedium , color = MaterialTheme.colorScheme.onSurfaceVariant , textAlign = TextAlign.Center - ) - } - - OutlinedButton(modifier = Modifier - .padding(all = 24.dp) - .align(Alignment.Start) - .bounceClick() , onClick = { - IntentsHelper.openActivity( - context = context , activityClass = HelpActivity::class.java - ) - }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ContactSupport , contentDescription = null - ) - ButtonIconSpacer() - Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.get_help)) - } - } - } - } - } -} - -@Composable -fun SettingsDetail(preference : String , context : Context , paddingValues : PaddingValues) { - when (preference) { - "notifications" -> IntentsHelper.openAppNotificationSettings(context) - "display" -> DisplaySettingsList( - paddingValues = paddingValues , provider = AppDisplaySettingsProvider() - ) - - "privacy" -> PrivacySettingsList( - paddingValues = paddingValues , provider = AppPrivacySettingsProvider() - ) - - "advanced" -> AdvancedSettingsList( - paddingValues = paddingValues , provider = AppAdvancedSettingsProvider() - ) - - "about" -> AboutSettingsList( - paddingValues = paddingValues , provider = AppAboutSettingsProvider() - ) - - else -> Text( - text = "Unknown preference" , style = MaterialTheme.typography.bodyLarge , color = MaterialTheme.colorScheme.error - ) - } -} - -@Composable -fun SettingsList( - paddingValues : PaddingValues , onPreferenceClick : (String) -> Unit -) { - LazyColumn( - modifier = Modifier - .fillMaxHeight() - .padding(paddingValues) , - ) { - item { - Spacer(modifier = Modifier.height(height = 24.dp)) - Column(modifier = Modifier.run { - padding(start = 16.dp , end = 16.dp).clip(RoundedCornerShape(24.dp)) - }) { - SettingsPreferenceItem(Icons.Outlined.Notifications , title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.notifications) , summary = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.summary_preference_settings_notifications) , onClick = { onPreferenceClick("notifications") }) - Spacer(modifier = Modifier.height(2.dp)) - SettingsPreferenceItem(Icons.Outlined.Palette , title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.display) , summary = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.summary_preference_settings_display) , onClick = { onPreferenceClick("display") }) - } - Spacer(modifier = Modifier.height(24.dp)) - } - item { - Column( - modifier = Modifier - .padding(start = 16.dp , end = 16.dp) - .clip(RoundedCornerShape(24.dp)) - ) { - SettingsPreferenceItem(Icons.Outlined.SafetyCheck , title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.security_and_privacy) , summary = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.summary_preference_settings_privacy_and_security) , onClick = { onPreferenceClick("privacy") }) - Spacer(modifier = Modifier.height(2.dp)) - SettingsPreferenceItem(Icons.Outlined.Build , title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.advanced) , summary = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.summary_preference_settings_advanced) , onClick = { onPreferenceClick("advanced") }) - Spacer(modifier = Modifier.height(2.dp)) - SettingsPreferenceItem(Icons.Outlined.Info , title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.about) , summary = stringResource(id = R.string.summary_preference_settings_about) , onClick = { onPreferenceClick("about") }) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/display/theme/style/Color.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/display/theme/style/Color.kt deleted file mode 100644 index a6adeedc..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/display/theme/style/Color.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.display.theme.style - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -val primaryLight = Color(color = 0xFF006D3B) -val onPrimaryLight = Color(color = 0xFFFFFFFF) -val primaryContainerLight = Color(color = 0xFF49E58C) -val onPrimaryContainerLight = Color(color = 0xFF004221) -val secondaryLight = Color(color = 0xFF326945) -val onSecondaryLight = Color(color = 0xFFFFFFFF) -val secondaryContainerLight = Color(color = 0xFFB7F3C5) -val onSecondaryContainerLight = Color(color = 0xFF1B5332) -val tertiaryLight = Color(color = 0xFF00658E) -val onTertiaryLight = Color(color = 0xFFFFFFFF) -val tertiaryContainerLight = Color(color = 0xFF8DD2FF) -val onTertiaryContainerLight = Color(color = 0xFF003D57) -val errorLight = Color(color = 0xFFBA1A1A) -val onErrorLight = Color(color = 0xFFFFFFFF) -val errorContainerLight = Color(color = 0xFFFFDAD6) -val onErrorContainerLight = Color(color = 0xFF410002) -val backgroundLight = Color(color = 0xFFF3FCF1) -val onBackgroundLight = Color(color = 0xFF161D17) -val surfaceLight = Color(color = 0xFFF3FCF1) -val onSurfaceLight = Color(color = 0xFF161D17) -val surfaceVariantLight = Color(color = 0xFFD7E7D7) -val onSurfaceVariantLight = Color(color = 0xFF3C4A3F) -val outlineLight = Color(color = 0xFF6C7B6E) -val outlineVariantLight = Color(color = 0xFFBBCBBC) -val scrimLight = Color(color = 0xFF000000) -val inverseSurfaceLight = Color(color = 0xFF2A322B) -val inverseOnSurfaceLight = Color(color = 0xFFEBF3E9) -val inversePrimaryLight = Color(color = 0xFF43E188) -val surfaceDimLight = Color(color = 0xFFD4DCD2) -val surfaceBrightLight = Color(color = 0xFFF3FCF1) -val surfaceContainerLowestLight = Color(color = 0xFFFFFFFF) -val surfaceContainerLowLight = Color(color = 0xFFEDF6EB) -val surfaceContainerLight = Color(color = 0xFFE8F0E6) -val surfaceContainerHighLight = Color(color = 0xFFE2EBE0) -val surfaceContainerHighestLight = Color(color = 0xFFDCE5DB) - -val primaryDark = Color(color = 0xFF79FFAA) -val onPrimaryDark = Color(color = 0xFF00391C) -val primaryContainerDark = Color(color = 0xFF33D57E) -val onPrimaryContainerDark = Color(color = 0xFF00361A) -val secondaryDark = Color(color = 0xFF99D4A8) -val onSecondaryDark = Color(color = 0xFF00391C) -val secondaryContainerDark = Color(color = 0xFF0A4626) -val onSecondaryContainerDark = Color(color = 0xFFA3DEB1) -val tertiaryDark = Color(color = 0xFFCEEAFF) -val onTertiaryDark = Color(color = 0xFF00344C) -val tertiaryContainerDark = Color(color = 0xFF70C5F9) -val onTertiaryContainerDark = Color(color = 0xFF003249) -val errorDark = Color(color = 0xFFFFB4AB) -val onErrorDark = Color(color = 0xFF690005) -val errorContainerDark = Color(color = 0xFF93000A) -val onErrorContainerDark = Color(color = 0xFFFFDAD6) -val backgroundDark = Color(color = 0xFF0E150F) -val onBackgroundDark = Color(color = 0xFFDCE5DB) -val surfaceDark = Color(color = 0xFF0E150F) -val onSurfaceDark = Color(color = 0xFFDCE5DB) -val surfaceVariantDark = Color(color = 0xFF3C4A3F) -val onSurfaceVariantDark = Color(color = 0xFFBBCBBC) -val outlineDark = Color(color = 0xFF869587) -val outlineVariantDark = Color(color = 0xFF3C4A3F) -val scrimDark = Color(color = 0xFF000000) -val inverseSurfaceDark = Color(color = 0xFFDCE5DB) -val inverseOnSurfaceDark = Color(color = 0xFF2A322B) -val inversePrimaryDark = Color(color = 0xFF006D3B) -val surfaceDimDark = Color(color = 0xFF0E150F) -val surfaceBrightDark = Color(color = 0xFF333B34) -val surfaceContainerLowestDark = Color(color = 0xFF08100A) -val surfaceContainerLowDark = Color(color = 0xFF161D17) -val surfaceContainerDark = Color(color = 0xFF1A211B) -val surfaceContainerHighDark = Color(color = 0xFF242C25) -val surfaceContainerHighestDark = Color(color = 0xFF2F3730) - -object Colors { - @Composable - fun primaryText() = MaterialTheme.colorScheme.onBackground - - @Composable - fun secondaryText() = MaterialTheme.colorScheme.onSurface -} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/display/theme/style/TextStyles.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/display/theme/style/TextStyles.kt deleted file mode 100644 index 188e2a08..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/display/theme/style/TextStyles.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.display.theme.style - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable - -object TextStyles { - @Composable - fun header() = MaterialTheme.typography.headlineMedium - - @Composable - fun body() = MaterialTheme.typography.bodyMedium - - @Composable - fun label() = MaterialTheme.typography.labelMedium -} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/display/theme/style/Theme.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/display/theme/style/Theme.kt deleted file mode 100644 index b483c4db..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/display/theme/style/Theme.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.display.theme.style - -import android.app.Activity -import android.content.Context -import android.os.Build -import android.view.View -import android.view.Window -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.core.view.WindowCompat -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.data.datastore.DataStore - -private val lightScheme = lightColorScheme( - primary = primaryLight , - onPrimary = onPrimaryLight , - primaryContainer = primaryContainerLight , - onPrimaryContainer = onPrimaryContainerLight , - secondary = secondaryLight , - onSecondary = onSecondaryLight , - secondaryContainer = secondaryContainerLight , - onSecondaryContainer = onSecondaryContainerLight , - tertiary = tertiaryLight , - onTertiary = onTertiaryLight , - tertiaryContainer = tertiaryContainerLight , - onTertiaryContainer = onTertiaryContainerLight , - error = errorLight , - onError = onErrorLight , - errorContainer = errorContainerLight , - onErrorContainer = onErrorContainerLight , - background = backgroundLight , - onBackground = onBackgroundLight , - surface = surfaceLight , - onSurface = onSurfaceLight , - surfaceVariant = surfaceVariantLight , - onSurfaceVariant = onSurfaceVariantLight , - outline = outlineLight , - outlineVariant = outlineVariantLight , - scrim = scrimLight , - inverseSurface = inverseSurfaceLight , - inverseOnSurface = inverseOnSurfaceLight , - inversePrimary = inversePrimaryLight , - surfaceDim = surfaceDimLight , - surfaceBright = surfaceBrightLight , - surfaceContainerLowest = surfaceContainerLowestLight , - surfaceContainerLow = surfaceContainerLowLight , - surfaceContainer = surfaceContainerLight , - surfaceContainerHigh = surfaceContainerHighLight , - surfaceContainerHighest = surfaceContainerHighestLight , -) - -private val darkScheme = darkColorScheme( - primary = primaryDark , - onPrimary = onPrimaryDark , - primaryContainer = primaryContainerDark , - onPrimaryContainer = onPrimaryContainerDark , - secondary = secondaryDark , - onSecondary = onSecondaryDark , - secondaryContainer = secondaryContainerDark , - onSecondaryContainer = onSecondaryContainerDark , - tertiary = tertiaryDark , - onTertiary = onTertiaryDark , - tertiaryContainer = tertiaryContainerDark , - onTertiaryContainer = onTertiaryContainerDark , - error = errorDark , - onError = onErrorDark , - errorContainer = errorContainerDark , - onErrorContainer = onErrorContainerDark , - background = backgroundDark , - onBackground = onBackgroundDark , - surface = surfaceDark , - onSurface = onSurfaceDark , - surfaceVariant = surfaceVariantDark , - onSurfaceVariant = onSurfaceVariantDark , - outline = outlineDark , - outlineVariant = outlineVariantDark , - scrim = scrimDark , - inverseSurface = inverseSurfaceDark , - inverseOnSurface = inverseOnSurfaceDark , - inversePrimary = inversePrimaryDark , - surfaceDim = surfaceDimDark , - surfaceBright = surfaceBrightDark , - surfaceContainerLowest = surfaceContainerLowestDark , - surfaceContainerLow = surfaceContainerLowDark , - surfaceContainer = surfaceContainerDark , - surfaceContainerHigh = surfaceContainerHighDark , - surfaceContainerHighest = surfaceContainerHighestDark , -) - -/** - * Determines and returns the appropriate color scheme based on user preferences and system capabilities. - * - * This function considers the following factors: - * - **Dark Theme:** If enabled by the user (isDarkTheme). - * - **AMOLED Mode:** If enabled by the user (isAmoledMode). - * - **Dynamic Colors:** If supported by the device (Build Version) and enabled by the user (isDynamicColors). - * - **System Context:** Used to access dynamic color resources if available (context). - * - * @param isDarkTheme Whether the user prefers a dark theme. - * @param isAmoledMode Whether the user prefers AMOLED-optimized colors. - * @param isDynamicColors Whether the user has enabled dynamic colors (if supported). - * @param context The current application context, used for accessing system resources. - * - * @return The most suitable color scheme based on the provided parameters. - */ -private fun getColorScheme( - isDarkTheme : Boolean , isAmoledMode : Boolean , isDynamicColors : Boolean , context : Context -) : ColorScheme { - val dynamicDark : ColorScheme = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicDarkColorScheme(context) else darkScheme - val dynamicLight : ColorScheme = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicLightColorScheme(context) else lightScheme - - return when { - isAmoledMode && isDarkTheme && isDynamicColors -> dynamicDark.copy( - surface = Color.Black , - background = Color.Black , - ) - - isAmoledMode && isDarkTheme -> darkScheme.copy( - surface = Color.Black , - background = Color.Black , - ) - - isDynamicColors -> if (isDarkTheme) dynamicDark else dynamicLight - else -> if (isDarkTheme) darkScheme else lightScheme - } -} - -@Composable -fun AppTheme( - content : @Composable () -> Unit -) { - val context : Context = LocalContext.current - val dataStore : DataStore = AppCoreManager.dataStore - val themeMode : String = dataStore.themeMode.collectAsState(initial = "follow_system").value - val isDynamicColors : Boolean = dataStore.dynamicColors.collectAsState(initial = true).value - val isAmoledMode : Boolean = dataStore.amoledMode.collectAsState(initial = false).value - - val isSystemDarkTheme : Boolean = isSystemInDarkTheme() - val isDarkTheme : Boolean = when (themeMode) { - stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.dark_mode) -> true - stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.light_mode) -> false - else -> isSystemDarkTheme - } - - val colorScheme : ColorScheme = - getColorScheme(isDarkTheme , isAmoledMode , isDynamicColors , context) - - val view : View = LocalView.current - if (! view.isInEditMode) { - SideEffect { - val window : Window = (view.context as Activity).window - window.statusBarColor = Color.Transparent.toArgb() - WindowCompat.getInsetsController(window , view).isAppearanceLightStatusBars = - ! isDarkTheme - } - } - - MaterialTheme( - colorScheme = colorScheme , content = content - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/general/GeneralSettingsActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/general/GeneralSettingsActivity.kt deleted file mode 100644 index 92c23ee0..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/general/GeneralSettingsActivity.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.general - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -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.R -import com.d4rk.androidtutorials.ui.screens.settings.display.theme.style.AppTheme - -class GeneralSettingsActivity : AppCompatActivity() { - - companion object { - private const val EXTRA_TITLE = "extra_title" - private const val EXTRA_CONTENT = "extra_content" - - fun start(context: Context , title: String , content: SettingsContent) { - val intent = Intent(context, GeneralSettingsActivity::class.java).apply { - putExtra(EXTRA_TITLE, title) - putExtra(EXTRA_CONTENT, content.name) - } - context.startActivity(intent) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - - val title = intent.getStringExtra(EXTRA_TITLE) ?: getString(com.d4rk.android.libs.apptoolkit.R.string.settings) - val content = intent.getStringExtra(EXTRA_CONTENT)?.let { SettingsContent.valueOf(it) } - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize() , - color = MaterialTheme.colorScheme.background - ) { - GeneralSettingsScreen( - title = title, - content = content, - onBackClicked = { finish() } - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/general/GeneralSettingsScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/general/GeneralSettingsScreen.kt deleted file mode 100644 index 615aaf62..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/general/GeneralSettingsScreen.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.general - -import androidx.compose.runtime.Composable -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.about.AboutSettingsList -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.advanced.AdvancedSettingsList -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.display.DisplaySettingsList -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.display.theme.ThemeSettingsList -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.privacy.PrivacySettingsList -import com.d4rk.android.libs.apptoolkit.ui.screens.settings.privacy.usage.UsageAndDiagnosticsList -import com.d4rk.androidtutorials.ui.components.navigation.TopAppBarScaffoldWithBackButton -import com.d4rk.androidtutorials.utils.providers.AppAboutSettingsProvider -import com.d4rk.androidtutorials.utils.providers.AppAdvancedSettingsProvider -import com.d4rk.androidtutorials.utils.providers.AppDisplaySettingsProvider -import com.d4rk.androidtutorials.utils.providers.AppPrivacySettingsProvider -import com.d4rk.androidtutorials.utils.providers.AppUsageAndDiagnosticsProvider - -@Composable -fun GeneralSettingsScreen( - title : String , content : SettingsContent? , onBackClicked : () -> Unit -) { - TopAppBarScaffoldWithBackButton(title = title , onBackClicked = onBackClicked) { paddingValues -> - when (content) { - SettingsContent.ABOUT -> AboutSettingsList( - paddingValues = paddingValues , provider = AppAboutSettingsProvider() - ) - - SettingsContent.ADVANCED -> AdvancedSettingsList( - paddingValues = paddingValues , provider = AppAdvancedSettingsProvider() - ) - - SettingsContent.DISPLAY -> DisplaySettingsList( - paddingValues = paddingValues , provider = AppDisplaySettingsProvider() - ) - - SettingsContent.PRIVACY -> PrivacySettingsList( - paddingValues = paddingValues , provider = AppPrivacySettingsProvider() - ) - - SettingsContent.THEME -> ThemeSettingsList( - paddingValues = paddingValues - ) - - SettingsContent.USAGE_AND_DIAGNOSTICS -> UsageAndDiagnosticsList( - paddingValues = paddingValues, - provider = AppUsageAndDiagnosticsProvider() - ) - - else -> {} - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/general/SettingsContent.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/general/SettingsContent.kt deleted file mode 100644 index 8e71372a..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/general/SettingsContent.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.general - -enum class SettingsContent { - ABOUT, - ADVANCED, - DISPLAY, - PRIVACY, - THEME, - USAGE_AND_DIAGNOSTICS, -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/ads/AdsSettingsActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/ads/AdsSettingsActivity.kt deleted file mode 100644 index 1d878f35..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/ads/AdsSettingsActivity.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.privacy.ads - -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -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 -import com.google.android.ump.ConsentForm -import com.google.android.ump.ConsentInformation -import com.google.android.ump.ConsentRequestParameters -import com.google.android.ump.UserMessagingPlatform - -class AdsSettingsActivity : AppCompatActivity() { - private lateinit var consentInformation : ConsentInformation - private val isPrivacyOptionsRequired : Boolean - get() = consentInformation.privacyOptionsRequirementStatus == ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED - private lateinit var consentForm : ConsentForm - - override fun onCreate(savedInstanceState : Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - consentInformation = UserMessagingPlatform.getConsentInformation(this@AdsSettingsActivity) - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize() , color = MaterialTheme.colorScheme.background - ) { - AdsSettingsScreen(activity = this@AdsSettingsActivity) - } - } - } - } - - fun openForm() { - UserMessagingPlatform.loadConsentForm(this , { consentForm -> - this.consentForm = consentForm - if (consentInformation.consentStatus == ConsentInformation.ConsentStatus.REQUIRED || consentInformation.consentStatus == ConsentInformation.ConsentStatus.OBTAINED) { - consentForm.show(this) { - loadForm() - } - } - } , {}) - } - - private fun loadForm() { - val params : ConsentRequestParameters = - ConsentRequestParameters.Builder().setTagForUnderAgeOfConsent(false).build() - consentInformation.requestConsentInfoUpdate(this@AdsSettingsActivity , params , { - UserMessagingPlatform.loadAndShowConsentFormIfRequired( - this@AdsSettingsActivity - ) { - if (isPrivacyOptionsRequired) { - invalidateOptionsMenu() - } - } - } , {}) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/ads/AdsSettingsScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/ads/AdsSettingsScreen.kt deleted file mode 100644 index d346da04..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/ads/AdsSettingsScreen.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.privacy.ads - -import android.content.Context -import androidx.compose.foundation.clickable -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.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.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.bounceClick -import com.d4rk.android.libs.apptoolkit.ui.components.preferences.PreferenceItem -import com.d4rk.android.libs.apptoolkit.ui.components.preferences.SwitchCardComposable -import com.d4rk.android.libs.apptoolkit.utils.helpers.IntentsHelper -import com.d4rk.androidtutorials.BuildConfig -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.data.datastore.DataStore -import com.d4rk.androidtutorials.ui.components.navigation.TopAppBarScaffoldWithBackButton -import com.google.android.ump.ConsentInformation -import com.google.android.ump.ConsentRequestParameters -import com.google.android.ump.UserMessagingPlatform -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -@Composable -fun AdsSettingsScreen(activity : AdsSettingsActivity) { - val context : Context = LocalContext.current - val dataStore : DataStore = AppCoreManager.dataStore - val switchState : State = dataStore.ads.collectAsState(initial = ! BuildConfig.DEBUG) - val coroutineScope : CoroutineScope = rememberCoroutineScope() - - TopAppBarScaffoldWithBackButton(title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.ads) , - onBackClicked = { activity.finish() }) { paddingValues -> - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) , - ) { - item(key = "display_ads") { - SwitchCardComposable( - title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.display_ads) , - switchState = switchState - ) { isChecked -> - coroutineScope.launch { - dataStore.saveAds(isChecked = isChecked) - } - } - } - item { - Box(modifier = Modifier.padding(horizontal = 8.dp)) { - PreferenceItem(title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.personalized_ads) , - enabled = switchState.value , - summary = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.summary_ads_personalized_ads) , - onClick = { - val params : ConsentRequestParameters = - ConsentRequestParameters.Builder() - .setTagForUnderAgeOfConsent(false) - .build() - val consentInformation : ConsentInformation = - UserMessagingPlatform.getConsentInformation( - context - ) - consentInformation.requestConsentInfoUpdate(activity , - params , - { - activity.openForm() - } , - {}) - }) - } - } - - item { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(all = 24.dp) - ) { - Icon(imageVector = Icons.Outlined.Info , contentDescription = null) - Spacer(modifier = Modifier.height(height = 24.dp)) - Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.summary_ads)) - - val annotatedString : AnnotatedString = buildAnnotatedString { - val startIndex : Int = length - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary , - textDecoration = TextDecoration.Underline - ) - ) { - append(stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.learn_more)) - } - val endIndex : Int = length - - addStringAnnotation( - tag = "URL" , - annotation = "https://sites.google.com/view/d4rk7355608/more/apps/ads-help-center" , - start = startIndex , - end = endIndex - ) - } - - Text(text = annotatedString , modifier = Modifier - .bounceClick() - .clickable { - annotatedString - .getStringAnnotations( - tag = "URL" , - start = 0 , - end = annotatedString.length - ) - .firstOrNull() - ?.let { annotation -> - IntentsHelper.openUrl( - context = context , url = annotation.item - ) - } - }) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/permissions/PermissionsSettingsActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/permissions/PermissionsSettingsActivity.kt deleted file mode 100644 index 40b73e60..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/permissions/PermissionsSettingsActivity.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.privacy.permissions - -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -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 PermissionsSettingsActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState : Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize() , color = MaterialTheme.colorScheme.background - ) { - PermissionsSettingsScreen(activity = this@PermissionsSettingsActivity) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/permissions/PermissionsSettingsList.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/permissions/PermissionsSettingsList.kt deleted file mode 100644 index ef0c7173..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/settings/privacy/permissions/PermissionsSettingsList.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.settings.privacy.permissions - -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.d4rk.android.libs.apptoolkit.ui.components.preferences.PreferenceCategoryItem -import com.d4rk.android.libs.apptoolkit.ui.components.preferences.PreferenceItem -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.ui.components.navigation.TopAppBarScaffoldWithBackButton - -@Composable -fun PermissionsSettingsScreen(activity : AppCompatActivity) { - TopAppBarScaffoldWithBackButton(title = activity.getString(com.d4rk.android.libs.apptoolkit.R.string.permissions) , onBackClicked = { - activity.finish() - }) { paddingValues -> - PermissionsSettingsList(paddingValues = paddingValues) - } -} - -@Composable -fun PermissionsSettingsList(paddingValues : PaddingValues) { - LazyColumn( - modifier = Modifier - .fillMaxHeight() - .padding(paddingValues) , - ) { - item { - PreferenceCategoryItem(title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.normal)) - PreferenceItem( - title = stringResource(id = R.string.ad_id) , - summary = stringResource(id = R.string.summary_preference_permissions_ad_id) , - ) - PreferenceItem( - title = stringResource(id = R.string.internet) , - summary = stringResource(id = R.string.summary_preference_permissions_internet) , - ) - PreferenceItem( - title = stringResource(id = R.string.post_notifications) , - summary = stringResource(id = R.string.summary_preference_permissions_post_notifications) , - ) - } - item { - PreferenceCategoryItem(title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.runtime)) - PreferenceItem( - title = stringResource(id = R.string.access_network_state) , - summary = stringResource(id = R.string.summary_preference_permissions_access_network_state) , - ) - PreferenceItem( - title = stringResource(id = R.string.access_notification_policy) , - summary = stringResource(id = R.string.summary_preference_permissions_access_notification_policy) , - ) - PreferenceItem( - title = stringResource(id = R.string.billing) , - summary = stringResource(id = R.string.summary_preference_permissions_billing) , - ) - PreferenceItem( - title = stringResource(id = R.string.check_license) , - summary = stringResource(id = R.string.summary_preference_permissions_check_license) , - ) - PreferenceItem( - title = stringResource(id = R.string.foreground_service) , - summary = stringResource(id = R.string.summary_preference_permissions_foreground_service) , - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/startup/StartupActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/startup/StartupActivity.kt deleted file mode 100644 index cb710bd7..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/startup/StartupActivity.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.startup - -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -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 -import com.google.android.ump.ConsentForm -import com.google.android.ump.ConsentInformation -import com.google.android.ump.ConsentRequestParameters -import com.google.android.ump.UserMessagingPlatform -import kotlinx.coroutines.flow.MutableStateFlow - -class StartupActivity : AppCompatActivity() { - private lateinit var consentInformation : ConsentInformation - private lateinit var consentForm : ConsentForm - val consentFormShown = MutableStateFlow(value = false) - override fun onCreate(savedInstanceState : Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize() , color = MaterialTheme.colorScheme.background - ) { - StartupComposable(activity = this@StartupActivity) - } - } - } - val params : ConsentRequestParameters = - ConsentRequestParameters.Builder().setTagForUnderAgeOfConsent(false).build() - consentInformation = UserMessagingPlatform.getConsentInformation(this) - consentInformation.requestConsentInfoUpdate(this , params , { - if (consentInformation.isConsentFormAvailable) { - loadForm() - } - } , {}) - } - - /** - * Loads the consent form for user messaging platform (UMP) based on consent status. - * - * This function initiates the loading of the consent form using UserMessagingPlatform (UMP) API. - * Upon successful loading of the consent form, it assigns the form to a local variable `consentForm`. - * If user consent is required (`ConsentStatus.REQUIRED`), the form is displayed to the user. - * If the consent status is not required or an error occurs during loading, the function handles this gracefully. - * - * @see com.google.android.gms.ads.UserMessagingPlatform - * @see com.google.ads.consent.ConsentInformation - */ - private fun loadForm() { - UserMessagingPlatform.loadConsentForm(this@StartupActivity , { consentForm -> - this.consentForm = consentForm - if (consentInformation.consentStatus == ConsentInformation.ConsentStatus.REQUIRED) { - consentFormShown.value = true - consentForm.show(this) { - loadForm() - } - } - } , {}) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/startup/StartupComposable.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/startup/StartupComposable.kt deleted file mode 100644 index ee5101ec..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/startup/StartupComposable.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.startup - -import android.app.Activity -import android.content.Context -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.CheckCircle -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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.graphics.Color.Companion.Gray -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.d4rk.android.libs.apptoolkit.ui.components.modifiers.bounceClick -import com.d4rk.android.libs.apptoolkit.utils.helpers.IntentsHelper -import com.d4rk.android.libs.apptoolkit.utils.helpers.PermissionsHelper -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.ui.components.navigation.TopAppBarScaffold -import com.d4rk.androidtutorials.ui.screens.main.MainActivity - -@Composable -fun StartupComposable(activity : StartupActivity) { - val context : Context = LocalContext.current - val fabEnabled : MutableState = remember { mutableStateOf(value = false) } - LaunchedEffect(context) { - if (! PermissionsHelper.hasNotificationPermission(context)) { - PermissionsHelper.requestNotificationPermission(context as Activity) - } - activity.consentFormShown.collect { shown -> - fabEnabled.value = shown - } - } - - TopAppBarScaffold( - title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.welcome) , - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(24.dp) - .safeDrawingPadding() - ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) , - ) { - item { - AsyncImage( - model = R.drawable.il_startup , - contentDescription = null , - ) - Icon( - Icons.Outlined.Info , contentDescription = null - ) - } - item { - Text( - text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.summary_browse_terms_of_service_and_privacy_policy) , - modifier = Modifier.padding(top = 24.dp , bottom = 24.dp) - ) - val annotatedString : AnnotatedString = buildAnnotatedString { - val startIndex : Int = length - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary , - textDecoration = TextDecoration.Underline - ) - ) { - append(stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.learn_more)) - } - val endIndex : Int = length - - addStringAnnotation( - tag = "URL" , - annotation = "https://sites.google.com/view/d4rk7355608/more/apps/privacy-policy" , - start = startIndex , - end = endIndex - ) - } - Text(text = annotatedString , modifier = Modifier - .bounceClick() - .clickable { - annotatedString - .getStringAnnotations( - tag = "URL" , start = 0 , end = annotatedString.length - ) - .firstOrNull() - ?.let { annotation -> - IntentsHelper.openUrl( - context = context , - url = annotation.item - ) - } - }) - } - } - - ExtendedFloatingActionButton(modifier = Modifier - .align(Alignment.BottomEnd) - .bounceClick() , - containerColor = if (fabEnabled.value) { - FloatingActionButtonDefaults.containerColor - } - else { - Gray - } , - text = { Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.agree)) } , - onClick = { - IntentsHelper.openActivity( - context , MainActivity::class.java - ) - } , - icon = { - Icon( - Icons.Outlined.CheckCircle , - contentDescription = null - ) - }) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/StudioBotScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/StudioBotScreen.kt deleted file mode 100644 index 39df318d..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/StudioBotScreen.kt +++ /dev/null @@ -1,349 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.studiobot - -import android.os.Bundle -import android.widget.Toast -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.Send -import androidx.compose.material.icons.filled.ThumbDown -import androidx.compose.material.icons.filled.ThumbUp -import androidx.compose.material.icons.outlined.Android -import androidx.compose.material.icons.outlined.CopyAll -import androidx.compose.material.icons.outlined.PersonOutline -import androidx.compose.material.icons.outlined.ThumbDown -import androidx.compose.material.icons.outlined.ThumbUp -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -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.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -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.viewmodel.compose.viewModel -import com.d4rk.android.libs.apptoolkit.ui.components.layouts.LoadingScreen -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.LargeHorizontalSpacer -import com.d4rk.android.libs.apptoolkit.utils.helpers.ClipboardHelper -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.data.model.api.ApiMessageData -import com.google.firebase.analytics.FirebaseAnalytics -import kotlinx.coroutines.delay - -@Composable -fun StudioBotScreen() { - val viewModel : StudioBotViewModel = viewModel() - val isLoading : Boolean by viewModel.isLoading.collectAsState() - - val transition : Transition = - updateTransition(targetState = ! isLoading , label = "LoadingTransition") - val progressAlpha : Float by transition.animateFloat(label = "Progress Alpha") { - if (it) 0f else 1f - } - - var userInput by remember { mutableStateOf(value = "") } - val chatHistory = viewModel.chatHistory.collectAsState() - - when { - isLoading -> { - LoadingScreen(progressAlpha = progressAlpha) - } - - else -> { - Column( - modifier = Modifier - .fillMaxSize() - .imePadding() - ) { - Box(modifier = Modifier.weight(weight = 1f)) { - ChatHistory(messages = chatHistory.value) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 16.dp) , - verticalAlignment = Alignment.CenterVertically - ) { - - OutlinedTextField(value = userInput , - singleLine = true , - onValueChange = { input -> - userInput = input.replaceFirstChar { char -> - if (char.isLowerCase()) char.titlecase() else char.toString() - } - } , - modifier = Modifier - .fillMaxWidth() - .weight(weight = 1f) - .padding(end = 8.dp) , - shape = CircleShape , - placeholder = { Text(text = stringResource(id = R.string.type_a_message)) } , - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences , - autoCorrectEnabled = true , - imeAction = ImeAction.Send - ) , - keyboardActions = KeyboardActions(onSend = { - viewModel.sendMessage(message = userInput) - userInput = "" - }) - ) - IconButton(enabled = userInput.isNotBlank() , onClick = { - if (userInput.isNotBlank()) { - viewModel.sendMessage(message = userInput) - userInput = "" - } - } , modifier = Modifier - .size(size = 56.dp) - .bounceClick()) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.Send , - contentDescription = "Send" , - ) - } - } - } - } - } -} - -@Composable -fun ChatHistory(messages : List) { - val scrollState = rememberScrollState() - - LaunchedEffect(key1 = messages.size) { - scrollState.animateScrollTo(value = scrollState.maxValue) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(state = scrollState) - ) { - messages.forEach { message -> - val isLatestBotMessage = message.isBot && message == messages.lastOrNull { it.isBot } - MessageBubble( - text = message.text , - isBot = message.isBot , - showTypingAnimation = isLatestBotMessage , - scrollState = scrollState - ) - } - } -} - - -@Composable -fun MessageBubble( - text : String , isBot : Boolean , showTypingAnimation : Boolean , scrollState : ScrollState -) { - var currentVisibleCharacters by remember(text) { mutableIntStateOf(value = if (showTypingAnimation) 0 else text.length) } - val textToDisplay = remember(key1 = text , key2 = currentVisibleCharacters) { - text.substring( - 0 , currentVisibleCharacters - ) - } - - LaunchedEffect(key1 = text , key2 = showTypingAnimation) { - when { - showTypingAnimation -> { - currentVisibleCharacters = 0 - while (currentVisibleCharacters < text.length) { - delay(timeMillis = 24) - currentVisibleCharacters ++ - } - scrollState.animateScrollTo(scrollState.maxValue) - } - - else -> { - currentVisibleCharacters = text.length - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 16.dp) , - verticalAlignment = Alignment.Top , - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (isBot) { - ProfilePicture( - icon = Icons.Outlined.Android , - backgroundColor = MaterialTheme.colorScheme.primaryContainer - ) - LargeHorizontalSpacer() - } - - Card( - shape = RoundedCornerShape(16.dp) , modifier = Modifier.weight(weight = 1f) - ) { - Column { - Text( - text = textToDisplay , modifier = Modifier.padding(all = 16.dp) - ) - MessageActions(text = text , isBot = isBot) - } - } - - if (! isBot) { - LargeHorizontalSpacer() - ProfilePicture( - icon = Icons.Outlined.PersonOutline , - backgroundColor = MaterialTheme.colorScheme.primaryContainer - ) - } - } -} - -@Composable -fun ProfilePicture(backgroundColor : Color , icon : ImageVector) { - Box( - modifier = Modifier - .size(size = 48.dp) - .background( - color = backgroundColor , shape = CircleShape - ) , contentAlignment = Alignment.Center - ) { - Icon( - modifier = Modifier.size(size = 32.dp) , - imageVector = icon , - contentDescription = "Profile Picture Icon" , - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } -} - -@Composable -fun MessageActions(text : String , isBot : Boolean) { - val context = LocalContext.current - var isLiked by remember { mutableStateOf(value = false) } - var isDisliked by remember { mutableStateOf(value = false) } - val firebaseAnalytics = remember { FirebaseAnalytics.getInstance(context) } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) , - horizontalArrangement = Arrangement.SpaceBetween , - verticalAlignment = Alignment.CenterVertically - ) { - - Row { - if (isBot) { - IconButton( - modifier = Modifier.bounceClick() , - onClick = { - if (isLiked) { - isLiked = false - } - else { - isLiked = true - isDisliked = false - } - - val params = Bundle().apply { - putString("message" , text) - } - firebaseAnalytics.logEvent( - if (isLiked) "message_liked" else "message_unliked" , params - ) - } , - ) { - Icon( - modifier = Modifier.size(size = ButtonDefaults.IconSize) , - imageVector = if (isLiked) Icons.Filled.ThumbUp else Icons.Outlined.ThumbUp , - contentDescription = "Like Icon" , - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - IconButton( - modifier = Modifier.bounceClick() , - onClick = { - if (isDisliked) { - isDisliked = false - } - else { - isDisliked = true - isLiked = false - } - val params = Bundle().apply { - putString("message" , text) - } - firebaseAnalytics.logEvent( - if (isDisliked) "message_disliked" else "message_undisliked" , params - ) - } , - ) { - Icon( - modifier = Modifier.size(size = ButtonDefaults.IconSize) , - imageVector = if (isDisliked) Icons.Filled.ThumbDown else Icons.Outlined.ThumbDown , - contentDescription = "Dislike Icon" , - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - TextButton(modifier = Modifier.bounceClick() , onClick = { - ClipboardHelper.copyTextToClipboard( - context = context , - label = "Message" , - text = text , - onShowSnackbar = { - Toast.makeText( - context , - "Message copied to clipboard" , - Toast.LENGTH_SHORT - ).show() - }) - } , contentPadding = PaddingValues(horizontal = 8.dp)) { - Icon( - imageVector = Icons.Outlined.CopyAll , - contentDescription = "Copy Message" , - modifier = Modifier.size(size = ButtonDefaults.IconSize) - ) - ButtonIconSpacer() - Text(text = stringResource(id = android.R.string.copy)) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/StudioBotViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/StudioBotViewModel.kt deleted file mode 100644 index 50a5eb96..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/StudioBotViewModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.studiobot - -import android.app.Application -import androidx.lifecycle.viewModelScope -import com.d4rk.androidtutorials.BuildConfig -import com.d4rk.androidtutorials.data.model.api.ApiMessageData -import com.d4rk.androidtutorials.ui.screens.studiobot.repository.StudioBotRepository -import com.d4rk.androidtutorials.ui.viewmodel.BaseViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.util.UUID - -class StudioBotViewModel(application : Application) : BaseViewModel(application) { - private val repository = StudioBotRepository() - - private val _chatHistory = MutableStateFlow( - value = listOf( - ApiMessageData( - id = UUID.randomUUID() , text = "Welcome to Studio Bot! How can I help you today?" , isBot = true - ) - ) - ) - val chatHistory : StateFlow> = _chatHistory.asStateFlow() - - init { - startChat() - } - - private fun startChat() { - viewModelScope.launch(context = coroutineExceptionHandler) { - showLoading() - repository.createChatSessionRepository(modelName = "gemini-1.5-flash" , apiKey = BuildConfig.API_KEY , onChatCreated = { _ -> }) - hideLoading() - } - } - - fun sendMessage(message : String) { - viewModelScope.launch(context = coroutineExceptionHandler) { - val newHistory = chatHistory.value.toMutableList() - newHistory.add( - element = ApiMessageData( - id = UUID.randomUUID() , text = message , isBot = false - ) - ) - _chatHistory.value = newHistory.toList() - - repository.sendMessageRepository(message = message) { reply -> - val updatedHistory = _chatHistory.value.toMutableList() - updatedHistory.add( - element = ApiMessageData( - id = UUID.randomUUID() , text = reply , isBot = true - ) - ) - _chatHistory.value = updatedHistory.toList() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/repository/StudioBotRepository.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/repository/StudioBotRepository.kt deleted file mode 100644 index dba37078..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/repository/StudioBotRepository.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.studiobot.repository - -import com.google.ai.client.generativeai.Chat -import com.google.ai.client.generativeai.type.asTextOrNull -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -class StudioBotRepository : StudioBotRepositoryImplementation() { - - suspend fun createChatSessionRepository( - modelName : String , - apiKey : String , - onChatCreated : (Chat) -> Unit , - ) { - withContext(Dispatchers.IO) { - val create = createChatSessionImplementation(modelName = modelName , apiKey = apiKey) - withContext(Dispatchers.Main) { - onChatCreated(create) - } - } - } - - suspend fun sendMessageRepository(message : String , onMessageSent : (String) -> Unit) { - withContext(Dispatchers.IO) { - val response = sendMessageToChatImplementation(message = message) - val messageContent = response.candidates.firstOrNull()?.content?.parts?.firstOrNull()?.asTextOrNull() ?: "" - withContext(Dispatchers.Main) { - onMessageSent(messageContent) - } - } - } -} diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/repository/StudioBotRepositoryImplementation.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/repository/StudioBotRepositoryImplementation.kt deleted file mode 100644 index 782a4afe..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/studiobot/repository/StudioBotRepositoryImplementation.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.d4rk.androidtutorials.ui.screens.studiobot.repository - -import com.google.ai.client.generativeai.Chat -import com.google.ai.client.generativeai.GenerativeModel -import com.google.ai.client.generativeai.type.GenerateContentResponse -import com.google.ai.client.generativeai.type.content -import com.google.ai.client.generativeai.type.generationConfig - -abstract class StudioBotRepositoryImplementation { - private lateinit var chat : Chat - - fun createChatSessionImplementation(modelName : String , apiKey : String) : Chat { - val generationConfig = generationConfig { - temperature = 1f - topK = 40 - topP = 0.95f - maxOutputTokens = 8192 - responseMimeType = "text/plain" - } - - val model = GenerativeModel( - modelName = modelName , - apiKey = apiKey , - generationConfig = generationConfig , - systemInstruction = content { text(text = "You are Studio Bot, an AI assistant integrated into the Android Studio Tutorials app developed by D4rK. Your primary function is to assist users with Android development by providing clear explanations, code examples, and troubleshooting advice. Maintain a professional and instructional tone in all interactions. If a user query falls outside the scope of Android development, politely inform them of your limitations") } , - ) - val createdChat = Chat(model = model) - chat = createdChat - return createdChat - } - - suspend fun sendMessageToChatImplementation(message : String) : GenerateContentResponse { - return chat.sendMessage(prompt = message.trim()) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/support/SupportActivity.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/support/SupportActivity.kt deleted file mode 100644 index 2db5b7a6..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/support/SupportActivity.kt +++ /dev/null @@ -1,46 +0,0 @@ -@file:Suppress("DEPRECATION") - -package com.d4rk.androidtutorials.ui.screens.support - -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.android.billingclient.api.BillingClient -import com.android.billingclient.api.BillingFlowParams -import com.android.billingclient.api.SkuDetails -import com.d4rk.androidtutorials.ui.screens.settings.display.theme.style.AppTheme - -class SupportActivity : AppCompatActivity() { - private val viewModel : SupportViewModel by viewModels() - - override fun onCreate(savedInstanceState : Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize() , color = MaterialTheme.colorScheme.background - ) { - SupportComposable(viewModel , activity = this@SupportActivity) - } - } - } - } - - fun initiatePurchase( - sku : String , skuDetailsMap : Map , billingClient : BillingClient - ) { - val skuDetails : SkuDetails? = skuDetailsMap[sku] - if (skuDetails != null) { - val flowParams : BillingFlowParams = - BillingFlowParams.newBuilder().setSkuDetails(skuDetails).build() - billingClient.launchBillingFlow(this , flowParams) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/support/SupportScreen.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/support/SupportScreen.kt deleted file mode 100644 index e83cc0c7..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/support/SupportScreen.kt +++ /dev/null @@ -1,239 +0,0 @@ -@file:Suppress("DEPRECATION") - -package com.d4rk.androidtutorials.ui.screens.support - -import android.content.Context -import android.view.SoundEffectConstants -import android.view.View -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -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.material.icons.Icons -import androidx.compose.material.icons.outlined.Paid -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -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.android.billingclient.api.BillingClient -import com.android.billingclient.api.BillingClientStateListener -import com.android.billingclient.api.BillingResult -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.utils.helpers.IntentsHelper -import com.d4rk.androidtutorials.ui.components.ads.AdBanner -import com.d4rk.androidtutorials.ui.components.navigation.TopAppBarScaffoldWithBackButton -import com.google.android.gms.ads.AdSize - -@Composable -fun SupportComposable(viewModel : SupportViewModel , activity : SupportActivity) { - val context : Context = LocalContext.current - val view : View = LocalView.current - val billingClient : BillingClient = rememberBillingClient(context , viewModel) - TopAppBarScaffoldWithBackButton(title = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.support_us) , onBackClicked = { activity.finish() }) { paddingValues -> - Box( - modifier = Modifier - .padding(paddingValues) - .fillMaxHeight() - ) { - LazyColumn { - item { - Text( - text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.paid_support) , - modifier = Modifier.padding(start = 16.dp , top = 16.dp) , - style = MaterialTheme.typography.titleLarge , - ) - } - item { - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Column { - Text( - text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.summary_donations) , modifier = Modifier.padding(16.dp) - ) - LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) , horizontalArrangement = Arrangement.SpaceEvenly - ) { - item { - FilledTonalButton( - modifier = Modifier - .fillMaxWidth() - .bounceClick() , - onClick = { - view.playSoundEffect(SoundEffectConstants.CLICK) - activity.initiatePurchase( - sku = "low_donation" , - viewModel.skuDetails , - billingClient , - ) - } , - ) { - Icon( - Icons.Outlined.Paid , contentDescription = null , modifier = Modifier.size(ButtonDefaults.IconSize) - ) - ButtonIconSpacer() - Text( - text = viewModel.skuDetails["low_donation"]?.price ?: "" - ) - } - } - item { - FilledTonalButton( - modifier = Modifier - .fillMaxWidth() - .bounceClick() , - onClick = { - view.playSoundEffect(SoundEffectConstants.CLICK) - activity.initiatePurchase( - sku = "normal_donation" , - viewModel.skuDetails , - billingClient , - ) - } , - ) { - Icon( - Icons.Outlined.Paid , contentDescription = null , modifier = Modifier.size(ButtonDefaults.IconSize) - ) - ButtonIconSpacer() - Text( - text = viewModel.skuDetails["normal_donation"]?.price ?: "" - ) - } - } - } - LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) , horizontalArrangement = Arrangement.SpaceEvenly - ) { - item { - FilledTonalButton( - modifier = Modifier - .fillMaxWidth() - .bounceClick() , - onClick = { - view.playSoundEffect(SoundEffectConstants.CLICK) - activity.initiatePurchase( - sku = "high_donation" , - viewModel.skuDetails , - billingClient , - ) - } , - ) { - Icon( - Icons.Outlined.Paid , contentDescription = null , modifier = Modifier.size(ButtonDefaults.IconSize) - ) - ButtonIconSpacer() - Text( - text = viewModel.skuDetails["high_donation"]?.price ?: "" - ) - } - } - item { - FilledTonalButton( - - modifier = Modifier - .fillMaxWidth() - .bounceClick() , - onClick = { - view.playSoundEffect(SoundEffectConstants.CLICK) - activity.initiatePurchase( - sku = "extreme_donation" , - viewModel.skuDetails , - billingClient , - ) - } , - ) { - Icon( - Icons.Outlined.Paid , contentDescription = null , modifier = Modifier.size(ButtonDefaults.IconSize) - ) - ButtonIconSpacer() - Text( - text = viewModel.skuDetails["extreme_donation"]?.price ?: "" - ) - } - } - } - } - } - } - item { - Text( - text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.non_paid_support) , - modifier = Modifier.padding(start = 16.dp) , - style = MaterialTheme.typography.titleLarge , - ) - } - item { - FilledTonalButton( - onClick = { - view.playSoundEffect(SoundEffectConstants.CLICK) - IntentsHelper.openUrl( - context = context , url = "https://direct-link.net/548212/agOqI7123501341" - ) - } , - modifier = Modifier - .fillMaxWidth() - .bounceClick() - .padding(16.dp) , - ) { - Icon( - Icons.Outlined.Paid , contentDescription = null , modifier = Modifier.size(ButtonDefaults.IconSize) - ) - ButtonIconSpacer() - Text(text = stringResource(id = com.d4rk.android.libs.apptoolkit.R.string.web_ad)) - } - } - item { - AdBanner(modifier = Modifier.padding(bottom = 12.dp) , adSize = AdSize.LARGE_BANNER) - } - } - } - } -} - -@Composable -fun rememberBillingClient( - context : Context , viewModel : SupportViewModel -) : BillingClient { - val billingClient : BillingClient = remember { - BillingClient.newBuilder(context).setListener { _ , _ -> }.enablePendingPurchases().build() - } - - DisposableEffect(billingClient) { - billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(billingResult : BillingResult) { - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - viewModel.querySkuDetails(billingClient) - } - } - - override fun onBillingServiceDisconnected() {} - }) - - onDispose { - billingClient.endConnection() - } - } - return billingClient -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/support/SupportViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/support/SupportViewModel.kt deleted file mode 100644 index ddf016fb..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/screens/support/SupportViewModel.kt +++ /dev/null @@ -1,35 +0,0 @@ -@file:Suppress("DEPRECATION") - -package com.d4rk.androidtutorials.ui.screens.support - -import androidx.compose.runtime.mutableStateMapOf -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.android.billingclient.api.BillingClient -import com.android.billingclient.api.SkuDetails -import com.android.billingclient.api.SkuDetailsParams -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class SupportViewModel : ViewModel() { - private val _skuDetails = mutableStateMapOf() - val skuDetails : Map = _skuDetails - - fun querySkuDetails(billingClient : BillingClient) { - viewModelScope.launch(Dispatchers.IO) { - val skuList : List = listOf( - "low_donation" , "normal_donation" , "high_donation" , "extreme_donation" - ) - val params : SkuDetailsParams = SkuDetailsParams.newBuilder().setSkusList(skuList) - .setType(BillingClient.SkuType.INAPP).build() - - billingClient.querySkuDetailsAsync(params) { billingResult , skuDetailsList -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && skuDetailsList != null) { - skuDetailsList.forEach { skuDetails -> - _skuDetails[skuDetails.sku] = skuDetails - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/viewmodel/BaseViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/viewmodel/BaseViewModel.kt deleted file mode 100644 index dbce1a44..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/viewmodel/BaseViewModel.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.d4rk.androidtutorials.ui.viewmodel - -import android.app.Application -import android.content.ActivityNotFoundException -import android.database.sqlite.SQLiteException -import android.util.Log -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.d4rk.android.libs.apptoolkit.data.model.ui.error.UiErrorModel -import com.d4rk.android.libs.apptoolkit.utils.constants.error.ErrorType -import com.d4rk.android.libs.apptoolkit.utils.error.ErrorHandler -import com.d4rk.androidtutorials.R -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.io.IOException - -open class BaseViewModel(application : Application) : AndroidViewModel(application) { - private val _isLoading : MutableStateFlow = MutableStateFlow(false) - val isLoading : StateFlow = _isLoading - - private val _uiErrorModel : MutableStateFlow = MutableStateFlow(UiErrorModel()) - val uiErrorModel : StateFlow = _uiErrorModel.asStateFlow() - - protected val coroutineExceptionHandler : CoroutineExceptionHandler = CoroutineExceptionHandler { _ , exception -> - Log.e("BaseViewModel" , "Coroutine Exception:" , exception) - handleError(exception = exception) - } - - val _visibilityStates : MutableStateFlow> = MutableStateFlow(emptyList()) - val visibilityStates : StateFlow> = _visibilityStates.asStateFlow() - - private val _isFabVisible : MutableStateFlow = MutableStateFlow(value = false) - val isFabVisible : StateFlow = _isFabVisible.asStateFlow() - - private fun handleError(exception : Throwable) { - viewModelScope.launch(context = coroutineExceptionHandler) { - val errorType : ErrorType = when (exception) { - is SecurityException -> ErrorType.SECURITY_EXCEPTION - is IOException -> ErrorType.IO_EXCEPTION - is ActivityNotFoundException -> ErrorType.ACTIVITY_NOT_FOUND - is IllegalArgumentException -> ErrorType.ILLEGAL_ARGUMENT - is SQLiteException -> ErrorType.SQLITE_EXCEPTION - else -> ErrorType.UNKNOWN_ERROR - } - - _uiErrorModel.value = UiErrorModel( - showErrorDialog = true , errorMessage = getErrorMessage(errorType = errorType) - ) - - ErrorHandler.handleError( - applicationContext = getApplication() , - errorType = errorType , - exception = exception - ) - } - } - - private fun getErrorMessage(errorType : ErrorType) : String { - return getApplication().getString( - when (errorType) { - ErrorType.SECURITY_EXCEPTION -> com.d4rk.android.libs.apptoolkit.R.string.security_error - ErrorType.IO_EXCEPTION -> com.d4rk.android.libs.apptoolkit.R.string.io_error - ErrorType.ACTIVITY_NOT_FOUND -> com.d4rk.android.libs.apptoolkit.R.string.activity_not_found - ErrorType.ILLEGAL_ARGUMENT -> com.d4rk.android.libs.apptoolkit.R.string.illegal_argument_error - ErrorType.SQLITE_EXCEPTION -> R.string.sqlite_error - else -> com.d4rk.android.libs.apptoolkit.R.string.unknown_error - } - ) - } - - fun dismissErrorDialog() { - viewModelScope.launch(context = coroutineExceptionHandler) { - _uiErrorModel.value = UiErrorModel(showErrorDialog = false) - } - } - - protected fun showLoading() { - viewModelScope.launch(context = coroutineExceptionHandler) { - _isLoading.value = true - } - } - - protected fun hideLoading() { - viewModelScope.launch(context = coroutineExceptionHandler) { - _isLoading.value = false - } - } - - protected fun showFab() { - viewModelScope.launch(context = coroutineExceptionHandler) { - _isFabVisible.value = true - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/viewmodel/LessonsViewModel.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/ui/viewmodel/LessonsViewModel.kt deleted file mode 100644 index 378393c0..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/ui/viewmodel/LessonsViewModel.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.d4rk.androidtutorials.ui.viewmodel - -import android.app.Application -import androidx.lifecycle.viewModelScope -import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeScreen -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -open class LessonsViewModel(application : Application) : BaseViewModel(application = application) { - - val _lessons = MutableStateFlow>(emptyList()) - val lessons : StateFlow> = _lessons.asStateFlow() - - fun initializeVisibilityStates() { - viewModelScope.launch(coroutineExceptionHandler) { - delay(50L) - _visibilityStates.value = - List(_lessons.value.firstOrNull()?.lessons?.size ?: 0) { false } - _lessons.value.firstOrNull()?.lessons?.indices?.forEach { index -> - delay(index * 8L) - _visibilityStates.value = List(_visibilityStates.value.size) { lessonIndex -> - lessonIndex == index || _visibilityStates.value[lessonIndex] - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ads/AdsConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ads/AdsConstants.kt deleted file mode 100644 index 539dc859..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ads/AdsConstants.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.d4rk.androidtutorials.utils.constants.ads - -import com.d4rk.android.libs.apptoolkit.utils.constants.ads.DebugAdsConstants -import com.d4rk.androidtutorials.BuildConfig - -object AdsConstants { - val BANNER_AD_UNIT_ID : String - get() = if (BuildConfig.DEBUG) { - DebugAdsConstants.BANNER_AD_UNIT_ID - } - else { - "ca-app-pub-5294151573817700/4974008668" - } - - val APP_OPEN_UNIT_ID : String - get() = if (BuildConfig.DEBUG) { - DebugAdsConstants.APP_OPEN_AD_UNIT_ID - } - else { - "ca-app-pub-5294151573817700/1738685282" - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/api/ApiConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/api/ApiConstants.kt deleted file mode 100644 index 3a6c5222..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/api/ApiConstants.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.d4rk.androidtutorials.utils.constants.api - -object ApiConstants { - const val BASE_REPOSITORY_URL = "https://raw.githubusercontent.com/D4rK7355608/com.d4rk.apis/refs/heads/main/Android%20Studio%20Tutorials" -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/datastore/AppDataStoreConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/datastore/AppDataStoreConstants.kt deleted file mode 100644 index 0a89a2f7..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/datastore/AppDataStoreConstants.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.d4rk.androidtutorials.utils.constants.datastore - -import com.d4rk.android.libs.apptoolkit.utils.constants.datastore.DataStoreNamesConstants - -object AppDataStoreConstants : DataStoreNamesConstants() { - const val DATA_STORE_STARTUP_PAGE = "startup_page" - const val DATA_STORE_SHOW_BOTTOM_BAR_LABELS = "show_bottom_bar_labels" -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/bottombar/BottomBarRoutes.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/bottombar/BottomBarRoutes.kt deleted file mode 100644 index 3626b560..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/bottombar/BottomBarRoutes.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.d4rk.androidtutorials.utils.constants.ui.bottombar - -object BottomBarRoutes { - const val HOME = "home" - const val STUDIO_BOT = "studio_bot" - const val FAVORITES = "favorites" -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/lessons/LessonCodeConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/lessons/LessonCodeConstants.kt deleted file mode 100644 index 0657f6eb..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/lessons/LessonCodeConstants.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.d4rk.androidtutorials.utils.constants.ui.lessons - -object LessonCodeConstants { - const val C = "C" - const val CPP = "CPP" - const val OBJECTIVEC = "ObjectiveC" - const val CSHARP = "CSharp" - const val JAVA = "Java" - const val BASH = "Bash" - const val PYTHON = "Python" - const val PERL = "Perl" - const val RUBY = "Ruby" - const val JAVASCRIPT = "JavaScript" - const val COFFEESCRIPT = "CoffeeScript" - const val RUST = "Rust" - const val BASIC = "Basic" - const val CLOJURE = "Clojure" - const val CSS = "CSS" - const val DART = "Dart" - const val ERLANG = "Erlang" - const val GO = "Go" - const val HASKELL = "Haskell" - const val LISP = "Lisp" - const val LUA = "Lua" - const val MATLAB = "Matlab" - const val ML = "ML" - const val SML = "SML" - const val MUMPS = "Mumps" - const val PASCAL = "Pascal" - const val SCALA = "Scala" - const val SQL = "SQL" - const val VHDL = "VHDL" - const val TCL = "Tcl" - const val WIKI = "Wiki" - const val XQUERY = "XQuery" - const val YAML = "YAML" - const val MARKDOWN = "Markdown" - const val JSON = "JSON" - const val XML = "XML" -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/lessons/LessonConstants.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/lessons/LessonConstants.kt deleted file mode 100644 index 3948bf62..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/lessons/LessonConstants.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.d4rk.androidtutorials.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" -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/lessons/LessonContentTypes.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/lessons/LessonContentTypes.kt deleted file mode 100644 index bb36cda7..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/constants/ui/lessons/LessonContentTypes.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.d4rk.androidtutorials.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 AD_BANNER = "ad_banner" - const val AD_BANNER_FULL = "ad_banner_full" - const val AD_LARGE_BANNER = "ad_large_banner" -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/error/CrashlyticsErrorReporter.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/error/CrashlyticsErrorReporter.kt deleted file mode 100644 index 78cd0069..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/error/CrashlyticsErrorReporter.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.d4rk.androidtutorials.utils.error - -import com.d4rk.android.libs.apptoolkit.utils.interfaces.ErrorReporter -import com.google.firebase.crashlytics.FirebaseCrashlytics - -class CrashlyticsErrorReporter : ErrorReporter { - - private val crashlytics: FirebaseCrashlytics = FirebaseCrashlytics.getInstance() - - override fun recordException(throwable: Throwable, message: String?) { - message?.let { - crashlytics.setCustomKey("error_message", it) - } - crashlytics.recordException(throwable) - } - - override fun setCustomKey(key: String, value: String) { - crashlytics.setCustomKey(key, value) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/extensions/MappingExtensions.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/extensions/MappingExtensions.kt deleted file mode 100644 index 69741ef8..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/extensions/MappingExtensions.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.d4rk.androidtutorials.utils.extensions - -import com.d4rk.androidtutorials.data.database.table.FavoriteLessonTable -import com.d4rk.androidtutorials.data.model.ui.screens.UiHomeLesson - -fun UiHomeLesson.toFavoriteLessonTable() : FavoriteLessonTable = FavoriteLessonTable( - lessonId = lessonId , - lessonTitle = lessonTitle , - lessonDescription = lessonDescription , - lessonType = lessonType , - thumbnailImageUrl = thumbnailImageUrl , - squareImageUrl = squareImageUrl , - deepLinkPath = deepLinkPath , - lessonTags = lessonTags , - isFavorite = isFavorite -) - -fun FavoriteLessonTable.toUiLesson() : UiHomeLesson { - return UiHomeLesson( - lessonId = lessonId , - lessonTitle = lessonTitle , - lessonDescription = lessonDescription , - lessonType = lessonType , - thumbnailImageUrl = thumbnailImageUrl , - squareImageUrl = squareImageUrl , - deepLinkPath = deepLinkPath , - lessonTags = lessonTags , - isFavorite = isFavorite - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppAboutSettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppAboutSettingsProvider.kt deleted file mode 100644 index 70f774c4..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppAboutSettingsProvider.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.d4rk.androidtutorials.utils.providers - -import android.content.Context -import com.d4rk.androidtutorials.R -import android.os.Build -import com.d4rk.android.libs.apptoolkit.utils.interfaces.providers.AboutSettingsProvider -import com.d4rk.androidtutorials.BuildConfig -import com.d4rk.androidtutorials.data.core.AppCoreManager - -class AppAboutSettingsProvider : AboutSettingsProvider { - - private val context : Context = AppCoreManager.instance - - override val appName : String - get() = context.getString(R.string.app_name) - - override val packageName : String - get() = context.packageName - - - override val appVersion : String - get() = BuildConfig.VERSION_NAME - - override val appVersionCode : Int - get() { - return BuildConfig.VERSION_CODE - } - - override val copyrightText : String - get() = context.getString(R.string.copyright) - - override val deviceInfo : String - get() { - return context.getString( - com.d4rk.android.libs.apptoolkit.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/utils/providers/AppAdvancedSettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppAdvancedSettingsProvider.kt deleted file mode 100644 index ac349816..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppAdvancedSettingsProvider.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.d4rk.androidtutorials.utils.providers - -import com.d4rk.android.libs.apptoolkit.utils.interfaces.providers.AdvancedSettingsProvider -import com.d4rk.androidtutorials.data.core.AppCoreManager - -class AppAdvancedSettingsProvider : AdvancedSettingsProvider { - override val bugReportUrl: String - get() = "https://github.com/D4rK7355608/${AppCoreManager.instance.packageName}/issues/new" -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppDisplaySettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppDisplaySettingsProvider.kt deleted file mode 100644 index bddf3686..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppDisplaySettingsProvider.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.d4rk.androidtutorials.utils.providers - -import android.content.Intent -import androidx.compose.runtime.Composable -import com.d4rk.android.libs.apptoolkit.utils.interfaces.providers.DisplaySettingsProvider -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.ui.components.dialogs.SelectLanguageAlertDialog -import com.d4rk.androidtutorials.ui.components.dialogs.SelectStartupScreenAlertDialog -import com.d4rk.androidtutorials.ui.screens.settings.general.GeneralSettingsActivity -import com.d4rk.androidtutorials.ui.screens.settings.general.SettingsContent - -class AppDisplaySettingsProvider : DisplaySettingsProvider { - - override val supportsStartupPage : Boolean = true - - @Composable - override fun LanguageSelectionDialog(onDismiss : () -> Unit , onLanguageSelected : (String) -> Unit) { - SelectLanguageAlertDialog( - dataStore = AppCoreManager.dataStore , onDismiss = onDismiss , onLanguageSelected = onLanguageSelected - ) - } - - @Composable - override fun StartupPageDialog(onDismiss : () -> Unit , onStartupSelected : (String) -> Unit) { - SelectStartupScreenAlertDialog( - dataStore = AppCoreManager.dataStore , onDismiss = onDismiss , onStartupSelected = onStartupSelected - ) - } - - override fun openThemeSettings() { - val context : AppCoreManager = AppCoreManager.instance - val intent : Intent = Intent(context , GeneralSettingsActivity::class.java).apply { - putExtra("extra_title" , context.getString(com.d4rk.android.libs.apptoolkit.R.string.dark_theme)) - putExtra("extra_content" , SettingsContent.THEME.name) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppPrivacySettingsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppPrivacySettingsProvider.kt deleted file mode 100644 index 828c90a7..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppPrivacySettingsProvider.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.d4rk.androidtutorials.utils.providers - -import android.content.Context -import android.content.Intent -import com.d4rk.android.libs.apptoolkit.utils.helpers.IntentsHelper -import com.d4rk.android.libs.apptoolkit.utils.interfaces.providers.PrivacySettingsProvider -import com.d4rk.androidtutorials.R -import com.d4rk.androidtutorials.data.core.AppCoreManager -import com.d4rk.androidtutorials.ui.screens.settings.general.GeneralSettingsActivity -import com.d4rk.androidtutorials.ui.screens.settings.general.SettingsContent -import com.d4rk.androidtutorials.ui.screens.settings.privacy.ads.AdsSettingsActivity -import com.d4rk.androidtutorials.ui.screens.settings.privacy.permissions.PermissionsSettingsActivity - -class AppPrivacySettingsProvider : PrivacySettingsProvider { - - val context : Context = AppCoreManager.instance - - override fun openPermissionsScreen() { - IntentsHelper.openActivity(context = context , activityClass = PermissionsSettingsActivity::class.java) - } - - override fun openAdsScreen() { - IntentsHelper.openActivity(context = context , activityClass = AdsSettingsActivity::class.java) - } - - override fun openUsageAndDiagnosticsScreen() { - - val intent : Intent = Intent(context , GeneralSettingsActivity::class.java).apply { - putExtra("extra_title" , context.getString(com.d4rk.android.libs.apptoolkit.R.string.usage_and_diagnostics)) - putExtra("extra_content" , SettingsContent.USAGE_AND_DIAGNOSTICS.name) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppUsageAndDiagnosticsProvider.kt b/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppUsageAndDiagnosticsProvider.kt deleted file mode 100644 index ca145735..00000000 --- a/app/src/main/kotlin/com/d4rk/androidtutorials/utils/providers/AppUsageAndDiagnosticsProvider.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.d4rk.androidtutorials.utils.providers - -import com.d4rk.android.libs.apptoolkit.utils.interfaces.providers.UsageAndDiagnosticsSettingsProvider -import com.d4rk.androidtutorials.BuildConfig - -class AppUsageAndDiagnosticsProvider : UsageAndDiagnosticsSettingsProvider { - - override val isDebugBuild : Boolean - get() { - return BuildConfig.DEBUG - } -} \ No newline at end of file diff --git a/app/src/main/play/keys/com.d4rk.androidtutorials.jks b/app/src/main/play/keys/com.d4rk.androidtutorials.jks deleted file mode 100644 index ebd77a0a..00000000 Binary files a/app/src/main/play/keys/com.d4rk.androidtutorials.jks and /dev/null differ diff --git a/app/src/main/play/keys/com.d4rk.androidtutorials_private_key.pepk b/app/src/main/play/keys/com.d4rk.androidtutorials_private_key.pepk deleted file mode 100644 index f8b33dc7..00000000 Binary files a/app/src/main/play/keys/com.d4rk.androidtutorials_private_key.pepk and /dev/null differ diff --git a/app/src/main/res/drawable-anydpi/anim_splash.xml b/app/src/main/res/drawable-anydpi/anim_splash.xml new file mode 100644 index 00000000..bc56d747 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/anim_splash.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/anim_splash_screen.xml b/app/src/main/res/drawable-anydpi/anim_splash_screen.xml deleted file mode 100644 index bf648f2b..00000000 --- a/app/src/main/res/drawable-anydpi/anim_splash_screen.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_launcher_foreground.xml b/app/src/main/res/drawable-anydpi/ic_launcher_foreground.xml index b8d39143..636b3a6b 100644 --- a/app/src/main/res/drawable-anydpi/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable-anydpi/ic_launcher_foreground.xml @@ -1,4 +1,21 @@ + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/il_square_image_error.xml b/app/src/main/res/drawable-anydpi/il_square_image_error.xml index f50b4a34..2355fb2b 100644 --- a/app/src/main/res/drawable-anydpi/il_square_image_error.xml +++ b/app/src/main/res/drawable-anydpi/il_square_image_error.xml @@ -1,4 +1,21 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/font/font_google_sans_code.ttf b/app/src/main/res/font/font_google_sans_code.ttf new file mode 100644 index 00000000..8f0aaaec Binary files /dev/null and b/app/src/main/res/font/font_google_sans_code.ttf differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 091d18dc..d4d2c81a 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,4 +1,21 @@ + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 091d18dc..d4d2c81a 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,4 +1,21 @@ + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_settings.xml b/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_settings.xml index 560d844d..62b0f24e 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_settings.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_settings.xml @@ -1,4 +1,21 @@ + + diff --git a/app/src/main/res/values-bg-rBG/strings.xml b/app/src/main/res/values-bg-rBG/strings.xml index d57995db..805b39ba 100644 --- a/app/src/main/res/values-bg-rBG/strings.xml +++ b/app/src/main/res/values-bg-rBG/strings.xml @@ -1,4 +1,21 @@ + + Научете как да създавате прости приложения в Android Studio! Липсвахте ни! Нека научим нещо ново за Android! @@ -15,23 +32,6 @@ Грешка при зареждане на любими уроци. Моля, опитайте отново по-късно. Не бяха намерени любими уроци. - Рекламен идентификатор [AD_ID] - Разрешава на приложението да извлича и използва рекламния идентификатор, свързан с устройството на потребителя, като предоставя персонализирани реклами, измерва ефективността на рекламите и показва реклами на устройства с Android 13 или по-нова версия. - Интернет [INTERNET] - Разрешава на приложението да установи интернет връзка, за да изпраща доклади за грешки или да проверява за актуализации. - Публикуване на известия [POST_NOTIFICATIONS] - Разрешава на приложението да показва известия на устройства с Android 13 или по-нова версия. - Достъп до състоянието на мрежата [ACCESS_NETWORK_STATE] - Разрешава на приложението да проверява мрежовата връзка и да извлича информация за Wi-Fi, включително активирано състояние и свързани имена на Wi-Fi устройства. - Достъп до правилата за известия [ACCESS_NOTIFICATION_POLICY] - Разрешава на приложението да осъществява достъп и да променя правилата за известия на устройството, като контролира как и кога известията се показват на потребителя и предоставя функции за персонализирано управление на известията. - Плащане [BILLING] - Разрешава на приложението да използва библиотеката за таксуване на Google Play, за да обработва покупки в приложението и дарения - Проверка на лиценза [CHECK_LICENSE] - Разрешава на приложението да проверява съответствието си с лицензионното споразумение и да прилага лицензионни условия за защита на интелектуалната собственост. - Преден план [FOREGROUND_SERVICE] - Разрешава на приложението да създава и използва услуги, които работят на преден план, като им дава приоритет пред други процеси на заден план и подобрява производителността и надеждността. - Научете повече за Android Studio Tutorials Какво представлява Android Studio Tutorials: Kotlin Edition? diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 0a95b374..8c2e05c4 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -1,4 +1,21 @@ + + Erfahre, wie du einfache Apps in Android Studio erstellst! Wir haben dich vermisst! Lass uns etwas Neues über Android lernen! @@ -15,23 +32,6 @@ Fehler beim Laden der Lieblingslektionen. Bitte versuche es später noch einmal. Keine Lieblingslektionen gefunden. - Werbe-ID [AD_ID] - Ermöglicht der App, die Werbe-ID abzurufen und zu verwenden, die dem Gerät des Benutzers zugeordnet ist, um personalisierte Werbung bereitzustellen, die Wirksamkeit von Werbung zu messen und Werbung auf Geräten mit Android 13 oder höher anzuzeigen. - Internet [INTERNET] - Ermöglicht der App, eine Internetverbindung herzustellen, um Fehlerberichte zu senden oder nach Updates zu suchen. - Benachrichtigungen senden [POST_NOTIFICATIONS] - Ermöglicht der App, Benachrichtigungen auf Geräten mit Android 13 oder höher anzuzeigen. - Netzwerkstatus abrufen [ACCESS_NETWORK_STATE] - Ermöglicht der App, die Netzwerkkonnektivität zu überprüfen und Informationen über WLAN abzurufen, einschließlich des aktivierten Status und der Namen der verbundenen WLAN-Geräte. - Zugriff auf Benachrichtigungsrichtlinie [ACCESS_NOTIFICATION_POLICY] - Ermöglicht der App, auf die Benachrichtigungsrichtlinie des Geräts zuzugreifen und diese zu ändern, um zu steuern, wie und wann Benachrichtigungen dem Benutzer angezeigt werden, und um benutzerdefinierte Benachrichtigungsverwaltungsfunktionen bereitzustellen. - Abrechnung [BILLING] - Ermöglicht der App die Verwendung der Google Play Billing Library zur Abwicklung von In-App-Käufen und Spenden - Lizenz prüfen [CHECK_LICENSE] - Ermöglicht der App, die Einhaltung der Lizenzvereinbarung zu überprüfen und die Lizenzbedingungen zum Schutz des geistigen Eigentums durchzusetzen. - Vordergrunddienst [FOREGROUND_SERVICE] - Ermöglicht der App, Dienste zu erstellen und zu verwenden, die im Vordergrund ausgeführt werden, wodurch sie Priorität vor anderen Hintergrundprozessen erhalten und die Leistung und Zuverlässigkeit verbessert wird. - Erfahre mehr über Android Studio Tutorials Was ist Android Studio Tutorials: Kotlin Edition? diff --git a/app/src/main/res/values-es-rGQ/strings.xml b/app/src/main/res/values-es-rGQ/strings.xml index ee224340..0a93ebf7 100644 --- a/app/src/main/res/values-es-rGQ/strings.xml +++ b/app/src/main/res/values-es-rGQ/strings.xml @@ -1,4 +1,21 @@ + + ¡Aprende a crear aplicaciones sencillas en Android Studio! ¡Te extrañamos! ¡Aprendamos algo nuevo sobre Android! @@ -15,23 +32,6 @@ Error al cargar las lecciones favoritas. Por favor, inténtalo de nuevo más tarde. No se encontraron lecciones favoritas. - ID de publicidad [AD_ID] - Permite que la aplicación recupere y use el identificador de publicidad asociado con el dispositivo del usuario, proporcionando anuncios personalizados, midiendo la eficacia de los anuncios y mostrando anuncios en dispositivos con Android 13 o posterior. - Internet [INTERNET] - Permite que la aplicación establezca una conexión a Internet para enviar informes de errores o buscar actualizaciones. - Publicar notificaciones [POST_NOTIFICATIONS] - Permite que la aplicación muestre notificaciones en dispositivos con Android 13 o posterior. - Acceder al estado de la red [ACCESS_NETWORK_STATE] - Permite que la aplicación compruebe la conectividad de la red y recupere información sobre Wi-Fi, incluido el estado habilitado y los nombres de dispositivos Wi-Fi conectados. - Acceder a la política de notificaciones [ACCESS_NOTIFICATION_POLICY] - Permite que la aplicación acceda y modifique la política de notificaciones del dispositivo, controlando cómo y cuándo se muestran las notificaciones al usuario y proporcionando funciones de gestión de notificaciones personalizadas. - Facturación [BILLING] - Permite a la aplicación usar la biblioteca de facturación de Google Play para gestionar compras en la aplicación y donaciones - Comprobar licencia [CHECK_LICENSE] - Permite que la aplicación verifique su cumplimiento con el acuerdo de licencia y haga cumplir los términos de licencia para proteger la propiedad intelectual. - Servicio en primer plano [FOREGROUND_SERVICE] - Permite a la aplicación crear y utilizar servicios que se ejecutan en primer plano, dándoles prioridad sobre otros procesos en segundo plano y mejorando el rendimiento y la fiabilidad. - Obtén más información sobre Android Studio Tutorials ¿Qué es Android Studio Tutorials: Kotlin Edition? diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 85685cad..d0a1a1a8 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -1,4 +1,21 @@ + + Apprenez à créer des applications simples dans Android Studio! Vous nous avez manqué ! Apprenons quelque chose de nouveau sur Android ! @@ -15,23 +32,6 @@ Erreur lors du chargement des leçons favorites. Veuillez réessayer plus tard. Aucune leçon favorite trouvée. - ID de publicité [AD_ID] - Permet à l’application de récupérer et d’utiliser l’identifiant publicitaire associé à l’appareil de l’utilisateur, fournissant des publicités personnalisées, mesurant l’efficacité des publicités et affichant des publicités sur les appareils Android 13 ou ultérieurs. - Internet [INTERNET] - Permet à l’application d’établir une connexion Internet pour envoyer des rapports d’erreurs ou rechercher des mises à jour. - Publier des notifications [POST_NOTIFICATIONS] - Permet à l’application d’afficher des notifications sur les appareils équipés d’Android 13 ou version ultérieure. - Accéder à l’état du réseau [ACCESS_NETWORK_STATE] - Permet à l’application de vérifier la connectivité du réseau et de récupérer des informations sur le Wi-Fi, y compris le statut activé et les noms des appareils Wi-Fi connectés. - Accéder à la politique de notification [ACCESS_NOTIFICATION_POLICY] - Permet à l’application d’accéder à la politique de notification de l’appareil et de la modifier, contrôlant comment et quand les notifications sont affichées à l’utilisateur et fournissant des fonctions de gestion de notification personnalisées. - Facturation [BILLING] - Permet à l’application d’utiliser la bibliothèque de facturation Google Play pour gérer les achats intégrés et les dons - Vérifier la licence [CHECK_LICENSE] - Permet à l’application de vérifier sa conformité avec le contrat de licence et d’appliquer les conditions de licence pour protéger la propriété intellectuelle. - Service de premier plan [FOREGROUND_SERVICE] - Permet à l’application de créer et d’utiliser des services qui s’exécutent au premier plan, leur donnant la priorité sur les autres processus d’arrière-plan et améliorant les performances et la fiabilité. - En savoir plus sur les Tutoriels Android Studio Qu’est-ce que Tutoriels Android Studio: Édition Kotlin? diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index afe99a30..e7a385c0 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -1,4 +1,21 @@ + + एंड्रॉइड स्टूडियो में सरल ऐप बनाना सीखें! हमें आपकी याद आई! आइए Android के बारे में कुछ नया सीखें! @@ -15,23 +32,6 @@ पसंदीदा सबक लोड करने में त्रुटि हुई। कृपया बाद में पुनः प्रयास करें. कोई पसंदीदा सबक नहीं मिला. - विज्ञापन आईडी [AD_ID] - ऐप को उपयोगकर्ता के डिवाइस से जुड़े विज्ञापन पहचानकर्ता को पुनः प्राप्त करने और उपयोग करने की अनुमति देता है, वैयक्तिकृत विज्ञापन प्रदान करता है, विज्ञापन प्रभावशीलता को मापता है और Android 13 या बाद के उपकरणों पर विज्ञापन दिखाता है। - इंटरनेट [INTERNET] - ऐप को त्रुटि रिपोर्ट भेजने या अपडेट की जांच करने के लिए एक इंटरनेट कनेक्शन स्थापित करने की अनुमति देता है। - सूचनाएं पोस्ट करें [POST_NOTIFICATIONS] - ऐप को Android 13 या बाद के उपकरणों पर सूचनाएं प्रदर्शित करने की अनुमति देता है। - नेटवर्क स्थिति तक पहुंचें [ACCESS_NETWORK_STATE] - ऐप को नेटवर्क कनेक्टिविटी की जांच करने और Wi-Fi के बारे में जानकारी प्राप्त करने की अनुमति देता है, जिसमें सक्षम स्थिति और कनेक्टेड Wi-Fi डिवाइस के नाम शामिल हैं। - अधिसूचना नीति तक पहुंचें [ACCESS_NOTIFICATION_POLICY] - ऐप को डिवाइस की अधिसूचना नीति तक पहुंचने और उसे संशोधित करने, यह नियंत्रित करने की अनुमति देता है कि उपयोगकर्ता को कब और कैसे सूचनाएं प्रदर्शित की जाती हैं और कस्टम अधिसूचना प्रबंधन सुविधाएँ प्रदान करते हैं। - बिलिंग [BILLING] - ऐप को इन-ऐप खरीदारी और दान को संभालने के लिए Google Play बिलिंग लाइब्रेरी का उपयोग करने की अनुमति देता है - लाइसेंस जांचें [CHECK_LICENSE] - ऐप को लाइसेंस समझौते के साथ अपनी अनुपालन को सत्यापित करने और बौद्धिक संपदा की सुरक्षा के लिए लाइसेंसिंग शर्तों को लागू करने की अनुमति देता है। - अग्रभूमि सेवा [FOREGROUND_SERVICE] - ऐप को उन सेवाओं को बनाने और उपयोग करने की अनुमति देता है जो अग्रभूमि में चलती हैं, जिससे उन्हें अन्य पृष्ठभूमि प्रक्रियाओं पर प्राथमिकता मिलती है और प्रदर्शन और विश्वसनीयता में सुधार होता है। - एंड्रॉइड स्टूडियो ट्यूटोरियल के बारे में अधिक जानें एंड्रॉइड स्टूडियो ट्यूटोरियल: कोटलिन एडिशन क्या है? diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 71a688df..34e790e0 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -1,4 +1,21 @@ + + Tanuld meg, hogyan készíthetsz egyszerű alkalmazásokat az Android Studioban! Hiányoztál! Tanuljunk valami újat az Androidról! @@ -15,23 +32,6 @@ Hiba történt a kedvenc leckék betöltésekor. Kérjük, próbálja újra később. Nem található kedvenc lecke. - Hirdetésazonosító [AD_ID] - Lehetővé teszi az alkalmazás számára, hogy lekérje és felhasználja a felhasználó eszközéhez társított hirdetési azonosítót, személyre szabott hirdetéseket biztosítva, mérve a hirdetések hatékonyságát, és hirdetéseket jelenítsen meg Android 13 vagy újabb rendszerű eszközökön. - Internet [INTERNET] - Lehetővé teszi az alkalmazás számára, hogy internetkapcsolatot hozzon létre hibajelentések küldéséhez vagy frissítések kereséséhez. - Értesítések küldése [POST_NOTIFICATIONS] - Lehetővé teszi az alkalmazás számára, hogy értesítéseket jelenítsen meg Android 13 vagy újabb rendszerű eszközökön. - Hálózati állapot elérése [ACCESS_NETWORK_STATE] - Lehetővé teszi az alkalmazás számára a hálózati kapcsolat ellenőrzését és a Wi-Fi-vel kapcsolatos információk lekérését, beleértve az engedélyezett állapotot és a csatlakoztatott Wi-Fi-eszközök neveit. - Értesítési szabályzat elérése [ACCESS_NOTIFICATION_POLICY] - Lehetővé teszi az alkalmazás számára az eszköz értesítési szabályzatának elérését és módosítását, szabályozva, hogy hogyan és mikor jelennek meg az értesítések a felhasználó számára, és egyedi értesítéskezelési funkciókat biztosítva. - Számlázás [BILLING] - Lehetővé teszi az alkalmazás számára a Google Play Billing Library használatát az alkalmazáson belüli vásárlások és adományok kezeléséhez - Licenc ellenőrzése [CHECK_LICENSE] - Lehetővé teszi az alkalmazás számára, hogy ellenőrizze a licencszerződésnek való megfelelését, és érvényesítse a licencfeltételeket a szellemi tulajdon védelme érdekében. - Előtérszolgáltatás [FOREGROUND_SERVICE] - Lehetővé teszi az alkalmazás számára, hogy előtérben futó szolgáltatásokat hozzon létre és használjon, ezzel prioritást adva nekik más háttérfolyamatokkal szemben, és javítva a teljesítményt és a megbízhatóságot. - Tudj meg többet az Android Studio Tutorials alkalmazásról Mi az Android Studio Tutorials: Kotlin Edition? diff --git a/app/src/main/res/values-in-rID/strings.xml b/app/src/main/res/values-in-rID/strings.xml index 18ed9a88..2f8b2344 100644 --- a/app/src/main/res/values-in-rID/strings.xml +++ b/app/src/main/res/values-in-rID/strings.xml @@ -1,4 +1,21 @@ + + Pelajari cara membuat aplikasi sederhana di Android Studio! Kami merindukanmu! Mari pelajari sesuatu yang baru tentang Android! @@ -15,23 +32,6 @@ Kesalahan saat memuat pelajaran favorit. Silakan coba lagi nanti. Tidak ada pelajaran favorit yang ditemukan. - ID iklan [AD_ID] - Memungkinkan aplikasi mengambil dan menggunakan pengenal iklan yang terkait dengan perangkat pengguna, menyediakan iklan yang dipersonalisasi, mengukur efektivitas iklan, dan menampilkan iklan di perangkat Android 13 atau yang lebih baru. - Internet [INTERNET] - Memungkinkan aplikasi untuk membuat koneksi internet untuk mengirim laporan kesalahan atau memeriksa pembaruan. - Kirim notifikasi [POST_NOTIFICATIONS] - Memungkinkan aplikasi untuk menampilkan notifikasi pada perangkat dengan Android 13 atau yang lebih baru. - Akses status jaringan [ACCESS_NETWORK_STATE] - Memungkinkan aplikasi untuk memeriksa konektivitas jaringan dan mengambil informasi tentang Wi-Fi, termasuk status aktif dan nama perangkat Wi-Fi yang terhubung. - Akses kebijakan notifikasi [ACCESS_NOTIFICATION_POLICY] - Memungkinkan aplikasi untuk mengakses dan memodifikasi kebijakan notifikasi perangkat, mengontrol bagaimana dan kapan notifikasi ditampilkan kepada pengguna dan menyediakan fitur manajemen notifikasi khusus. - Penagihan [BILLING] - Memungkinkan aplikasi menggunakan Google Play Billing Library untuk menangani pembelian dalam aplikasi dan donasi - Periksa lisensi [CHECK_LICENSE] - Memungkinkan aplikasi untuk memverifikasi kepatuhannya terhadap perjanjian lisensi dan menegakkan persyaratan lisensi untuk melindungi kekayaan intelektual. - Layanan latar depan [FOREGROUND_SERVICE] - Memungkinkan aplikasi untuk membuat dan menggunakan layanan yang berjalan di latar depan, memberikan prioritas lebih tinggi daripada proses latar belakang lainnya dan meningkatkan kinerja dan keandalan. - Pelajari lebih lanjut tentang Tutorial Android Studio Apa itu Tutorial Android Studio: Edisi Kotlin? diff --git a/app/src/main/res/values-it-rIT/strings.xml b/app/src/main/res/values-it-rIT/strings.xml index ba16690e..c1fe72fc 100644 --- a/app/src/main/res/values-it-rIT/strings.xml +++ b/app/src/main/res/values-it-rIT/strings.xml @@ -1,4 +1,21 @@ + + Impara a creare semplici app in Android Studio! Ci sei mancato! Impariamo qualcosa di nuovo su Android! @@ -15,23 +32,6 @@ Errore durante il caricamento delle lezioni preferite. Riprova più tardi. Nessuna lezione preferita trovata. - ID annuncio [AD_ID] - Consente all\'app di recuperare e utilizzare l\'identificatore pubblicitario associato al dispositivo dell\'utente, fornendo annunci personalizzati, misurando l\'efficacia degli annunci e visualizzando annunci su dispositivi Android 13 o versioni successive. - Internet [INTERNET] - Consente all\'app di stabilire una connessione Internet per inviare segnalazioni di errori o verificare la disponibilità di aggiornamenti. - Invia notifiche [POST_NOTIFICATIONS] - Consente all\'app di visualizzare notifiche sui dispositivi con Android 13 o versioni successive. - Accedi allo stato della rete [ACCESS_NETWORK_STATE] - Consente all\'app di controllare la connettività di rete e recuperare informazioni sul Wi-Fi, inclusi lo stato abilitato e i nomi dei dispositivi Wi-Fi connessi. - Accedi alla politica di notifica [ACCESS_NOTIFICATION_POLICY] - Consente all\'app di accedere e modificare la politica di notifica del dispositivo, controllando come e quando le notifiche vengono visualizzate all\'utente e fornendo funzionalità di gestione delle notifiche personalizzate. - Fatturazione [BILLING] - Consente all\'app di utilizzare la libreria di fatturazione di Google Play per gestire gli acquisti in-app e le donazioni - Verifica licenza [CHECK_LICENSE] - Consente all\'app di verificare la propria conformità al contratto di licenza e di applicare i termini di licenza per proteggere la proprietà intellettuale. - Servizio in primo piano [FOREGROUND_SERVICE] - Consente all\'app di creare e utilizzare servizi che vengono eseguiti in primo piano, dando loro la priorità su altri processi in background e migliorando le prestazioni e l\'affidabilità. - Scopri di più su Android Studio Tutorials Cos\'è Android Studio Tutorials: Kotlin Edition? diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index d967cc74..1a499b2d 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -1,4 +1,21 @@ + + Android Studio で簡単なアプリを作成する方法を学びましょう! 会えなくて寂しかったです!Android について何か新しいことを学びましょう! @@ -15,23 +32,6 @@ お気に入りのレッスンを読み込む際にエラーが発生しました。後でもう一度やり直してください。 お気に入りのレッスンが見つかりません。 - 広告 ID [AD_ID] - ユーザーのデバイスに関連付けられた広告識別子を取得して使用し、パーソナライズされた広告を提供し、広告の効果を測定し、Android 13以降のデバイスで広告を表示することをアプリに許可します。 - インターネット [INTERNET] - エラーレポートを送信したり、更新を確認したりするために、アプリがインターネット接続を確立することを許可します。 - 通知を投稿する [POST_NOTIFICATIONS] - アプリがAndroid 13以降を搭載したデバイスに通知を表示できるようにします。 - ネットワーク状態へのアクセス [ACCESS_NETWORK_STATE] - アプリがネットワーク接続を確認し、有効ステータスや接続されているWi-Fiデバイス名など、Wi-Fiに関する情報を取得できるようにします。 - 通知ポリシーへのアクセス [ACCESS_NOTIFICATION_POLICY] - アプリがデバイスの通知ポリシーにアクセスして変更し、ユーザーに通知が表示される方法とタイミングを制御し、カスタム通知管理機能を提供できるようにします。 - 請求 [BILLING] - アプリがGoogle Play Billing Libraryを使用してアプリ内購入と寄付を処理できるようにします - ライセンスを確認する [CHECK_LICENSE] - アプリがライセンス契約への準拠を確認し、知的財産を保護するためにライセンス条項を施行できるようにします。 - フォアグラウンドサービス [FOREGROUND_SERVICE] - アプリがフォアグラウンドで実行されるサービスを作成および使用できるようにし、他のバックグラウンドプロセスよりも優先順位を高くし、パフォーマンスと信頼性を向上させます。 - Android Studio Tutorialsの詳細 Android Studio Tutorials: Kotlin Editionとは何ですか? diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml index ac27e99c..a5320025 100644 --- a/app/src/main/res/values-night-v31/colors.xml +++ b/app/src/main/res/values-night-v31/colors.xml @@ -1,4 +1,21 @@ + + @android:color/system_neutral1_800 @android:color/system_accent1_100 diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 43412f0a..30062dde 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,4 +1,21 @@ + + #2D2D2D #2E3133 diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml deleted file mode 100644 index 48df705d..00000000 --- a/app/src/main/res/values-night/themes.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 1bcff7ec..f9763eab 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -1,4 +1,21 @@ + + Dowiedz się, jak tworzyć proste aplikacje w Android Studio! Tęskniliśmy! Dowiedzmy się czegoś nowego o Androidzie! @@ -15,23 +32,6 @@ Błąd ładowania ulubionych lekcji. Spróbuj ponownie później. Nie znaleziono ulubionych lekcji. - Identyfikator reklamy [AD_ID] - Umożliwia aplikacji pobieranie i wykorzystywanie identyfikatora reklamowego powiązanego z urządzeniem użytkownika, zapewniając spersonalizowane reklamy, mierząc skuteczność reklam i wyświetlając reklamy na urządzeniach z systemem Android 13 lub nowszym. - Internet [INTERNET] - Umożliwia aplikacji nawiązanie połączenia internetowego w celu wysyłania raportów o błędach lub sprawdzania aktualizacji. - Publikuj powiadomienia [POST_NOTIFICATIONS] - Umożliwia aplikacji wyświetlanie powiadomień na urządzeniach z systemem Android 13 lub nowszym. - Dostęp do stanu sieci [ACCESS_NETWORK_STATE] - Umożliwia aplikacji sprawdzanie połączenia sieciowego i pobieranie informacji o Wi-Fi, w tym stanu włączenia i nazw podłączonych urządzeń Wi-Fi. - Dostęp do zasad powiadomień [ACCESS_NOTIFICATION_POLICY] - Umożliwia aplikacji dostęp do zasad powiadomień urządzenia i modyfikowanie ich, kontrolowanie, w jaki sposób i kiedy powiadomienia są wyświetlane użytkownikowi oraz udostępnianie niestandardowych funkcji zarządzania powiadomieniami. - Rozliczenia [BILLING] - Umożliwia aplikacji korzystanie z Biblioteki rozliczeniowej Google Play do obsługi zakupów w aplikacji i darowizn - Sprawdź licencję [CHECK_LICENSE] - Umożliwia aplikacji weryfikację zgodności z umową licencyjną i egzekwowanie warunków licencyjnych w celu ochrony własności intelektualnej. - Usługa pierwszoplanowa [FOREGROUND_SERVICE] - Umożliwia aplikacji tworzenie i korzystanie z usług działających na pierwszym planie, przyznając im priorytet w stosunku do innych procesów w tle i poprawiając wydajność i niezawodność. - Dowiedz się więcej o samouczkach Android Studio Co to jest Android Studio Tutorials: Kotlin Edition? diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6d3fe7c2..b7f03ec6 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,4 +1,21 @@ + + Aprenda a criar aplicativos simples no Android Studio! Sentimos sua falta! Vamos aprender algo novo sobre o Android! @@ -15,23 +32,6 @@ Erro ao carregar lições favoritas. Tente novamente mais tarde. Nenhuma lição favorita encontrada. - ID do anúncio [AD_ID] - Permite que o aplicativo recupere e use o identificador de publicidade associado ao dispositivo do usuário, fornecendo anúncios personalizados, medindo a eficácia do anúncio e exibindo anúncios em dispositivos Android 13 ou posterior. - Internet [INTERNET] - Permite que o aplicativo estabeleça uma conexão com a Internet para enviar relatórios de erros ou verificar se há atualizações. - Publicar notificações [POST_NOTIFICATIONS] - Permite que o aplicativo exiba notificações nos dispositivos com Android 13 ou posterior. - Acessar estado da rede [ACCESS_NETWORK_STATE] - Permite que o aplicativo verifique a conectividade de rede e recupere informações sobre Wi-Fi, incluindo status ativado e nomes de dispositivos Wi-Fi conectados. - Acessar política de notificação [ACCESS_NOTIFICATION_POLICY] - Permite que o aplicativo acesse e modifique a política de notificação do dispositivo, controlando como e quando as notificações são exibidas ao usuário e fornecendo recursos de gerenciamento de notificação personalizados. - Cobrança [BILLING] - Permite que o aplicativo use a biblioteca de cobrança do Google Play para lidar com compras e doações no aplicativo - Verificar licença [CHECK_LICENSE] - Permite que o aplicativo verifique sua conformidade com o contrato de licença e imponha os termos de licenciamento para proteger a propriedade intelectual. - Serviço em primeiro plano [FOREGROUND_SERVICE] - Permite que o aplicativo crie e use serviços que são executados em primeiro plano, dando-lhes prioridade sobre outros processos em segundo plano e melhorando o desempenho e a confiabilidade. - Saiba mais sobre os Tutoriais do Android Studio O que são os Tutoriais do Android Studio: Kotlin Edition? diff --git a/app/src/main/res/values-ro-rRO/strings.xml b/app/src/main/res/values-ro-rRO/strings.xml index e99e2ff7..f87c8f47 100644 --- a/app/src/main/res/values-ro-rRO/strings.xml +++ b/app/src/main/res/values-ro-rRO/strings.xml @@ -1,4 +1,21 @@ + + Învață cum să creezi aplicații simple în Android Studio! Ne-a fost dor de tine! Să învățăm ceva nou despre Android! @@ -15,23 +32,6 @@ Eroare la încărcarea lecțiilor favorite. Vă rugăm să încercați din nou mai târziu. Nu s-au găsit lecții favorite. - ID anunț [AD_ID] - Permite aplicației să preia și să utilizeze identificatorul de publicitate asociat cu dispozitivul utilizatorului, oferind reclame personalizate, măsurând eficacitatea reclamelor și afișând reclame pe dispozitivele cu Android 13 sau versiuni ulterioare. - Internet [INTERNET] - Permite aplicației să stabilească o conexiune la internet pentru a trimite rapoarte de erori sau a verifica actualizările. - Publicați notificări [POST_NOTIFICATIONS] - Permite aplicației să afișeze notificări pe dispozitivele cu Android 13 sau versiuni ulterioare. - Accesați starea rețelei [ACCESS_NETWORK_STATE] - Permite aplicației să verifice conectivitatea rețelei și să preia informații despre Wi-Fi, inclusiv starea activată și numele dispozitivelor Wi-Fi conectate. - Accesați politica de notificare [ACCESS_NOTIFICATION_POLICY] - Permite aplicației să acceseze și să modifice politica de notificare a dispozitivului, controlând modul și momentul în care notificările sunt afișate utilizatorului și oferind funcții de gestionare personalizate a notificărilor. - Facturare [BILLING] - Permite aplicației să utilizeze Google Play Billing Library pentru a gestiona achizițiile și donațiile în aplicație - Verificați licența [CHECK_LICENSE] - Permite aplicației să verifice conformitatea cu acordul de licență și să aplice termenii de licență pentru a proteja proprietatea intelectuală. - Serviciu de prim-plan [FOREGROUND_SERVICE] - Permite aplicației să creeze și să utilizeze servicii care rulează în prim-plan, oferindu-le prioritate față de alte procese de fundal și îmbunătățind performanța și fiabilitatea. - Aflați mai multe despre Tutorialele Android Studio Ce sunt Tutorialele Android Studio: Ediția Kotlin? diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index b429f767..050f9bad 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -1,4 +1,21 @@ + + Узнайте, как создавать простые приложения в Android Studio! Мы скучали по тебе! Давай узнаем что-нибудь новое об Android! @@ -15,23 +32,6 @@ Ошибка при загрузке избранных уроков. Пожалуйста, попробуйте позже. Избранные уроки не найдены. - Идентификатор рекламы [AD_ID] - Позволяет приложению извлекать и использовать идентификатор рекламы, связанный с устройством пользователя, предоставляя персонализированную рекламу, измеряя эффективность рекламы и показывая рекламу на устройствах Android 13 или более поздней версии. - Интернет [INTERNET] - Позволяет приложению устанавливать интернет-соединение для отправки отчетов об ошибках или проверки наличия обновлений. - Отправлять уведомления [POST_NOTIFICATIONS] - Позволяет приложению отображать уведомления на устройствах с Android 13 или более поздней версии. - Доступ к состоянию сети [ACCESS_NETWORK_STATE] - Позволяет приложению проверять сетевое подключение и получать информацию о Wi-Fi, включая статус включения и имена подключенных устройств Wi-Fi. - Доступ к политике уведомлений [ACCESS_NOTIFICATION_POLICY] - Позволяет приложению получать доступ к политике уведомлений устройства и изменять ее, контролируя, как и когда уведомления отображаются пользователю, и предоставляя настраиваемые функции управления уведомлениями. - Выставление счетов [BILLING] - Позволяет приложению использовать библиотеку Google Play Billing для обработки покупок и пожертвований в приложении - Проверить лицензию [CHECK_LICENSE] - Позволяет приложению проверять соблюдение лицензионного соглашения и обеспечивать соблюдение условий лицензирования для защиты интеллектуальной собственности. - Служба переднего плана [FOREGROUND_SERVICE] - Позволяет приложению создавать и использовать службы, работающие на переднем плане, предоставляя им приоритет над другими фоновыми процессами и повышая производительность и надежность. - Подробнее о руководствах Android Studio Что такое Android Studio Tutorials: Kotlin Edition? diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index 530a8aab..1759a091 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -1,4 +1,21 @@ + + Lär dig hur man gör enkla appar i Android Studio! Vi saknade dig! Låt oss lära oss något nytt om Android! @@ -15,23 +32,6 @@ Fel vid inläsning av favoritlektioner. Försök igen senare. Inga favoritlektioner hittades. - Annons-id [AD_ID] - Tillåter appen att hämta och använda annonsidentifieraren som är associerad med användarens enhet, tillhandahålla anpassade annonser, mäta annonseffektivitet och visa annonser på Android 13 eller senare enheter. - Internet [INTERNET] - Tillåter appen att upprätta en internetanslutning för att skicka felrapporter eller söka efter uppdateringar. - Publicera aviseringar [POST_NOTIFICATIONS] - Tillåter appen att visa aviseringar på enheter med Android 13 eller senare. - Åtkomst till nätverksstatus [ACCESS_NETWORK_STATE] - Tillåter appen att kontrollera nätverksanslutning och hämta information om Wi-Fi, inklusive aktiverad status och anslutna Wi-Fi-enhetsnamn. - Åtkomst till aviseringspolicy [ACCESS_NOTIFICATION_POLICY] - Tillåter appen att komma åt och ändra enhetens aviseringspolicy, styra hur och när aviseringar visas för användaren och tillhandahålla anpassade aviseringshanteringsfunktioner. - Fakturering [BILLING] - Tillåter appen att använda Google Play Faktureringsbibliotek för att hantera köp och donationer i appen - Kontrollera licens [CHECK_LICENSE] - Tillåter appen att verifiera sin överensstämmelse med licensavtalet och tillämpa licensvillkor för att skydda immateriella rättigheter. - Förgrundstjänst [FOREGROUND_SERVICE] - Tillåter appen att skapa och använda tjänster som körs i förgrunden, ge dem prioritet över andra bakgrundsprocesser och förbättra prestanda och tillförlitlighet. - Läs mer om Android Studio Tutorials Vad är Android Studio Tutorials: Kotlin Edition? diff --git a/app/src/main/res/values-th-rTH/strings.xml b/app/src/main/res/values-th-rTH/strings.xml index e351a546..c2aed606 100644 --- a/app/src/main/res/values-th-rTH/strings.xml +++ b/app/src/main/res/values-th-rTH/strings.xml @@ -1,4 +1,21 @@ + + เรียนรู้วิธีสร้างแอปง่ายๆ ใน Android Studio! เราคิดถึงคุณ! มาเรียนรู้สิ่งใหม่ๆ เกี่ยวกับ Android กันเถอะ! @@ -15,23 +32,6 @@ เกิดข้อผิดพลาดในการโหลดบทเรียนที่ชื่นชอบ โปรดลองอีกครั้งในภายหลัง ไม่พบบทเรียนที่ชื่นชอบ - รหัสโฆษณา [AD_ID] - อนุญาตให้แอปดึงและใช้ตัวระบุโฆษณาที่เชื่อมโยงกับอุปกรณ์ของผู้ใช้ โดยแสดงโฆษณาที่ปรับให้เหมาะกับแต่ละบุคคล วัดประสิทธิภาพของโฆษณา และแสดงโฆษณาบนอุปกรณ์ Android 13 ขึ้นไป - อินเทอร์เน็ต [INTERNET] - อนุญาตให้แอปสร้างการเชื่อมต่ออินเทอร์เน็ตเพื่อส่งรายงานข้อผิดพลาดหรือตรวจสอบการอัปเดต - โพสต์การแจ้งเตือน [POST_NOTIFICATIONS] - อนุญาตให้แอปแสดงการแจ้งเตือนบนอุปกรณ์ที่มี Android 13 ขึ้นไป - เข้าถึงสถานะเครือข่าย [ACCESS_NETWORK_STATE] - อนุญาตให้แอปตรวจสอบการเชื่อมต่อเครือข่ายและดึงข้อมูลเกี่ยวกับ Wi-Fi รวมถึงสถานะที่เปิดใช้งานและชื่ออุปกรณ์ Wi-Fi ที่เชื่อมต่อ - เข้าถึงนโยบายการแจ้งเตือน [ACCESS_NOTIFICATION_POLICY] - อนุญาตให้แอปเข้าถึงและแก้ไขนโยบายการแจ้งเตือนของอุปกรณ์ ควบคุมวิธีการและเวลาที่การแจ้งเตือนจะแสดงต่อผู้ใช้ และมอบคุณสมบัติการจัดการการแจ้งเตือนที่กำหนดเอง - การเรียกเก็บเงิน [BILLING] - อนุญาตให้แอปใช้ไลบรารีการเรียกเก็บเงินของ Google Play เพื่อจัดการการซื้อและการบริจาคในแอป - ตรวจสอบใบอนุญาต [CHECK_LICENSE] - อนุญาตให้แอปยืนยันการปฏิบัติตามข้อตกลงใบอนุญาตและบังคับใช้เงื่อนไขการอนุญาตเพื่อปกป้องทรัพย์สินทางปัญญา - บริการเบื้องหน้า [FOREGROUND_SERVICE] - อนุญาตให้แอปสร้างและใช้บริการที่ทำงานในเบื้องหน้า โดยให้ความสำคัญเหนือกว่ากระบวนการเบื้องหลังอื่นๆ และปรับปรุงประสิทธิภาพและความน่าเชื่อถือ - เรียนรู้เพิ่มเติมเกี่ยวกับ Android Studio Tutorials Android Studio Tutorials: Kotlin Edition คืออะไร diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index f78c55f7..6afebe07 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -1,4 +1,21 @@ + + Android Studio\'da basit uygulamaların nasıl yapılacağını öğrenin! Seni özledik! Android hakkında yeni bir şeyler öğrenelim! @@ -15,23 +32,6 @@ Favori dersler yüklenirken hata oluştu. Lütfen daha sonra tekrar deneyin. Favori ders bulunamadı. - Reklam kimliği [AD_ID] - Uygulamanın, kullanıcının cihazıyla ilişkili reklam kimliğini almasına ve kullanmasına, kişiselleştirilmiş reklamlar sağlamasına, reklam etkinliğini ölçmesine ve Android 13 veya sonraki sürümleri kullanan cihazlarda reklam göstermesine olanak tanır. - İnternet [INTERNET] - Uygulamanın hata raporları göndermek veya güncellemeleri kontrol etmek için bir internet bağlantısı kurmasına olanak tanır. - Bildirim gönder [POST_NOTIFICATIONS] - Uygulamanın Android 13 veya sonraki sürümleri kullanan cihazlarda bildirim görüntülemesine olanak tanır. - Ağ durumuna erişim [ACCESS_NETWORK_STATE] - Uygulamanın ağ bağlantısını kontrol etmesine ve Wi-Fi hakkında bilgi almasına, etkin durumu ve bağlı Wi-Fi cihaz adları da dahil olmak üzere, izin verir. - Bildirim politikasına erişim [ACCESS_NOTIFICATION_POLICY] - Uygulamanın cihazın bildirim politikasına erişmesine ve bu politikayı değiştirmesine, bildirimlerin kullanıcıya nasıl ve ne zaman görüntüleneceğini kontrol etmesine ve özel bildirim yönetimi özellikleri sunmasına olanak tanır. - Faturalandırma [BILLING] - Uygulamanın, uygulama içi satın alma ve bağış işlemlerini gerçekleştirmek için Google Play Billing Library\'yi kullanmasına izin verir - Lisansı kontrol et [CHECK_LICENSE] - Uygulamanın, lisans anlaşmasına uygunluğunu doğrulamasına ve fikri mülkiyeti korumak için lisans koşullarını uygulamasına olanak tanır. - Ön plan hizmeti [FOREGROUND_SERVICE] - Uygulamanın, ön planda çalışan hizmetler oluşturmasına ve kullanmasına, diğer arka plan süreçlerine göre öncelik vermesine ve performansı ve güvenilirliği artırmasına olanak tanır. - Android Studio Tutorials hakkında daha fazla bilgi edinin Android Studio Tutorials: Kotlin Edition nedir? diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 7370ef5f..98713868 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -1,4 +1,21 @@ + + Дізнайтеся, як створювати прості програми в Android Studio! Ми сумували за вами! Давайте дізнаємося щось нове про Android! @@ -15,23 +32,6 @@ Помилка завантаження улюблених уроків. Спробуйте ще раз пізніше. Улюблені уроки не знайдено. - Ідентифікатор реклами [AD_ID] - Дозволяє програмі отримувати та використовувати ідентифікатор реклами, пов’язаний із пристроєм користувача, надаючи персоналізовану рекламу, вимірюючи ефективність реклами та показуючи рекламу на пристроях з Android 13 або новішої версії. - Інтернет [INTERNET] - Дозволяє програмі встановлювати підключення до Інтернету для надсилання звітів про помилки або перевірки наявності оновлень. - Публікувати сповіщення [POST_NOTIFICATIONS] - Дозволяє програмі відображати сповіщення на пристроях з Android 13 або новішої версії. - Доступ до стану мережі [ACCESS_NETWORK_STATE] - Дозволяє програмі перевіряти підключення до мережі та отримувати інформацію про Wi-Fi, зокрема статус увімкнення та імена підключених пристроїв Wi-Fi. - Доступ до політики сповіщень [ACCESS_NOTIFICATION_POLICY] - Дозволяє програмі отримувати доступ і змінювати політику сповіщень пристрою, контролюючи, як і коли сповіщення відображаються для користувача, і надаючи спеціальні функції керування сповіщеннями. - Виставлення рахунків [BILLING] - Дозволяє програмі використовувати бібліотеку Google Play Billing для обробки покупок і пожертвувань у програмі - Перевірити ліцензію [CHECK_LICENSE] - Дозволяє програмі перевірити її відповідність ліцензійній угоді та забезпечити дотримання ліцензійних умов для захисту інтелектуальної власності. - Служба переднього плану [FOREGROUND_SERVICE] - Дозволяє програмі створювати та використовувати служби, які працюють на передньому плані, надаючи їм пріоритет над іншими фоновими процесами та підвищуючи продуктивність і надійність. - Дізнайтеся більше про Android Studio Tutorials Що таке Android Studio Tutorials: Kotlin Edition? diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml index 2888941d..a07c9a47 100644 --- a/app/src/main/res/values-v31/colors.xml +++ b/app/src/main/res/values-v31/colors.xml @@ -1,4 +1,21 @@ + + @android:color/system_accent1_100 @android:color/system_neutral1_700 diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml deleted file mode 100644 index 464fa523..00000000 --- a/app/src/main/res/values-v31/themes.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f701cb3e..519924a2 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,4 +1,21 @@ + + 學習如何在 Android Studio 中製作簡單的應用程式! 我們想念你! 讓我們學習一些關於 Android 的新東西! @@ -15,23 +32,6 @@ 載入最愛課程時發生錯誤。請稍後再試一次。 找不到最愛課程。 - 廣告 ID [AD_ID] - 允許應用程式檢索和使用與使用者裝置相關聯的廣告識別碼,提供個人化廣告、衡量廣告效果以及在 Android 13 或更高版本的裝置上顯示廣告。 - 網際網路 [INTERNET] - 允許應用程式建立網際網路連線以傳送錯誤報告或檢查更新。 - 發布通知 [POST_NOTIFICATIONS] - 允許應用程式在 Android 13 或更高版本的裝置上顯示通知。 - 存取網路狀態 [ACCESS_NETWORK_STATE] - 允許應用程式檢查網路連線並檢索有關 Wi-Fi 的資訊,包括啟用狀態和已連線 Wi-Fi 裝置名稱。 - 存取通知策略 [ACCESS_NOTIFICATION_POLICY] - 允許應用程式存取和修改裝置的通知策略,控制向使用者顯示通知的方式和時間,並提供自訂的通知管理功能。 - 帳單 [BILLING] - 允許應用程式使用 Google Play Billing Library 來處理應用程式內購買和捐款 - 檢查授權 [CHECK_LICENSE] - 允許應用程式驗證其是否符合授權協議並強制執行授權條款以保護智慧財產權。 - 前景服務 [FOREGROUND_SERVICE] - 允許應用程式建立和使用在前台運行的服務,使其優先於其他後台進程,並提高效能和可靠性。 - 深入瞭解 Android Studio Tutorials 什麼是 Android Studio Tutorials:Kotlin Edition? diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index e8366eb0..238e63e0 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1,4 +1,21 @@ + + @string/home @@ -10,45 +27,4 @@ studio_bot favorites - - - @string/bulgarian - @string/english - @string/french - @string/german - @string/hindi - @string/hungarian - @string/indonesian - @string/italian - @string/japanese - @string/polish - @string/brazilian_portuguese - @string/romanian - @string/russian - @string/spanish - @string/thai - @string/turkish - @string/ukrainian - @string/traditional_chinese - - - bg - en - fr - de - hi - hu - in - it - ja - pl - pt - ro - ru - es - th - tr - uk - zh-Hant - \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index ddd38cb0..2b8900c5 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,4 +1,21 @@ + + #C1E8FF #454749 diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml new file mode 100644 index 00000000..79df125b --- /dev/null +++ b/app/src/main/res/values/plurals.xml @@ -0,0 +1,24 @@ + + + + + + %1$d message + %1$d messages + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c19e8efe..ebf810d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,36 +1,54 @@ - + + Learn how to make simple apps in Android Studio! We missed you! Let\'s learn something new about Android! Try again Get the app and see the full lesson: %1$s + Look what I learned %1$s + You can download the app from here No lessons found. Database error encountered. Please try the following steps:\n\n1. Restart the app.\n2. If the issue persists, clear app data from Settings > Apps > Android Studio Tutorials > Storage.\n3. Contact support if the problem continues. Studio Bot Type a message… + Send + Back to conversations + New conversation + No messages yet + Last updated %1$s + Open conversation %1$s + Delete conversation + No conversations yet + Start a conversation to ask Studio Bot for help, explanations, or code guidance. + Start your first conversation + Copy code + + Studio Bot Terms + To continue, you must accept that Studio Bot can generate AI responses that may be inaccurate. Verify important information before using it in production projects. + Accept Favorites + Favorites will be available here once the migration is completed. Error loading favorite lessons. Please try again later. No favorite lessons found. - - Ad id [AD_ID] - Allows the app to retrieve and use the advertising identifier associated with the user\'s device, providing personalized ads, measuring ad effectiveness, and showing ads on Android 13 or later devices. - Internet [INTERNET] - Allows the app to establish an internet connection to send error reports or check for updates. - Post notifications [POST_NOTIFICATIONS] - Allows the app to display notifications on the devices with Android 13 or later. - Access network state [ACCESS_NETWORK_STATE] - Allows the app to check network connectivity and retrieve information about Wi-Fi, including enabled status and connected Wi-Fi device names. - Access notification policy [ACCESS_NOTIFICATION_POLICY] - Allows the app to access and modify the device\'s notification policy, controlling how and when notifications are displayed to the user and providing custom notification management features. - Billing [BILLING] - Allows the app to use the Google Play Billing Library to handle in-app purchases and donations - Check license [CHECK_LICENSE] - Allows the app to verify its compliance with the license agreement and enforce licensing terms to protect intellectual property. - Foreground service [FOREGROUND_SERVICE] - Allows the app to create and use services that run in the foreground, giving them priority over other background processes and improving performance and reliability. + Written by %1$s Learn more about Android Studio Tutorials @@ -52,4 +70,4 @@ You can check for updates to Android Studio Tutorials: Kotlin Edition by going to the settings menu in the app and selecting the \"Check for updates\" option. How can I support the development of Android Studio Tutorials: Kotlin Edition? ou can support the development of Android Studio Tutorials: Kotlin Edition by leaving a positive review on the Google Play Store, sharing the app with friends and colleagues, and supporting the developers through the \"Share\" option in the settings menu. - \ No newline at end of file + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml deleted file mode 100644 index a2475e5c..00000000 --- a/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/untranslatable_strings.xml b/app/src/main/res/values/untranslatable_strings.xml index 89cfdb28..327ffbe0 100644 --- a/app/src/main/res/values/untranslatable_strings.xml +++ b/app/src/main/res/values/untranslatable_strings.xml @@ -1,26 +1,23 @@ + + Android Studio Tutorials - %1$s - Български - English - Français - Magyar - Deutsch - हिन्दी - Bahasa Indonesia - Italiano - 日本語 - Polski - Română - Русский - Español - Türkçe - Українська - 中文 - ไทย - Português (Brasil) - Copyright ©2022-2025, D4rK - - Feedback for %1$s + Android Studio Tutorials for Android + Copyright ©2022-2026, Mihai-Cristian Condrea \ No newline at end of file diff --git a/app/src/main/res/xml-v25/shortcuts.xml b/app/src/main/res/xml-v25/shortcuts.xml index b7e4c661..dd6b1892 100644 --- a/app/src/main/res/xml-v25/shortcuts.xml +++ b/app/src/main/res/xml-v25/shortcuts.xml @@ -1,4 +1,21 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts index f1fda7a5..0d77e99f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,12 +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 . + */ + plugins { - alias(notation = libs.plugins.androidApplication) apply false - alias(notation = libs.plugins.androidLibrary) apply false - alias(notation = libs.plugins.jetbrainsKotlinAndroid) apply false - alias(notation = libs.plugins.compose.compiler) apply false - alias(notation = libs.plugins.jetbrainsKotlinParcelize) apply false + alias(notation = libs.plugins.android.application) apply false + alias(notation = libs.plugins.kotlin.compose) apply false alias(notation = libs.plugins.kotlin.serialization) apply false - alias(notation = libs.plugins.googlePlayServices) apply false - alias(notation = libs.plugins.googleFirebase) apply false - alias(notation = libs.plugins.devToolsKsp) apply false - alias(notation = libs.plugins.about.libraries) apply true + alias(notation = libs.plugins.kotlin.parcelize) apply false + alias(notation = libs.plugins.google.mobile.services) apply false + alias(notation = libs.plugins.google.devtools.ksp) apply false + alias(notation = libs.plugins.firebase.crashlytics) apply false + alias(notation = libs.plugins.firebase.performance) apply false + alias(notation = libs.plugins.about.libraries) apply false + alias(notation = libs.plugins.mannodermaus.android.junit5) apply false } \ No newline at end of file diff --git a/docs/MIGRATION_DOC_3_CHAPTERS.md b/docs/MIGRATION_DOC_3_CHAPTERS.md new file mode 100644 index 00000000..6a1c6741 --- /dev/null +++ b/docs/MIGRATION_DOC_3_CHAPTERS.md @@ -0,0 +1,65 @@ +# Migration Doc (3 Chapters) + +This document tracks migration from the legacy `data/ui/utils` app structure to the template architecture based on: + +- `com.d4rk.androidtutorials.app.*` for feature modules. +- `com.d4rk.androidtutorials.core.*` for reusable core modules and DI. +- `com.d4rk.android.libs.apptoolkit` as the foundation for state, navigation, and platform integrations. + +## Chapter 1 — Stabilize base project and app identity + +### Implemented +- The manifest uses `com.d4rk.androidtutorials.AndroidStudioTutorials` as the only `Application` entry point. +- Manifest components use fully qualified class names for critical entries (`MainActivity`, `LessonActivity`). +- Koin initialization is centralized through `initializeKoin(context)` from `AndroidStudioTutorials`. + +### Validation checklist +- [x] Project wiring points to `AndroidStudioTutorials`. +- [x] No relative manifest reference is used for critical app entry components. + +--- + +## Chapter 2 — Refactor listing data layer to the new endpoint + +### Implemented +- Listing DTOs follow the Android Studio Tutorials JSON shape with snake case via `@SerialName`: + - `ListingLessonsResponseDto(data: List)` + - `ListingLessonDto(lesson_id, lesson_title, lesson_description, lesson_type, thumbnail_image_url, square_image_url, lesson_tags, deep_link_path)` +- Listing uses a Ktor-powered remote data source with JSON decoding. +- Endpoint usage is centralized with a single source of truth constant: + - `AndroidStudioTutorialsApiEndpoints.HOME_LESSONS_RELEASE_EN` +- Mapping keeps DTOs out of UI: + - `ListingLessonsResponseDto -> List` +- Repository/use case return `Flow>` with `AppErrors` mapping. + +### Validation checklist +- [x] Listing endpoint is served from `core.utils.constants.api`. +- [x] DTOs match snake_case payload fields. +- [x] UI consumes mapped models only (no DTO leakage). +- [x] Use case emits `Flow>`. + +--- + +## Chapter 3 — Refactor listing UI to AppToolkit patterns and mirror details + +### Implemented +- Listing follows split architecture: + - `ui/views` composables + - `ui` screen + view model + - `ui/contract` events/actions + - `ui/state` screen model +- Listing view model follows AppToolkit flow conventions: + - `init { onEvent(LoadLessons) }` + - `flowOn(dispatchers.io)` + - `onStart { setLoading() }` + - `catchReport(...)` + - terminal states: `Success`, `NoData`, `Error` +- Lesson/ad item rendering is driven by `LessonConstants` types. +- Listing click dispatches action; navigation is handled by screen host (`ListingRoute`) outside item composables. +- Details feature follows the same separation (DTO -> mapper -> domain -> repository -> use case -> view model -> UI). + +### Validation checklist +- [x] Listing pipeline does not depend on legacy `UiHomeLesson`. +- [x] Lesson/ad type checks are centralized around `LessonConstants`. +- [x] Navigation is action-driven from view model contract. +- [x] Details uses matching layered architecture. diff --git a/gradle.properties b/gradle.properties index 9a676734..b81259e1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,18 +1,26 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. +# +# 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 . +# org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app"s APK +# Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": @@ -20,5 +28,4 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true -android.enableJetifier=true \ No newline at end of file +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..e3c5c469 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -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 . +# + +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/0b98aec810298c2c1d7fdac5dac37910/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/658299a896470fbb3103ba3a430ee227/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/23adb857f3cb3cbe28750bc7faa7abc0/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3259f52b..f26b47ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,31 +1,94 @@ [versions] -agp = "8.8.1" -aboutlibraries = "11.3.0" -coilSvg = "3.1.0" +androidGradlePlugin = "9.1.0" +androidxRoom = "2.8.4" +googleMobileServices = "4.4.4" +googleDevtoolsKsp = "2.3.4" +firebaseCrashlyticsPlugin = "3.0.6" +firebasePerformancePlugin = "2.0.2" +mannodermausPlugin = "2.0.1" +kotlin = "2.3.10" +ktor = "3.4.1" +koin = "4.1.1" +aboutLibraries = "14.0.0-b02" composeCodeEditor = "2.0.3" -roomKtx = "2.6.1" -kotlin = "2.1.0" -google-devtools-ksp = "2.1.0-1.0.29" -google-services = "4.4.2" -google-firebase-crashlytics = "3.0.3" -generativeai = "0.9.0" +androidxComposeUiTestManifest = "1.10.4" +androidxEspressoCore = "3.7.0" +androidxTestTruth = "1.7.0" +testJupiter = "6.0.3" +testKotlinCoroutines = "1.10.2" +testMockk = "1.14.9" +testRobolectric = "4.16.1" +testSlf4jSimple = "2.0.17" +testTurbine = "1.2.1" [libraries] -coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilSvg" } compose-code-editor = { module = "com.github.qawaz:compose-code-editor", version.ref = "composeCodeEditor" } -androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomKtx" } -androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" } -androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomKtx" } -generativeai = { module = "com.google.ai.client.generativeai:generativeai", version.ref = "generativeai" } + +# Room +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidxRoom" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidxRoom" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidxRoom" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "androidxRoom" } + +# Firebase +firebase-ai = { module = "com.google.firebase:firebase-ai" } + +# Testing +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "androidxComposeUiTestManifest" } +androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspressoCore" } +androidx-test-truth = { module = "androidx.test.ext:truth", version.ref = "androidxTestTruth" } +koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } +test-koin-junit5 = { module = "io.insert-koin:koin-test-junit5", version.ref = "koin" } +test-junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "testJupiter" } +test-junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "testJupiter" } +test-junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "testJupiter" } +test-kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "testKotlinCoroutines" } +test-mockk = { module = "io.mockk:mockk", version.ref = "testMockk" } +test-robolectric = { module = "org.robolectric:robolectric", version.ref = "testRobolectric" } +test-slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "testSlf4jSimple" } +test-turbine = { module = "app.cash.turbine:turbine", version.ref = "testTurbine" } +test-mockk-android = { module = "io.mockk:mockk-android", version.ref = "testMockk" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } [plugins] -androidApplication = { id = "com.android.application", version.ref = "agp" } -androidLibrary = { id = "com.android.library", version.ref = "agp" } -jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -googlePlayServices = { id = "com.google.gms.google-services", version.ref = "google-services" } -googleFirebase = { id = "com.google.firebase.crashlytics", version.ref = "google-firebase-crashlytics" } -compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -devToolsKsp = { id = "com.google.devtools.ksp", version.ref = "google-devtools-ksp" } +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -jetbrainsKotlinParcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } -about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" } \ No newline at end of file +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +google-mobile-services = { id = "com.google.gms.google-services", version.ref = "googleMobileServices" } +google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "googleDevtoolsKsp" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" } +firebase-performance = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerformancePlugin" } +about-libraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } +mannodermaus-android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "mannodermausPlugin" } + +[bundles] +# For local tests on the JVM (test sourceSet) +unitTest = [ + "androidx-test-truth", + "ktor-client-mock", + "test-junit-jupiter-api", + "test-junit-jupiter-params", + "test-koin-junit5", + "test-kotlinx-coroutines", + "test-mockk", + "test-robolectric", + "test-slf4j-simple", + "test-turbine", +] + +unitTestRuntime = ["test-junit-jupiter-engine"] + +# For instrumentation tests on an Android device (androidTest sourceSet) +instrumentationTest = [ + "androidx-compose-ui-test-manifest", + "androidx-test-espresso-core", + "androidx-test-truth", + "koin-androidx-compose", + "test-junit-jupiter-api", + "test-junit-jupiter-params", + "test-kotlinx-coroutines", + "test-mockk-android", + "test-turbine", + "androidx-room-testing", +] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bf477f2e..c0fae911 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,23 @@ -#Fri Feb 14 13:50:20 EET 2025 +# +# 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 . +# + +#Wed Feb 25 15:36:58 EET 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 index 4f906e0c..8afc2c2f --- a/gradlew +++ b/gradlew @@ -1,5 +1,22 @@ #!/usr/bin/env sh +# +# 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 . +# + # # Copyright 2015 the original author or authors. # diff --git a/settings.gradle.kts b/settings.gradle.kts index f5eee7c7..03121507 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,13 +1,39 @@ +/* + * 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 . + */ + pluginManagement { repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } gradlePluginPortal() - google() mavenCentral() maven { setUrl("https://jitpack.io") } } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} @Suppress("UnstableApiUsage") dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)