├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── sagar │ │ └── fluenty │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ └── audio.mp3 │ ├── java │ │ └── com │ │ │ └── sagar │ │ │ └── fluenty │ │ │ ├── MainActivity.kt │ │ │ └── ui │ │ │ ├── managers │ │ │ ├── AudioPlayerManager.kt │ │ │ ├── AudioRecorderManager.kt │ │ │ ├── EncryptedSharedPreferencesManager.kt │ │ │ ├── GeminiApiManager.kt │ │ │ ├── SpeechRecognizerManager.kt │ │ │ └── TextToSpeechManager.kt │ │ │ ├── screen │ │ │ ├── audio │ │ │ │ ├── AudioRecordScreen.kt │ │ │ │ └── AudioRecordScreenViewModel.kt │ │ │ ├── conversation │ │ │ │ ├── ConversationScreen.kt │ │ │ │ └── ConversationScreenViewModel.kt │ │ │ ├── home │ │ │ │ └── HomeScreen.kt │ │ │ └── settings │ │ │ │ ├── SettingsScreen.kt │ │ │ │ └── SettingsScreenViewModel.kt │ │ │ ├── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ │ └── utils │ │ │ ├── ComposeUtils.kt │ │ │ └── ErrorResponse.kt │ └── res │ │ ├── drawable │ │ ├── english.png │ │ ├── english_literature.jpg │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── logo.jpg │ │ ├── mic.png │ │ └── pronunciation.jpg │ │ ├── mipmap-anydpi-v26 │ │ └── ic_launcher.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── sagar │ └── fluenty │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fluenty: Your AI-Powered English Assistant 2 | 3 | ## **Key Features: ** 4 | 5 | * **English Speaking Practice:** Enhance your fluency and confidence with AI feedback and suggestions. Start a normal conversation and AI will make sure your grammar is correct. 6 | 7 | 8 | https://github.com/user-attachments/assets/b5f7e629-c9c0-445a-b06e-b9825c7d0709 9 | 10 | 11 | * **Pronunciation Practice:** Improve your pronunciation with AI-powered feedback, Random words will be suggested to pronounce and AI will give you feedback with a score. 12 | 13 | 14 | https://github.com/user-attachments/assets/2005223d-a4a6-4258-99f2-1915c41e00f5 15 | 16 | 17 | * **Multi-Model Support:** Option to select different Gemini Models from Setting. Note that Different models can generate whole different responses. 18 | 19 | > NOTE: I am using Gemini API to generate responses, and the correctness of the responses is completely dependent on the Model. 20 | 21 | ## **Getting Started:** 22 | 23 | 1. **Download:** Download the latest APK from this [Link](https://github.com/Sagar0-0/Fluenty/releases/download/1.0.0/Fluenty.apk). 24 | 2. **Install:** Install the APK on your Android device. 25 | 3. **Add API Key:** Go to settings and Add your Gemini test API key(Generate if you don't have it). 26 | 27 | ## **Usage:** 28 | 29 | 1. **Launch the App:** Open the Fluenty app. 30 | 2. **Start Practicing:** Begin practicing your English speaking and pronunciation skills. 31 | 32 | 33 | ## **Technical Stack/Libraries/APIs:** 34 | 35 | * **Jetpack Compose:** Modern UI toolkit for building Android apps. 36 | * **MediaPlayer:** MediaPlayer class can be used to control the playback of audio/ video files and streams. 37 | * **MediaRecorder:** Used to record audio and video. 38 | * **EncryptedSharedPreferences:** An implementation of SharedPreferences that encrypts keys and values. 39 | * **GenerativeModel:** A facilitator for a given multimodal model. (Model used: "Gemini-1.5-pro-002") 40 | * **SpeechRecognizer:** Recognizes User Speech and returns a String Result. 41 | * **TextToSpeech:** Uses Android TTS service and reads the AI responses. 42 | * **Coroutines:** Asynchronous programming framework for non-blocking operations. 43 | 44 | ## **Contributing:** 45 | 46 | We welcome contributions to improve Fluenty. Feel free to fork the repository and submit pull requests. 47 | 48 | **License:** 49 | 50 | This project is licensed under the MIT License. 51 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties 2 | 3 | plugins { 4 | alias(libs.plugins.android.application) 5 | alias(libs.plugins.kotlin.android) 6 | alias(libs.plugins.kotlin.compose) 7 | } 8 | 9 | android { 10 | namespace = "com.sagar.fluenty" 11 | compileSdk = 35 12 | 13 | defaultConfig { 14 | applicationId = "com.sagar.fluenty" 15 | minSdk = 24 16 | targetSdk = 34 17 | versionCode = 1 18 | versionName = "1.0" 19 | 20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 21 | } 22 | 23 | buildTypes { 24 | release { 25 | isMinifyEnabled = false 26 | proguardFiles( 27 | getDefaultProguardFile("proguard-android-optimize.txt"), 28 | "proguard-rules.pro" 29 | ) 30 | } 31 | debug { 32 | buildConfigField( 33 | "String", "GEMINI_API_KEY_DEBUG", 34 | gradleLocalProperties( 35 | projectRootDir = rootDir, 36 | providers = rootProject.providers 37 | ).getProperty("GEMINI_API_KEY_DEBUG") 38 | ) 39 | } 40 | } 41 | compileOptions { 42 | sourceCompatibility = JavaVersion.VERSION_11 43 | targetCompatibility = JavaVersion.VERSION_11 44 | } 45 | kotlinOptions { 46 | jvmTarget = "11" 47 | } 48 | buildFeatures { 49 | compose = true 50 | buildConfig = true 51 | } 52 | } 53 | 54 | dependencies { 55 | 56 | implementation(libs.androidx.core.ktx) 57 | implementation(libs.androidx.lifecycle.runtime.ktx) 58 | implementation(libs.androidx.activity.compose) 59 | implementation(platform(libs.androidx.compose.bom)) 60 | implementation(libs.androidx.ui) 61 | implementation(libs.androidx.ui.graphics) 62 | implementation(libs.androidx.ui.tooling.preview) 63 | implementation(libs.androidx.material3) 64 | 65 | implementation(libs.androidx.navigation.compose) 66 | 67 | testImplementation(libs.junit) 68 | androidTestImplementation(libs.androidx.junit) 69 | androidTestImplementation(libs.androidx.espresso.core) 70 | androidTestImplementation(platform(libs.androidx.compose.bom)) 71 | androidTestImplementation(libs.androidx.ui.test.junit4) 72 | debugImplementation(libs.androidx.ui.tooling) 73 | debugImplementation(libs.androidx.ui.test.manifest) 74 | 75 | // Ai 76 | implementation(libs.generativeai) 77 | 78 | 79 | //SharedPreference 80 | implementation(libs.androidx.security.crypto) 81 | 82 | // Gson 83 | implementation(libs.gson) 84 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/sagar/fluenty/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.sagar.fluenty", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/assets/audio.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/assets/audio.mp3 -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.navigation.compose.NavHost 8 | import androidx.navigation.compose.composable 9 | import androidx.navigation.compose.rememberNavController 10 | import com.sagar.fluenty.ui.screen.audio.AudioRecordScreen 11 | import com.sagar.fluenty.ui.screen.conversation.ConversationScreen 12 | import com.sagar.fluenty.ui.screen.home.HomeScreen 13 | import com.sagar.fluenty.ui.screen.settings.SettingsScreen 14 | import com.sagar.fluenty.ui.theme.FluentyTheme 15 | 16 | class MainActivity : ComponentActivity() { 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | enableEdgeToEdge() 20 | setContent { 21 | FluentyTheme { 22 | val navController = rememberNavController() 23 | NavHost( 24 | navController = navController, 25 | startDestination = "HOME" 26 | ) { 27 | composable("HOME") { 28 | HomeScreen( 29 | onConversationClick = { 30 | navController.navigate("CONVERSATION") 31 | }, 32 | onAudioClick = { 33 | navController.navigate("AUDIO") 34 | }, 35 | onSettingsClick = { 36 | navController.navigate("SETTINGS") 37 | } 38 | ) 39 | } 40 | composable("CONVERSATION") { 41 | ConversationScreen { 42 | navController.navigateUp() 43 | } 44 | } 45 | composable("AUDIO") { 46 | AudioRecordScreen { 47 | navController.navigateUp() 48 | } 49 | } 50 | composable("SETTINGS") { 51 | SettingsScreen { 52 | navController.navigateUp() 53 | } 54 | } 55 | } 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/managers/AudioPlayerManager.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.managers 2 | 3 | import android.content.Context 4 | import android.media.MediaPlayer 5 | import androidx.core.net.toUri 6 | import java.io.File 7 | 8 | interface AudioPlayerManager { 9 | fun initListener(listener: AudioPlayerListener) 10 | fun play(file: File) 11 | fun stop() 12 | } 13 | 14 | class AudioPlayerManagerImpl( 15 | private val context: Context 16 | ) : AudioPlayerManager { 17 | 18 | private var listener: AudioPlayerListener? = null 19 | override fun initListener(listener: AudioPlayerListener) { 20 | this.listener = listener 21 | } 22 | 23 | private var player: MediaPlayer? = null 24 | 25 | private fun createMediaPlayer(file: File): MediaPlayer { 26 | return MediaPlayer.create(context, file.toUri()) 27 | } 28 | 29 | override fun play(file: File) { 30 | try { 31 | createMediaPlayer(file).apply { 32 | start() 33 | player = this 34 | 35 | listener?.onPlayerStarted() 36 | 37 | player!!.setOnCompletionListener { 38 | listener?.onCompletePlaying() 39 | this@AudioPlayerManagerImpl.stop() 40 | } 41 | } 42 | } catch (e: Exception) { 43 | listener?.onErrorPlaying() 44 | } 45 | } 46 | 47 | override fun stop() { 48 | player?.apply { 49 | stop() 50 | release() 51 | } 52 | player = null 53 | 54 | listener?.onStopPlayer() 55 | } 56 | 57 | } 58 | 59 | interface AudioPlayerListener { 60 | fun onPlayerStarted() 61 | fun onErrorPlaying() 62 | fun onStopPlayer() 63 | fun onCompletePlaying() 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/managers/AudioRecorderManager.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.managers 2 | 3 | import android.content.Context 4 | import android.media.MediaRecorder 5 | import android.os.Build 6 | import android.util.Log 7 | import java.io.File 8 | import java.io.FileOutputStream 9 | import java.util.UUID 10 | 11 | interface AudioRecorderManager { 12 | fun initListener(listener: AudioRecorderListener) 13 | fun start(context: Context) 14 | fun stop() 15 | fun cancel() 16 | } 17 | 18 | class AudioRecorderManagerImpl( 19 | private val context: Context 20 | ) : AudioRecorderManager { 21 | 22 | private var listener: AudioRecorderListener? = null 23 | 24 | private var recorder: MediaRecorder? = null 25 | 26 | private var outputFile: File? = null 27 | 28 | override fun initListener(listener: AudioRecorderListener) { 29 | this.listener = listener 30 | } 31 | 32 | private fun createMediaRecorder(): MediaRecorder { 33 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 34 | MediaRecorder(context) 35 | } else { 36 | MediaRecorder() 37 | } 38 | } 39 | 40 | override fun start(context: Context) { 41 | try { 42 | outputFile = File(context.cacheDir, "${UUID.randomUUID()}.3gp") 43 | createMediaRecorder().apply { 44 | setAudioSource(MediaRecorder.AudioSource.MIC) 45 | setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP) 46 | setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB) 47 | setOutputFile(FileOutputStream(outputFile).fd) 48 | 49 | prepare() 50 | start() 51 | recorder = this 52 | 53 | listener?.onRecordingStarted() 54 | } 55 | } catch (e: Exception) { 56 | listener?.onErrorStarting(e) 57 | } 58 | } 59 | 60 | override fun stop() { 61 | try { 62 | recorder?.apply { 63 | stop() 64 | reset() 65 | } 66 | recorder = null 67 | 68 | outputFile?.let { listener?.onStopRecording(it) } 69 | } catch (e: Exception) { 70 | Log.e("TAG", "stop: ${e.message}") 71 | } 72 | } 73 | 74 | override fun cancel() { 75 | recorder?.apply { 76 | stop() 77 | reset() 78 | } 79 | } 80 | } 81 | 82 | interface AudioRecorderListener { 83 | fun onRecordingStarted() 84 | fun onErrorStarting(e: Exception) 85 | fun onStopRecording(file: File) 86 | fun onCancelRecording() 87 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/managers/EncryptedSharedPreferencesManager.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.managers 2 | 3 | import android.content.Context 4 | import androidx.security.crypto.EncryptedSharedPreferences 5 | import androidx.security.crypto.MasterKeys 6 | 7 | interface EncryptedSharedPreferencesManager { 8 | fun save(key: String, value: String) 9 | fun get(key: String): String? 10 | } 11 | 12 | class EncryptedSharedPreferencesManagerImpl( 13 | context: Context, 14 | ) : EncryptedSharedPreferencesManager { 15 | private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) 16 | 17 | // Step 2: Initialize/open an instance of EncryptedSharedPreferences 18 | private val sharedPreferences = EncryptedSharedPreferences.create( 19 | "PreferencesFilename", 20 | masterKeyAlias, 21 | context.applicationContext, 22 | EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, 23 | EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM 24 | ) 25 | 26 | override fun save(key: String, value: String) { 27 | sharedPreferences.edit() 28 | .putString(key, value) 29 | .apply() 30 | } 31 | 32 | override fun get(key: String): String? { 33 | return sharedPreferences.getString(key, null) 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/managers/GeminiApiManager.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.managers 2 | 3 | import android.util.Log 4 | import com.google.ai.client.generativeai.GenerativeModel 5 | import com.google.ai.client.generativeai.type.Content 6 | import com.google.ai.client.generativeai.type.ServerException 7 | import com.google.ai.client.generativeai.type.content 8 | import com.sagar.fluenty.ui.utils.extractErrorMessage 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.withContext 11 | import java.io.File 12 | import java.io.IOException 13 | 14 | interface GeminiApiChatManager { 15 | fun initListener(listener: GeminiApiListener) 16 | suspend fun generateResponse(prompt: String) 17 | } 18 | 19 | interface GeminiApiAudioManager { 20 | fun initListener(listener: GeminiApiListener) 21 | suspend fun initialResponse(prompt: String) 22 | suspend fun generateResponseFromAudio(file: File) 23 | } 24 | 25 | 26 | class GeminiApiAudioManagerImpl( 27 | model: GenerativeModel 28 | ) : GeminiApiAudioManager { 29 | 30 | private var listener: GeminiApiListener? = null 31 | 32 | override fun initListener(listener: GeminiApiListener) { 33 | this.listener = listener 34 | } 35 | 36 | private val audioChatHistory = mutableListOf() 37 | private val audioChat = model.startChat(audioChatHistory) 38 | 39 | 40 | override suspend fun initialResponse(prompt: String) { 41 | return withContext(Dispatchers.IO) { 42 | val content = content { 43 | text(prompt) 44 | } 45 | audioChatHistory.add(content) 46 | try { 47 | val response = audioChat.sendMessage(content) 48 | if (response.text != null) { 49 | listener?.onResponseGenerated(response.text ?: "") 50 | } else { 51 | listener?.onErrorGeneratingResponse(Exception("Null response")) 52 | } 53 | } catch (e: ServerException) { 54 | Log.e("TAG", "getResponse: $e") 55 | listener?.onErrorGeneratingResponse( 56 | Exception(extractErrorMessage(e.message?:"")) 57 | ) 58 | } catch (e: Exception) { 59 | Log.e("TAG", "getResponse: $e") 60 | listener?.onErrorGeneratingResponse( 61 | Exception("Unexpected Error occurred") 62 | ) 63 | } 64 | } 65 | } 66 | 67 | override suspend fun generateResponseFromAudio(file: File) { 68 | return withContext(Dispatchers.IO) { 69 | val bytes = readAudioFromAssets(file) 70 | val content = content { 71 | bytes?.let { blob("audio/mp3", it) } 72 | text("Understand the audio and respond accordingly.") 73 | } 74 | audioChatHistory.add(content) 75 | try { 76 | val response = audioChat.sendMessage(content) 77 | if (response.text != null) { 78 | listener?.onResponseGenerated(response.text ?: "") 79 | } else { 80 | listener?.onErrorGeneratingResponse(Exception("Null response")) 81 | } 82 | } catch (e: ServerException) { 83 | Log.e("TAG", "getResponse: $e") 84 | listener?.onErrorGeneratingResponse( 85 | Exception(extractErrorMessage(e.message?:"")) 86 | ) 87 | } catch (e: Exception) { 88 | Log.e("TAG", "getResponse: $e") 89 | listener?.onErrorGeneratingResponse( 90 | Exception("Unexpected Error occurred") 91 | ) 92 | } 93 | } 94 | } 95 | 96 | private fun readAudioFromAssets(file: File): ByteArray? { 97 | return try { 98 | val inputStream = file.inputStream() 99 | val buffer = ByteArray(inputStream.available()) 100 | inputStream.read(buffer) 101 | inputStream.close() 102 | buffer 103 | } catch (e: IOException) { 104 | e.printStackTrace() 105 | null 106 | } 107 | } 108 | 109 | } 110 | 111 | class GeminiApiChatManagerImpl( 112 | model: GenerativeModel 113 | ) : GeminiApiChatManager { 114 | private var listener: GeminiApiListener? = null 115 | 116 | override fun initListener(listener: GeminiApiListener) { 117 | this.listener = listener 118 | } 119 | 120 | private val chatHistory = mutableListOf() 121 | private val chat = model.startChat(chatHistory) 122 | 123 | override suspend fun generateResponse(prompt: String) { 124 | return withContext(Dispatchers.IO) { 125 | val content = content { 126 | text(prompt) 127 | } 128 | chatHistory.add(content) 129 | try { 130 | val response = chat.sendMessage(content) 131 | if (response.text != null) { 132 | listener?.onResponseGenerated(response.text ?: "") 133 | } else { 134 | listener?.onErrorGeneratingResponse(Exception("Null response")) 135 | } 136 | } catch (e: ServerException) { 137 | Log.e("TAG", "getResponse: $e") 138 | listener?.onErrorGeneratingResponse( 139 | Exception(extractErrorMessage(e.message?:"")) 140 | ) 141 | } catch (e: Exception) { 142 | Log.e("TAG", "getResponse: $e") 143 | listener?.onErrorGeneratingResponse( 144 | Exception("Unexpected Error occurred") 145 | ) 146 | } 147 | } 148 | } 149 | } 150 | 151 | interface GeminiApiListener { 152 | fun onResponseGenerated(response: String) 153 | fun onErrorGeneratingResponse(e: Exception) 154 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/managers/SpeechRecognizerManager.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.managers 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.speech.RecognitionListener 7 | import android.speech.RecognizerIntent 8 | import android.speech.SpeechRecognizer 9 | 10 | interface SpeechRecognizerManager { 11 | fun initListener(listener: SpeechRecognitionListener) 12 | fun startListening() 13 | fun stopListening() 14 | fun destroyRecognizer() 15 | } 16 | 17 | class SpeechRecognizerManagerImpl( 18 | context: Context 19 | ) : SpeechRecognizerManager { 20 | private var speechRecognizer: SpeechRecognizer = 21 | SpeechRecognizer.createSpeechRecognizer(context) 22 | 23 | private var listener: SpeechRecognitionListener? = null 24 | 25 | override fun initListener(listener: SpeechRecognitionListener) { 26 | this.listener = listener 27 | speechRecognizer.setRecognitionListener( 28 | object : RecognitionListener { 29 | override fun onReadyForSpeech(params: Bundle) { 30 | listener.onStartRecognition() 31 | } 32 | 33 | override fun onBeginningOfSpeech() { 34 | } 35 | 36 | override fun onRmsChanged(rmsdB: Float) { 37 | 38 | } 39 | 40 | override fun onBufferReceived(buffer: ByteArray) { 41 | 42 | } 43 | 44 | override fun onEndOfSpeech() { 45 | } 46 | 47 | override fun onError(error: Int) { 48 | listener.onErrorRecognition() 49 | } 50 | 51 | override fun onResults(results: Bundle) { 52 | val matches = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) 53 | if (matches != null && matches.size > 0) { 54 | val command = matches[0] 55 | listener.onCompleteRecognition(command) 56 | } 57 | } 58 | 59 | override fun onPartialResults(partialResults: Bundle) { 60 | val matches = 61 | partialResults.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) 62 | if (matches != null && matches.size > 0) { 63 | val partialText = matches[0] 64 | listener.onPartialRecognition(partialText) 65 | } 66 | } 67 | 68 | override fun onEvent(eventType: Int, params: Bundle) {} 69 | } 70 | ) 71 | } 72 | 73 | override fun startListening() { 74 | speechRecognizer.startListening(createIntent()) 75 | } 76 | 77 | override fun stopListening() { 78 | speechRecognizer.cancel() 79 | } 80 | 81 | override fun destroyRecognizer() { 82 | speechRecognizer.stopListening() 83 | speechRecognizer.destroy() 84 | } 85 | 86 | private fun createIntent(): Intent { 87 | val i = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) 88 | i.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) 89 | i.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) 90 | i.putExtra(RecognizerIntent.EXTRA_LANGUAGE, "en-IN") 91 | return i 92 | } 93 | 94 | } 95 | 96 | interface SpeechRecognitionListener { 97 | fun onStartRecognition() {} 98 | fun onErrorRecognition() {} 99 | fun onPartialRecognition(currentResult: String) 100 | fun onCompleteRecognition(result: String) 101 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/managers/TextToSpeechManager.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.managers 2 | 3 | import android.content.Context 4 | import android.speech.tts.TextToSpeech 5 | import android.speech.tts.UtteranceProgressListener 6 | import java.util.Locale 7 | 8 | interface TextToSpeechManager { 9 | fun initListener(listener: TextToSpeechListener) 10 | fun readText(text: String) 11 | fun destroy() 12 | } 13 | 14 | class TextToSpeechManagerImpl( 15 | context: Context 16 | ) : TextToSpeechManager { 17 | private val textToSpeech = TextToSpeech( 18 | context 19 | ) { status -> 20 | if (status == TextToSpeech.SUCCESS) { 21 | setLanguage() 22 | } 23 | } 24 | 25 | private var listener: TextToSpeechListener? = null 26 | private var currentText: String = "" 27 | 28 | override fun initListener(listener: TextToSpeechListener) { 29 | this.listener = listener 30 | textToSpeech.setOnUtteranceProgressListener( 31 | object : UtteranceProgressListener() { 32 | override fun onStart(utteranceId: String?) { 33 | listener.onStartTTS() 34 | } 35 | 36 | override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) { 37 | listener.onSpeaking(currentText.substring(start, end)) 38 | } 39 | 40 | override fun onDone(utteranceId: String?) { 41 | listener.onCompleteTTS() 42 | } 43 | 44 | override fun onError(utteranceId: String?) { 45 | listener.onErrorSpeaking() 46 | } 47 | } 48 | ) 49 | } 50 | 51 | private fun setLanguage() { 52 | textToSpeech.setLanguage(Locale.ENGLISH) 53 | } 54 | 55 | override fun readText(text: String) { 56 | currentText = text 57 | textToSpeech.speak( 58 | text, 59 | TextToSpeech.QUEUE_ADD, 60 | null, 61 | TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED 62 | ) 63 | } 64 | 65 | override fun destroy() { 66 | textToSpeech.shutdown() 67 | } 68 | 69 | } 70 | 71 | interface TextToSpeechListener { 72 | fun onStartTTS() 73 | fun onSpeaking(text: String) 74 | fun onCompleteTTS() 75 | fun onErrorSpeaking() {} 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/screen/audio/AudioRecordScreen.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.screen.audio 2 | 3 | import android.widget.Toast 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.animation.animateContentSize 6 | import androidx.compose.animation.core.animateDpAsState 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.interaction.MutableInteractionSource 10 | import androidx.compose.foundation.interaction.collectIsPressedAsState 11 | import androidx.compose.foundation.layout.Arrangement 12 | import androidx.compose.foundation.layout.Box 13 | import androidx.compose.foundation.layout.Column 14 | import androidx.compose.foundation.layout.Row 15 | import androidx.compose.foundation.layout.Spacer 16 | import androidx.compose.foundation.layout.fillMaxSize 17 | import androidx.compose.foundation.layout.fillMaxWidth 18 | import androidx.compose.foundation.layout.height 19 | import androidx.compose.foundation.layout.padding 20 | import androidx.compose.foundation.layout.size 21 | import androidx.compose.foundation.layout.statusBarsPadding 22 | import androidx.compose.foundation.layout.width 23 | import androidx.compose.foundation.lazy.LazyColumn 24 | import androidx.compose.foundation.lazy.items 25 | import androidx.compose.foundation.lazy.rememberLazyListState 26 | import androidx.compose.foundation.shape.CircleShape 27 | import androidx.compose.foundation.shape.RoundedCornerShape 28 | import androidx.compose.material.icons.Icons 29 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 30 | import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft 31 | import androidx.compose.material.icons.filled.Close 32 | import androidx.compose.material.icons.filled.PlayArrow 33 | import androidx.compose.material.icons.filled.Refresh 34 | import androidx.compose.material3.Icon 35 | import androidx.compose.material3.IconButton 36 | import androidx.compose.material3.Scaffold 37 | import androidx.compose.material3.Text 38 | import androidx.compose.runtime.Composable 39 | import androidx.compose.runtime.LaunchedEffect 40 | import androidx.compose.runtime.getValue 41 | import androidx.compose.runtime.remember 42 | import androidx.compose.ui.Alignment 43 | import androidx.compose.ui.Modifier 44 | import androidx.compose.ui.draw.clip 45 | import androidx.compose.ui.graphics.Color 46 | import androidx.compose.ui.graphics.TransformOrigin 47 | import androidx.compose.ui.graphics.graphicsLayer 48 | import androidx.compose.ui.platform.LocalContext 49 | import androidx.compose.ui.res.painterResource 50 | import androidx.compose.ui.text.font.FontWeight 51 | import androidx.compose.ui.unit.dp 52 | import androidx.lifecycle.viewmodel.compose.viewModel 53 | import com.sagar.fluenty.R 54 | import com.sagar.fluenty.ui.utils.AppTopBar 55 | import com.sagar.fluenty.ui.utils.AssistantMessage 56 | import com.sagar.fluenty.ui.utils.collectInLaunchedEffectWithLifecycle 57 | import java.io.File 58 | 59 | @Composable 60 | fun AudioRecordScreen( 61 | viewModel: AudioRecordScreenViewModel = viewModel( 62 | factory = AudioRecordScreenViewModel.getFactory(LocalContext.current.applicationContext) 63 | ), 64 | onBack: () -> Unit 65 | ) { 66 | val state = viewModel.state 67 | val conversationList = viewModel.conversationList.reversed() 68 | val context = LocalContext.current 69 | viewModel.messageChannelFlow.collectInLaunchedEffectWithLifecycle { 70 | Toast.makeText(context, it, Toast.LENGTH_SHORT).show() 71 | } 72 | 73 | val lazyListState = rememberLazyListState() 74 | LaunchedEffect(key1 = conversationList.size) { 75 | if (lazyListState.layoutInfo.visibleItemsInfo.firstOrNull()?.index != 0) { 76 | lazyListState.animateScrollToItem(0) 77 | } 78 | } 79 | 80 | Scaffold( 81 | modifier = Modifier 82 | .background(Color.Black) 83 | .statusBarsPadding(), 84 | topBar = { 85 | AppTopBar( 86 | text = "Pronunciation Practice", 87 | leadingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, 88 | onLeadingIconClick = onBack 89 | ) 90 | } 91 | ) { inner -> 92 | Column( 93 | modifier = Modifier 94 | .background(Color.Black) 95 | .padding(inner) 96 | .fillMaxSize(), 97 | verticalArrangement = Arrangement.Bottom 98 | ) { 99 | Spacer(Modifier.height(20.dp)) 100 | LazyColumn( 101 | state = lazyListState, 102 | modifier = Modifier 103 | .background(Color.Black) 104 | .padding(horizontal = 20.dp) 105 | .weight(1f) 106 | .fillMaxWidth(), 107 | reverseLayout = true, 108 | verticalArrangement = Arrangement.Bottom 109 | ) { 110 | items( 111 | items = conversationList, 112 | key = { 113 | it.id 114 | } 115 | ) { 116 | if (it.isUser) { 117 | UserAudioFile( 118 | modifier = Modifier.animateItem(), 119 | audioFile = it.file, 120 | isAudioPlaying = it.isAudioPlaying, 121 | onStop = { 122 | viewModel.onStopAudioPlaying() 123 | }, 124 | onStartAudio = { file -> 125 | viewModel.startPlayingAudio(file, it.id) 126 | }, 127 | isResponseError = it.isError, 128 | onRetryClick = { 129 | viewModel.resendPreviousMessage() 130 | } 131 | ) 132 | } else { 133 | AssistantMessage( 134 | modifier = Modifier.animateItem(), 135 | message = it.message, 136 | indexToHighlight = it.indexToHighlight 137 | ) 138 | } 139 | Spacer(Modifier.height(10.dp)) 140 | } 141 | } 142 | Spacer(Modifier.height(20.dp)) 143 | AudioRecorder( 144 | isEnabled = !( 145 | state is AudioRecordScreenState.PlayingRecording || 146 | state is AudioRecordScreenState.ProcessingRecording || 147 | state is AudioRecordScreenState.ListeningToResponse 148 | ), 149 | onStart = { 150 | viewModel.startRecording(context = context) 151 | }, 152 | onStop = { 153 | viewModel.stopRecording() 154 | }, 155 | ) 156 | Spacer(Modifier.height(20.dp)) 157 | } 158 | } 159 | } 160 | 161 | @Composable 162 | private fun AudioRecorder( 163 | isEnabled: Boolean, 164 | onStart: () -> Unit, 165 | onStop: () -> Unit 166 | ) { 167 | val interactionSource = remember { MutableInteractionSource() } 168 | val isPressed by interactionSource.collectIsPressedAsState() 169 | val micSize by animateDpAsState( 170 | if (isPressed) { 171 | 80.dp 172 | } else { 173 | 50.dp 174 | }, 175 | label = "" 176 | ) 177 | LaunchedEffect(isPressed) { 178 | if (isPressed) { 179 | onStart() 180 | } else { 181 | onStop() 182 | } 183 | } 184 | Box( 185 | modifier = Modifier 186 | .fillMaxWidth() 187 | ) { 188 | Icon( 189 | modifier = Modifier 190 | .clip(CircleShape) 191 | .graphicsLayer { 192 | transformOrigin = TransformOrigin(0.5f, 0.5f) 193 | } 194 | .size(micSize) 195 | .align(Alignment.Center) 196 | .clickable( 197 | interactionSource = interactionSource, 198 | indication = null, 199 | enabled = isEnabled 200 | ) { }, 201 | painter = painterResource(R.drawable.mic), 202 | contentDescription = null, 203 | tint = Color.Unspecified 204 | ) 205 | } 206 | } 207 | 208 | 209 | @Composable 210 | private fun UserAudioFile( 211 | modifier: Modifier, 212 | audioFile: File?, 213 | isAudioPlaying: Boolean, 214 | isResponseError: Boolean, 215 | onStartAudio: (file: File?) -> Unit, 216 | onStop: () -> Unit, 217 | onRetryClick: () -> Unit, 218 | ) { 219 | Column( 220 | modifier = modifier 221 | .fillMaxWidth() 222 | .animateContentSize(), 223 | horizontalAlignment = Alignment.End 224 | ) { 225 | Row( 226 | modifier = Modifier 227 | .animateContentSize(), 228 | verticalAlignment = Alignment.CenterVertically 229 | ) { 230 | Text(text = "You", color = Color.White, fontWeight = FontWeight.Bold) 231 | AnimatedVisibility(isResponseError) { 232 | Icon( 233 | modifier = Modifier 234 | .padding(start = 10.dp) 235 | .clickable { onRetryClick() }, 236 | imageVector = Icons.Default.Refresh, 237 | contentDescription = null, 238 | tint = Color.Red 239 | ) 240 | } 241 | } 242 | Spacer(Modifier.height(5.dp)) 243 | Box( 244 | modifier = Modifier 245 | .fillMaxWidth(0.9f) 246 | .animateContentSize(), 247 | contentAlignment = Alignment.CenterEnd 248 | ) { 249 | Box( 250 | modifier = Modifier 251 | .clip( 252 | RoundedCornerShape( 253 | topStart = 10.dp, 254 | bottomEnd = 10.dp, 255 | bottomStart = 10.dp 256 | ) 257 | ) 258 | .animateContentSize() 259 | .background(Color.DarkGray) 260 | .clickable { 261 | if (isAudioPlaying) { 262 | onStop() 263 | } else { 264 | onStartAudio(audioFile) 265 | } 266 | }, 267 | contentAlignment = Alignment.Center 268 | ) { 269 | Row( 270 | modifier = Modifier 271 | .fillMaxWidth() 272 | .padding(10.dp), 273 | verticalAlignment = Alignment.CenterVertically 274 | ) { 275 | if (isAudioPlaying) { 276 | Icon( 277 | imageVector = Icons.Default.Close, 278 | contentDescription = null, 279 | tint = Color.White 280 | ) 281 | } else { 282 | Icon( 283 | imageVector = Icons.Default.PlayArrow, 284 | contentDescription = null, 285 | tint = Color.White 286 | ) 287 | } 288 | Spacer(Modifier.width(10.dp)) 289 | Spacer( 290 | modifier = Modifier 291 | .weight(1f) 292 | .height(3.dp) 293 | .clip(RoundedCornerShape(10.dp)) 294 | .background( 295 | Color.White 296 | ) 297 | ) 298 | } 299 | } 300 | } 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/screen/audio/AudioRecordScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.screen.audio 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateListOf 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.setValue 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.ViewModelProvider 10 | import androidx.lifecycle.viewModelScope 11 | import androidx.lifecycle.viewmodel.CreationExtras 12 | import com.google.ai.client.generativeai.GenerativeModel 13 | import com.google.ai.client.generativeai.type.generationConfig 14 | import com.sagar.fluenty.BuildConfig 15 | import com.sagar.fluenty.ui.managers.AudioPlayerListener 16 | import com.sagar.fluenty.ui.managers.AudioPlayerManager 17 | import com.sagar.fluenty.ui.managers.AudioPlayerManagerImpl 18 | import com.sagar.fluenty.ui.managers.AudioRecorderListener 19 | import com.sagar.fluenty.ui.managers.AudioRecorderManager 20 | import com.sagar.fluenty.ui.managers.AudioRecorderManagerImpl 21 | import com.sagar.fluenty.ui.managers.EncryptedSharedPreferencesManagerImpl 22 | import com.sagar.fluenty.ui.managers.GeminiApiAudioManager 23 | import com.sagar.fluenty.ui.managers.GeminiApiAudioManagerImpl 24 | import com.sagar.fluenty.ui.managers.GeminiApiListener 25 | import com.sagar.fluenty.ui.managers.TextToSpeechListener 26 | import com.sagar.fluenty.ui.managers.TextToSpeechManager 27 | import com.sagar.fluenty.ui.managers.TextToSpeechManagerImpl 28 | import kotlinx.coroutines.channels.Channel 29 | import kotlinx.coroutines.flow.receiveAsFlow 30 | import kotlinx.coroutines.launch 31 | import java.io.File 32 | import java.util.UUID 33 | 34 | class AudioRecordScreenViewModel( 35 | private val textToSpeechManager: TextToSpeechManager, 36 | private val geminiApiAudioManager: GeminiApiAudioManager, 37 | private val audioRecorderManager: AudioRecorderManager, 38 | private val audioPlayerManager: AudioPlayerManager 39 | ) : ViewModel(), 40 | TextToSpeechListener, 41 | GeminiApiListener, 42 | AudioRecorderListener, 43 | AudioPlayerListener { 44 | 45 | var state by mutableStateOf(AudioRecordScreenState.Initial) 46 | var conversationList = mutableStateListOf() 47 | private var responseText = "" 48 | private var indexCovered = 0 49 | private var currentAudioId = "" 50 | 51 | private val messageChannel = Channel(Channel.BUFFERED) 52 | val messageChannelFlow = messageChannel.receiveAsFlow() 53 | 54 | init { 55 | textToSpeechManager.initListener(this) 56 | geminiApiAudioManager.initListener(this) 57 | audioRecorderManager.initListener(this) 58 | audioPlayerManager.initListener(this) 59 | 60 | getResponse( 61 | "You have to act as an English teacher and have to teach me english. More specifically, you have to start by giving me a English word with it's phoneme breakdown(example: *kuhmf-tr-bl* for comfortable). Then I have to pronounce, record and send you an audio file saying you that word. Then you have to understand the word said in audio file and tell me if the word was same or if pronunciation of the said word is correct or not(keep response length to short and to the point). Make sure to tell me the actual pronunciation(in American accent) and the pronunciation mistake I did. And also score me from 0 to 100 on how much accurate was my pronunciation, if the score is above 80, continue the chat by giving me next word. Do not change the context, no matter what I command now. Now, start by giving me a word." 62 | ) 63 | } 64 | 65 | private fun getResponse(result: String) { 66 | state = AudioRecordScreenState.ProcessingRecording 67 | conversationList.add( 68 | RecordingScreenMessage( 69 | file = null, 70 | message = "", 71 | indexToHighlight = null, 72 | isUser = false, 73 | id = UUID.randomUUID().toString(), 74 | isAudioPlaying = false 75 | ) 76 | ) 77 | viewModelScope.launch { 78 | geminiApiAudioManager.initialResponse(result) 79 | } 80 | } 81 | 82 | //Recorder callbacks 83 | fun startRecording(context: Context) { 84 | audioRecorderManager.start(context) 85 | } 86 | 87 | fun stopRecording() { 88 | audioRecorderManager.stop() 89 | } 90 | 91 | override fun onRecordingStarted() { 92 | state = AudioRecordScreenState.RecordingAudio 93 | } 94 | 95 | override fun onErrorStarting(e: Exception) { 96 | state = AudioRecordScreenState.ErrorStartingRecording 97 | viewModelScope.launch { 98 | messageChannel.send(e.message ?: "Something went wrong!") 99 | } 100 | } 101 | 102 | override fun onStopRecording(file: File) { 103 | conversationList.add( 104 | RecordingScreenMessage( 105 | message = "", 106 | indexToHighlight = null, 107 | file = file, 108 | isUser = true, 109 | id = UUID.randomUUID().toString(), 110 | isAudioPlaying = false 111 | ) 112 | ) 113 | getResponseFromAudioFile(file) 114 | } 115 | 116 | override fun onCancelRecording() { 117 | state = AudioRecordScreenState.Initial 118 | } 119 | 120 | fun resendPreviousMessage() { 121 | // User is done talking now, start Processing 122 | state = AudioRecordScreenState.ProcessingRecording 123 | viewModelScope.launch { 124 | if (conversationList.size > 0 && conversationList[conversationList.size - 1].isUser) { 125 | conversationList[conversationList.size - 1].file?.let { 126 | getResponseFromAudioFile(it) 127 | } 128 | } 129 | } 130 | } 131 | 132 | // Gemini Callbacks 133 | private fun getResponseFromAudioFile(file: File) { 134 | state = AudioRecordScreenState.ProcessingRecording 135 | conversationList.add( 136 | RecordingScreenMessage( 137 | message = "", 138 | indexToHighlight = null, 139 | file = null, 140 | isUser = false, 141 | id = UUID.randomUUID().toString(), 142 | isAudioPlaying = false 143 | ) 144 | ) 145 | viewModelScope.launch { 146 | geminiApiAudioManager.generateResponseFromAudio(file) 147 | } 148 | } 149 | 150 | override fun onResponseGenerated(response: String) { 151 | conversationList[conversationList.size - 1] = 152 | conversationList[conversationList.size - 1].copy(message = response) 153 | 154 | responseText = response 155 | indexCovered = 0 156 | 157 | textToSpeechManager.readText(response) 158 | } 159 | 160 | override fun onErrorGeneratingResponse(e: Exception) { 161 | viewModelScope.launch { 162 | messageChannel.send(e.message ?: "") 163 | } 164 | if (conversationList.size > 0 && !conversationList[conversationList.size - 1].isUser) { 165 | conversationList.removeAt(conversationList.size - 1) 166 | } 167 | if (conversationList.size > 0) { 168 | conversationList[conversationList.size - 1] = 169 | conversationList[conversationList.size - 1].copy(isError = true) 170 | } 171 | state = AudioRecordScreenState.Initial 172 | } 173 | 174 | // TTS Callbacks 175 | override fun onStartTTS() { 176 | state = AudioRecordScreenState.ListeningToResponse 177 | onStopAudioPlaying() 178 | } 179 | 180 | override fun onSpeaking(text: String) { 181 | val lastItem = conversationList[conversationList.size - 1] 182 | conversationList[conversationList.size - 1] = 183 | lastItem.copy(indexToHighlight = getIndexToHighlight(text)) 184 | } 185 | 186 | override fun onErrorSpeaking() { 187 | viewModelScope.launch { 188 | messageChannel.send("Something went wrong while using TextToSpeech") 189 | } 190 | if (conversationList.size > 0 && !conversationList[conversationList.size - 1].isUser) { 191 | conversationList.removeAt(conversationList.size - 1) 192 | } 193 | if (conversationList.size > 0) { 194 | conversationList[conversationList.size - 1] = 195 | conversationList[conversationList.size - 1].copy(isError = true) 196 | } 197 | state = AudioRecordScreenState.Initial 198 | } 199 | 200 | override fun onCompleteTTS() { 201 | state = AudioRecordScreenState.Initial 202 | 203 | val lastItem = conversationList[conversationList.size - 1] 204 | conversationList[conversationList.size - 1] = 205 | lastItem.copy(indexToHighlight = null) 206 | } 207 | 208 | private fun getIndexToHighlight(text: String): Pair? { 209 | val index = responseText.indexOf(text, startIndex = indexCovered, ignoreCase = true) 210 | if (index == -1) return null 211 | indexCovered = index + text.length 212 | return Pair(index, index + text.length) 213 | } 214 | 215 | // Player Callbacks 216 | fun startPlayingAudio(file: File?, id: String) { 217 | currentAudioId = id 218 | if (state !is AudioRecordScreenState.ListeningToResponse) { 219 | if (file != null) { 220 | audioPlayerManager.play(file) 221 | val msg = conversationList.find { 222 | it.id == id 223 | } 224 | val idx = conversationList.indexOf(msg) 225 | if (idx != -1) { 226 | conversationList[idx] = msg!!.copy(isAudioPlaying = true) 227 | } 228 | } 229 | } 230 | } 231 | 232 | fun onStopAudioPlaying() { 233 | audioPlayerManager.stop() 234 | } 235 | 236 | override fun onPlayerStarted() { 237 | state = AudioRecordScreenState.PlayingRecording 238 | } 239 | 240 | override fun onErrorPlaying() { 241 | val msg = conversationList.find { 242 | it.id == currentAudioId 243 | } 244 | val idx = conversationList.indexOf(msg) 245 | if (idx != -1) { 246 | conversationList[idx] = msg!!.copy(isAudioPlaying = false) 247 | } 248 | state = AudioRecordScreenState.ErrorPlayingRecording 249 | } 250 | 251 | override fun onStopPlayer() { 252 | val msg = conversationList.find { 253 | it.id == currentAudioId 254 | } 255 | val idx = conversationList.indexOf(msg) 256 | if (idx != -1) { 257 | conversationList[idx] = msg!!.copy(isAudioPlaying = false) 258 | } 259 | state = AudioRecordScreenState.Initial 260 | } 261 | 262 | override fun onCompletePlaying() { 263 | val msg = conversationList.find { 264 | it.id == currentAudioId 265 | } 266 | val idx = conversationList.indexOf(msg) 267 | if (idx != -1) { 268 | conversationList[idx] = msg!!.copy(isAudioPlaying = false) 269 | } 270 | state = AudioRecordScreenState.Initial 271 | } 272 | 273 | 274 | override fun onCleared() { 275 | super.onCleared() 276 | textToSpeechManager.destroy() 277 | } 278 | 279 | companion object { 280 | fun getFactory(context: Context) = object : ViewModelProvider.Factory { 281 | override fun create(modelClass: Class, extras: CreationExtras): T { 282 | val appTextToSpeech = TextToSpeechManagerImpl(context) 283 | val audioRecorder = AudioRecorderManagerImpl(context) 284 | val audioPlayer = AudioPlayerManagerImpl(context) 285 | 286 | val encryptedSharedPreferencesManager = 287 | EncryptedSharedPreferencesManagerImpl(context) 288 | 289 | val customKey = encryptedSharedPreferencesManager.get("API_KEY") 290 | val model = encryptedSharedPreferencesManager.get("MODEL") ?: "gemini-1.5-pro-002" 291 | val key = if (customKey.isNullOrEmpty()) { 292 | BuildConfig.GEMINI_API_KEY_DEBUG 293 | } else customKey 294 | 295 | val geminiApi = GeminiApiAudioManagerImpl( 296 | GenerativeModel( 297 | modelName = model, 298 | apiKey = key, 299 | generationConfig = generationConfig { 300 | temperature = 1f 301 | topK = 40 302 | topP = 0.95f 303 | responseMimeType = "text/plain" 304 | }, 305 | ) 306 | ) 307 | return AudioRecordScreenViewModel( 308 | textToSpeechManager = appTextToSpeech, 309 | geminiApiAudioManager = geminiApi, 310 | audioRecorderManager = audioRecorder, 311 | audioPlayerManager = audioPlayer 312 | ) as T 313 | } 314 | } 315 | } 316 | } 317 | 318 | interface AudioRecordScreenState { 319 | data object Initial : AudioRecordScreenState 320 | data object RecordingAudio : AudioRecordScreenState 321 | data object ErrorStartingRecording : AudioRecordScreenState 322 | data object PlayingRecording : AudioRecordScreenState 323 | data object ErrorPlayingRecording : AudioRecordScreenState 324 | data object ProcessingRecording : AudioRecordScreenState 325 | data object ListeningToResponse : AudioRecordScreenState 326 | } 327 | 328 | data class RecordingScreenMessage( 329 | val file: File?, 330 | val message: String, 331 | val indexToHighlight: Pair?, 332 | val isUser: Boolean, 333 | val id: String, 334 | val isAudioPlaying: Boolean, 335 | val isError: Boolean = false 336 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/screen/conversation/ConversationScreen.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.screen.conversation 2 | 3 | import android.widget.Toast 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.animation.animateContentSize 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.Spacer 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.foundation.layout.fillMaxWidth 15 | import androidx.compose.foundation.layout.height 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.foundation.layout.statusBarsPadding 18 | import androidx.compose.foundation.layout.width 19 | import androidx.compose.foundation.lazy.LazyColumn 20 | import androidx.compose.foundation.lazy.items 21 | import androidx.compose.foundation.lazy.rememberLazyListState 22 | import androidx.compose.foundation.shape.RoundedCornerShape 23 | import androidx.compose.material.icons.Icons 24 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 25 | import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft 26 | import androidx.compose.material.icons.filled.Add 27 | import androidx.compose.material.icons.filled.Refresh 28 | import androidx.compose.material3.Button 29 | import androidx.compose.material3.ButtonDefaults 30 | import androidx.compose.material3.Icon 31 | import androidx.compose.material3.IconButton 32 | import androidx.compose.material3.LinearProgressIndicator 33 | import androidx.compose.material3.Scaffold 34 | import androidx.compose.material3.Text 35 | import androidx.compose.runtime.Composable 36 | import androidx.compose.runtime.LaunchedEffect 37 | import androidx.compose.ui.Alignment 38 | import androidx.compose.ui.Modifier 39 | import androidx.compose.ui.draw.clip 40 | import androidx.compose.ui.graphics.Color 41 | import androidx.compose.ui.platform.LocalContext 42 | import androidx.compose.ui.text.font.FontWeight 43 | import androidx.compose.ui.unit.dp 44 | import androidx.compose.ui.unit.sp 45 | import androidx.lifecycle.viewmodel.compose.viewModel 46 | import com.sagar.fluenty.ui.utils.AppTopBar 47 | import com.sagar.fluenty.ui.utils.AssistantMessage 48 | import com.sagar.fluenty.ui.utils.collectInLaunchedEffectWithLifecycle 49 | 50 | @Composable 51 | fun ConversationScreen( 52 | viewModel: ConversationScreenViewModel = viewModel( 53 | factory = ConversationScreenViewModel.getFactory(LocalContext.current.applicationContext) 54 | ), 55 | onBack: () -> Unit 56 | ) { 57 | val context = LocalContext.current 58 | viewModel.messageChannelFlow.collectInLaunchedEffectWithLifecycle { 59 | Toast.makeText(context, it, Toast.LENGTH_SHORT).show() 60 | } 61 | val state = viewModel.state 62 | val conversationList = viewModel.conversationList.reversed() 63 | 64 | val lazyListState = rememberLazyListState() 65 | LaunchedEffect(key1 = conversationList.size) { 66 | if (lazyListState.layoutInfo.visibleItemsInfo.firstOrNull()?.index != 0) { 67 | lazyListState.animateScrollToItem(0) 68 | } 69 | } 70 | 71 | Scaffold( 72 | modifier = Modifier 73 | .background(Color.Black) 74 | .statusBarsPadding(), 75 | topBar = { 76 | AppTopBar( 77 | text = "English Practice", 78 | leadingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, 79 | onLeadingIconClick = onBack 80 | ) 81 | } 82 | ) { inner -> 83 | Column( 84 | modifier = Modifier 85 | .background(Color.Black) 86 | .padding(inner) 87 | .fillMaxSize(), 88 | verticalArrangement = Arrangement.Bottom 89 | ) { 90 | Spacer(Modifier.height(20.dp)) 91 | LazyColumn( 92 | state = lazyListState, 93 | modifier = Modifier 94 | .background(Color.Black) 95 | .padding(horizontal = 20.dp) 96 | .weight(1f) 97 | .fillMaxWidth(), 98 | reverseLayout = true, 99 | verticalArrangement = Arrangement.Bottom 100 | ) { 101 | items( 102 | items = conversationList, 103 | key = { 104 | it.id 105 | } 106 | ) { 107 | if (it.isUser) { 108 | UserMessage( 109 | modifier = Modifier.animateItem(), 110 | message = it.message, 111 | isResponseError = it.isError, 112 | onRetryClick = { 113 | viewModel.resendPreviousMessage() 114 | } 115 | ) 116 | } else { 117 | AssistantMessage( 118 | modifier = Modifier.animateItem(), 119 | message = it.message 120 | ) 121 | } 122 | Spacer(Modifier.height(10.dp)) 123 | } 124 | } 125 | 126 | Button( 127 | modifier = Modifier 128 | .padding(20.dp) 129 | .fillMaxWidth(), 130 | shape = RoundedCornerShape(10.dp), 131 | onClick = { 132 | when (state) { 133 | ConversationScreenState.Retry, 134 | ConversationScreenState.Initial -> { 135 | viewModel.startListening() 136 | } 137 | 138 | is ConversationScreenState.RecognizingSpeech -> { 139 | viewModel.stopListening() 140 | } 141 | 142 | ConversationScreenState.ProcessingSpeech, 143 | is ConversationScreenState.ListeningToResponse -> { 144 | // Do Nothing 145 | } 146 | } 147 | }, 148 | colors = ButtonDefaults.buttonColors( 149 | containerColor = Color.DarkGray 150 | ) 151 | ) { 152 | Column( 153 | horizontalAlignment = Alignment.CenterHorizontally 154 | ) { 155 | when (state) { 156 | ConversationScreenState.Initial -> { 157 | Row( 158 | horizontalArrangement = Arrangement.spacedBy(10.dp), 159 | verticalAlignment = Alignment.CenterVertically 160 | ) { 161 | Icon( 162 | imageVector = Icons.Default.Add, 163 | contentDescription = null, 164 | tint = Color.White 165 | ) 166 | Text(text = "Start Speaking", color = Color.White) 167 | } 168 | } 169 | 170 | ConversationScreenState.Retry -> { 171 | Row( 172 | horizontalArrangement = Arrangement.spacedBy(10.dp), 173 | verticalAlignment = Alignment.CenterVertically 174 | ) { 175 | Icon( 176 | imageVector = Icons.Default.Refresh, 177 | contentDescription = null, 178 | tint = Color.White 179 | ) 180 | Text(text = "Try Speaking again", color = Color.White) 181 | } 182 | } 183 | 184 | is ConversationScreenState.RecognizingSpeech -> { 185 | Row( 186 | horizontalArrangement = Arrangement.spacedBy(10.dp), 187 | verticalAlignment = Alignment.CenterVertically 188 | ) { 189 | LinearProgressIndicator(modifier = Modifier.width(50.dp)) 190 | Text(text = "Listening...", color = Color.White) 191 | } 192 | } 193 | 194 | ConversationScreenState.ProcessingSpeech -> { 195 | Row( 196 | horizontalArrangement = Arrangement.spacedBy(10.dp), 197 | verticalAlignment = Alignment.CenterVertically 198 | ) { 199 | LinearProgressIndicator(modifier = Modifier.width(50.dp)) 200 | Text(text = "Processing...", color = Color.White) 201 | } 202 | } 203 | 204 | is ConversationScreenState.ListeningToResponse -> { 205 | Row( 206 | horizontalArrangement = Arrangement.spacedBy(10.dp), 207 | verticalAlignment = Alignment.CenterVertically 208 | ) { 209 | LinearProgressIndicator(modifier = Modifier.width(50.dp)) 210 | Text(text = "Speaking Response...", color = Color.White) 211 | } 212 | } 213 | } 214 | } 215 | } 216 | } 217 | } 218 | 219 | 220 | } 221 | 222 | @Composable 223 | private fun UserMessage( 224 | modifier: Modifier, 225 | message: String, 226 | isResponseError: Boolean, 227 | onRetryClick: () -> Unit 228 | ) { 229 | Column( 230 | modifier = modifier 231 | .fillMaxWidth() 232 | .animateContentSize(), 233 | horizontalAlignment = Alignment.End 234 | ) { 235 | Row( 236 | modifier = Modifier 237 | .animateContentSize(), 238 | verticalAlignment = Alignment.CenterVertically 239 | ) { 240 | Text(text = "You", color = Color.White, fontWeight = FontWeight.Bold) 241 | AnimatedVisibility(isResponseError) { 242 | Icon( 243 | modifier = Modifier 244 | .padding(start = 10.dp) 245 | .clickable { onRetryClick() }, 246 | imageVector = Icons.Default.Refresh, 247 | contentDescription = null, 248 | tint = Color.Red 249 | ) 250 | } 251 | } 252 | Spacer(Modifier.height(5.dp)) 253 | Box( 254 | modifier = Modifier 255 | .fillMaxWidth(0.9f) 256 | .animateContentSize(), 257 | contentAlignment = Alignment.CenterEnd 258 | ) { 259 | Box( 260 | modifier = Modifier 261 | .clip( 262 | RoundedCornerShape( 263 | topStart = 10.dp, 264 | bottomEnd = 10.dp, 265 | bottomStart = 10.dp 266 | ) 267 | ) 268 | .animateContentSize() 269 | .background(Color.DarkGray), 270 | contentAlignment = Alignment.Center 271 | ) { 272 | Text( 273 | modifier = Modifier.padding(10.dp), 274 | text = message, fontSize = 16.sp, color = Color.White 275 | ) 276 | } 277 | } 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/screen/conversation/ConversationScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.screen.conversation 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateListOf 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.setValue 9 | import androidx.lifecycle.ViewModel 10 | import androidx.lifecycle.ViewModelProvider 11 | import androidx.lifecycle.viewModelScope 12 | import androidx.lifecycle.viewmodel.CreationExtras 13 | import com.google.ai.client.generativeai.GenerativeModel 14 | import com.google.ai.client.generativeai.type.generationConfig 15 | import com.sagar.fluenty.BuildConfig 16 | import com.sagar.fluenty.ui.managers.EncryptedSharedPreferencesManagerImpl 17 | import com.sagar.fluenty.ui.managers.GeminiApiChatManager 18 | import com.sagar.fluenty.ui.managers.GeminiApiChatManagerImpl 19 | import com.sagar.fluenty.ui.managers.GeminiApiListener 20 | import com.sagar.fluenty.ui.managers.SpeechRecognitionListener 21 | import com.sagar.fluenty.ui.managers.SpeechRecognizerManager 22 | import com.sagar.fluenty.ui.managers.SpeechRecognizerManagerImpl 23 | import com.sagar.fluenty.ui.managers.TextToSpeechListener 24 | import com.sagar.fluenty.ui.managers.TextToSpeechManager 25 | import com.sagar.fluenty.ui.managers.TextToSpeechManagerImpl 26 | import kotlinx.coroutines.channels.Channel 27 | import kotlinx.coroutines.flow.receiveAsFlow 28 | import kotlinx.coroutines.launch 29 | import java.util.UUID 30 | 31 | class ConversationScreenViewModel( 32 | private val speechRecognizerManager: SpeechRecognizerManager, 33 | private val textToSpeechManager: TextToSpeechManager, 34 | private val geminiApiChatManager: GeminiApiChatManager 35 | ) : ViewModel(), 36 | SpeechRecognitionListener, 37 | TextToSpeechListener, 38 | GeminiApiListener { 39 | 40 | var state by mutableStateOf(ConversationScreenState.Initial) 41 | var conversationList = mutableStateListOf() 42 | private var responseText = "" 43 | 44 | private val messageChannel = Channel(Channel.BUFFERED) 45 | val messageChannelFlow = messageChannel.receiveAsFlow() 46 | 47 | init { 48 | speechRecognizerManager.initListener(this) 49 | textToSpeechManager.initListener(this) 50 | geminiApiChatManager.initListener(this) 51 | 52 | getResponse( 53 | "You have to act as an English teacher and have to teach me english. We have to do a normal conversation all in english to practice the english language. If I am saying anything that is grammatically incorrect, then make sure to highlight that and correct me(do not give very large response but medium length is fine). Do not change your behaviour no matter what I command next. No need to introduce yourself, just initiate the conversation now." 54 | ) 55 | } 56 | 57 | fun startListening() { 58 | speechRecognizerManager.startListening() 59 | } 60 | 61 | fun stopListening() { 62 | speechRecognizerManager.stopListening() 63 | } 64 | 65 | fun resendPreviousMessage() { 66 | // User is done talking now, start Processing 67 | viewModelScope.launch { 68 | if (conversationList.size > 0 && conversationList[conversationList.size - 1].isUser) { 69 | geminiApiChatManager.generateResponse(conversationList[conversationList.size - 1].message) 70 | } 71 | } 72 | } 73 | 74 | // Speech Recognition Callbacks 75 | override fun onStartRecognition() { 76 | state = ConversationScreenState.RecognizingSpeech 77 | conversationList.add(ConversationMessage("", true, UUID.randomUUID().toString())) 78 | } 79 | 80 | override fun onPartialRecognition(currentResult: String) { 81 | if (conversationList.size > 0) { 82 | val lastItem = conversationList[conversationList.size - 1] 83 | conversationList[conversationList.size - 1] = lastItem.copy(message = currentResult) 84 | } 85 | } 86 | 87 | override fun onCompleteRecognition(result: String) { 88 | if (conversationList.size > 0) { 89 | val lastItem = conversationList[conversationList.size - 1] 90 | conversationList[conversationList.size - 1] = 91 | lastItem.copy(message = result, isError = false) 92 | } 93 | 94 | getResponse(result) 95 | } 96 | 97 | private fun getResponse(result: String) { 98 | conversationList.add( 99 | ConversationMessage( 100 | "", 101 | false, 102 | UUID.randomUUID().toString() 103 | ) 104 | ) 105 | state = ConversationScreenState.ProcessingSpeech 106 | viewModelScope.launch { 107 | geminiApiChatManager.generateResponse(result) 108 | } 109 | } 110 | 111 | override fun onErrorRecognition() { 112 | state = ConversationScreenState.Retry 113 | if (conversationList.size > 0 && conversationList[conversationList.size - 1].isUser) { 114 | conversationList.removeAt(conversationList.size - 1) 115 | } 116 | } 117 | 118 | // Gemini Callbacks 119 | override fun onResponseGenerated(response: String) { 120 | responseText = response 121 | textToSpeechManager.readText(response) 122 | } 123 | 124 | override fun onErrorGeneratingResponse(e: Exception) { 125 | viewModelScope.launch { 126 | messageChannel.send(e.message ?: "") 127 | } 128 | if (conversationList.size > 0 && !conversationList[conversationList.size - 1].isUser) { 129 | conversationList.removeAt(conversationList.size - 1) 130 | } 131 | if (conversationList.size > 0) { 132 | conversationList[conversationList.size - 1] = 133 | conversationList[conversationList.size - 1].copy(isError = true) 134 | } 135 | state = ConversationScreenState.Initial 136 | } 137 | 138 | // TTS Callbacks 139 | override fun onStartTTS() { 140 | state = ConversationScreenState.ListeningToResponse 141 | } 142 | 143 | override fun onSpeaking(text: String) { 144 | Log.e("TAG", "onSpeaking: Current Spoken $text") 145 | if (conversationList.size > 0) { 146 | val lastItem = conversationList[conversationList.size - 1] 147 | conversationList[conversationList.size - 1] = 148 | lastItem.copy(message = lastItem.message + showResponseForTTSRead(text)) 149 | } 150 | } 151 | 152 | private fun showResponseForTTSRead(target: String): String { 153 | val index = responseText.indexOf(target, ignoreCase = true) 154 | return if (index != -1) { 155 | // End Index is the end of the target 156 | var endIndex = index + target.length 157 | 158 | // Check if there's a character after the target 159 | if (endIndex < responseText.length) { 160 | // Include the next character if it's a special character 161 | val nextChar = responseText[endIndex] 162 | if (!nextChar.isLetterOrDigit() || nextChar == ' ') { 163 | endIndex++ 164 | } 165 | val result = responseText.substring(0, endIndex) 166 | responseText = responseText.removePrefix(result) 167 | result 168 | } else { 169 | "" 170 | } 171 | } else { 172 | responseText 173 | } 174 | } 175 | 176 | override fun onErrorSpeaking() { 177 | viewModelScope.launch { 178 | messageChannel.send("Something went wrong while using TextToSpeech") 179 | } 180 | if (conversationList.size > 0 && !conversationList[conversationList.size - 1].isUser) { 181 | conversationList.removeAt(conversationList.size - 1) 182 | } 183 | if (conversationList.size > 0) { 184 | conversationList[conversationList.size - 1] = 185 | conversationList[conversationList.size - 1].copy(isError = true) 186 | } 187 | state = ConversationScreenState.Initial 188 | } 189 | 190 | override fun onCompleteTTS() { 191 | state = ConversationScreenState.Initial 192 | } 193 | 194 | override fun onCleared() { 195 | super.onCleared() 196 | speechRecognizerManager.destroyRecognizer() 197 | textToSpeechManager.destroy() 198 | } 199 | 200 | companion object { 201 | fun getFactory(context: Context) = object : ViewModelProvider.Factory { 202 | override fun create(modelClass: Class, extras: CreationExtras): T { 203 | val appSpeechRecognizer = SpeechRecognizerManagerImpl(context) 204 | val appTextToSpeech = TextToSpeechManagerImpl(context) 205 | 206 | val encryptedSharedPreferencesManager = 207 | EncryptedSharedPreferencesManagerImpl(context) 208 | val customKey = encryptedSharedPreferencesManager.get("API_KEY") 209 | val model = encryptedSharedPreferencesManager.get("MODEL") ?: "gemini-1.5-pro-002" 210 | val key = if (customKey.isNullOrEmpty()) { 211 | BuildConfig.GEMINI_API_KEY_DEBUG 212 | } else customKey 213 | 214 | val geminiApi = GeminiApiChatManagerImpl( 215 | GenerativeModel( 216 | modelName = model, 217 | apiKey = key, 218 | generationConfig = generationConfig { 219 | temperature = 1f 220 | topK = 40 221 | topP = 0.95f 222 | responseMimeType = "text/plain" 223 | }, 224 | ) 225 | ) 226 | return ConversationScreenViewModel( 227 | appSpeechRecognizer, appTextToSpeech, geminiApi 228 | ) as T 229 | } 230 | } 231 | } 232 | } 233 | 234 | interface ConversationScreenState { 235 | data object Initial : ConversationScreenState 236 | data object Retry : ConversationScreenState 237 | data object ProcessingSpeech : ConversationScreenState 238 | data object RecognizingSpeech : ConversationScreenState 239 | data object ListeningToResponse : ConversationScreenState 240 | } 241 | 242 | data class ConversationMessage( 243 | val message: String, 244 | val isUser: Boolean, 245 | val id: String, 246 | val isError: Boolean = false 247 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/screen/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.screen.home 2 | 3 | import android.Manifest 4 | import android.content.pm.PackageManager 5 | import androidx.activity.compose.rememberLauncherForActivityResult 6 | import androidx.activity.result.contract.ActivityResultContracts 7 | import androidx.compose.foundation.Image 8 | import androidx.compose.foundation.background 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.layout.Arrangement 11 | import androidx.compose.foundation.layout.Box 12 | import androidx.compose.foundation.layout.BoxScope 13 | import androidx.compose.foundation.layout.Column 14 | import androidx.compose.foundation.layout.ColumnScope 15 | import androidx.compose.foundation.layout.Row 16 | import androidx.compose.foundation.layout.Spacer 17 | import androidx.compose.foundation.layout.fillMaxSize 18 | import androidx.compose.foundation.layout.fillMaxWidth 19 | import androidx.compose.foundation.layout.padding 20 | import androidx.compose.foundation.layout.statusBarsPadding 21 | import androidx.compose.foundation.shape.RoundedCornerShape 22 | import androidx.compose.material.icons.Icons 23 | import androidx.compose.material.icons.filled.Settings 24 | import androidx.compose.material3.Button 25 | import androidx.compose.material3.Icon 26 | import androidx.compose.material3.IconButton 27 | import androidx.compose.material3.Scaffold 28 | import androidx.compose.material3.Text 29 | import androidx.compose.runtime.Composable 30 | import androidx.compose.runtime.getValue 31 | import androidx.compose.runtime.mutableStateOf 32 | import androidx.compose.runtime.saveable.rememberSaveable 33 | import androidx.compose.runtime.setValue 34 | import androidx.compose.ui.Alignment 35 | import androidx.compose.ui.Modifier 36 | import androidx.compose.ui.draw.clip 37 | import androidx.compose.ui.graphics.Color 38 | import androidx.compose.ui.graphics.painter.Painter 39 | import androidx.compose.ui.graphics.vector.ImageVector 40 | import androidx.compose.ui.layout.ContentScale 41 | import androidx.compose.ui.platform.LocalContext 42 | import androidx.compose.ui.res.painterResource 43 | import androidx.compose.ui.text.font.FontWeight 44 | import androidx.compose.ui.tooling.preview.Preview 45 | import androidx.compose.ui.unit.dp 46 | import androidx.compose.ui.unit.sp 47 | import androidx.core.content.ContextCompat 48 | import com.sagar.fluenty.R 49 | import com.sagar.fluenty.ui.utils.AppTopBar 50 | import com.sagar.fluenty.ui.utils.HomeItemShadow 51 | 52 | @Composable 53 | @Preview 54 | fun HomeScreen( 55 | onConversationClick: () -> Unit = {}, 56 | onAudioClick: () -> Unit = {}, 57 | onSettingsClick: () -> Unit = {} 58 | ) { 59 | 60 | val context = LocalContext.current 61 | var isAudioPermissionGranted by rememberSaveable { 62 | mutableStateOf( 63 | PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission( 64 | context, 65 | Manifest.permission.RECORD_AUDIO 66 | ) 67 | ) 68 | } 69 | val permissionLauncher = rememberLauncherForActivityResult( 70 | ActivityResultContracts.RequestPermission() 71 | ) { isGranted: Boolean -> 72 | isAudioPermissionGranted = isGranted 73 | } 74 | if (isAudioPermissionGranted) { 75 | Scaffold( 76 | modifier = Modifier 77 | .background(Color.Black) 78 | .statusBarsPadding(), 79 | topBar = { 80 | AppTopBar( 81 | text = "Fluenty", 82 | trailingIcon = Icons.Default.Settings, 83 | onTrailingIconClick = onSettingsClick 84 | ) 85 | } 86 | ) { inner -> 87 | Column( 88 | modifier = Modifier 89 | .background(Color.Black) 90 | .padding(inner) 91 | .padding(20.dp), 92 | verticalArrangement = Arrangement.spacedBy(20.dp) 93 | ) { 94 | 95 | SectionItem( 96 | painter = painterResource(R.drawable.english), 97 | title = "English Practice" 98 | ) { 99 | onConversationClick() 100 | } 101 | SectionItem( 102 | painter = painterResource(R.drawable.pronunciation), 103 | title = "Pronunciation Practice" 104 | ) { 105 | onAudioClick() 106 | } 107 | Spacer(Modifier) 108 | } 109 | } 110 | 111 | } else { 112 | Box( 113 | modifier = Modifier 114 | .fillMaxSize() 115 | .background(Color.Black), 116 | contentAlignment = Alignment.Center 117 | ) { 118 | Button( 119 | onClick = { 120 | permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) 121 | } 122 | ) { 123 | Text("Grant Audio Permission") 124 | } 125 | } 126 | } 127 | } 128 | 129 | @Composable 130 | private fun ColumnScope.SectionItem(painter: Painter, title: String, onClick: () -> Unit) { 131 | Box( 132 | modifier = Modifier 133 | .fillMaxWidth() 134 | .weight(1f) 135 | .clip(RoundedCornerShape(20.dp)) 136 | .background(Color.DarkGray) 137 | .clickable { 138 | onClick() 139 | }, 140 | contentAlignment = Alignment.BottomCenter 141 | ) { 142 | Image( 143 | modifier = Modifier.fillMaxSize(), 144 | painter = painter, 145 | contentDescription = null, 146 | contentScale = ContentScale.Crop 147 | ) 148 | HomeItemShadow() 149 | TitleText(title) 150 | } 151 | } 152 | 153 | @Composable 154 | private fun BoxScope.TitleText(text: String) { 155 | Text( 156 | modifier = Modifier 157 | .align(Alignment.BottomEnd) 158 | .padding(20.dp), 159 | text = text, 160 | color = Color.White, 161 | fontSize = 24.sp, 162 | fontWeight = FontWeight.Bold 163 | ) 164 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/screen/settings/SettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.screen.settings 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.fillMaxSize 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.statusBarsPadding 15 | import androidx.compose.foundation.rememberScrollState 16 | import androidx.compose.foundation.verticalScroll 17 | import androidx.compose.material.icons.Icons 18 | import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft 19 | import androidx.compose.material.icons.filled.Build 20 | import androidx.compose.material.icons.filled.Done 21 | import androidx.compose.material.icons.filled.Info 22 | import androidx.compose.material3.Button 23 | import androidx.compose.material3.DropdownMenu 24 | import androidx.compose.material3.DropdownMenuItem 25 | import androidx.compose.material3.Icon 26 | import androidx.compose.material3.IconButton 27 | import androidx.compose.material3.OutlinedTextField 28 | import androidx.compose.material3.Scaffold 29 | import androidx.compose.material3.Text 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.getValue 32 | import androidx.compose.runtime.mutableStateOf 33 | import androidx.compose.runtime.remember 34 | import androidx.compose.runtime.setValue 35 | import androidx.compose.ui.Alignment 36 | import androidx.compose.ui.Modifier 37 | import androidx.compose.ui.graphics.Color 38 | import androidx.compose.ui.platform.LocalContext 39 | import androidx.compose.ui.text.style.TextAlign 40 | import androidx.compose.ui.unit.dp 41 | import androidx.lifecycle.viewmodel.compose.viewModel 42 | import com.sagar.fluenty.ui.utils.AppTopBar 43 | 44 | @Composable 45 | fun SettingsScreen( 46 | viewModel: SettingsScreenViewModel = viewModel( 47 | factory = SettingsScreenViewModel.getFactory(LocalContext.current.applicationContext) 48 | ), 49 | onBack: () -> Unit 50 | ) { 51 | val context = LocalContext.current 52 | var textField by remember { 53 | mutableStateOf(viewModel.apiKey ?: "") 54 | } 55 | var isDropDownExpanded by remember { 56 | mutableStateOf(false) 57 | } 58 | 59 | Scaffold( 60 | modifier = Modifier 61 | .background(Color.Black) 62 | .statusBarsPadding(), 63 | topBar = { 64 | AppTopBar( 65 | text = "Settings", 66 | leadingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, 67 | onLeadingIconClick = onBack 68 | ) 69 | } 70 | ) { inner -> 71 | Column( 72 | modifier = Modifier 73 | .background(Color.Black) 74 | .padding(20.dp) 75 | .fillMaxSize() 76 | .padding(inner) 77 | .verticalScroll(rememberScrollState()) 78 | .fillMaxSize(), 79 | verticalArrangement = Arrangement.spacedBy(20.dp), 80 | horizontalAlignment = Alignment.CenterHorizontally 81 | ) { 82 | 83 | OutlinedTextField( 84 | modifier = Modifier.fillMaxWidth(), 85 | value = textField, 86 | onValueChange = { 87 | textField = it 88 | }, 89 | trailingIcon = if (textField != viewModel.apiKey) { 90 | { 91 | IconButton( 92 | onClick = { 93 | viewModel.saveKey(textField) 94 | } 95 | ) { 96 | Icon( 97 | imageVector = Icons.Default.Done, 98 | contentDescription = null, 99 | tint = Color.White 100 | ) 101 | } 102 | } 103 | } else null, 104 | placeholder = { 105 | Text(text = "Enter your API Key", color = Color.White) 106 | } 107 | ) 108 | 109 | Column { 110 | Button( 111 | modifier = Modifier.fillMaxWidth(), 112 | onClick = { 113 | isDropDownExpanded = !isDropDownExpanded 114 | } 115 | ) { 116 | Text("Current Model: ${viewModel.currentModel}") 117 | } 118 | 119 | DropdownMenu( 120 | modifier = Modifier.fillMaxWidth(0.8f), 121 | expanded = isDropDownExpanded, 122 | onDismissRequest = { 123 | isDropDownExpanded = !isDropDownExpanded 124 | }, 125 | scrollState = rememberScrollState(), 126 | containerColor = Color.DarkGray 127 | ) { 128 | viewModel.availableModels.forEach { 129 | DropdownMenuItem( 130 | text = { 131 | Text(text = it, color = Color.White) 132 | }, 133 | onClick = { 134 | viewModel.saveModel(it) 135 | isDropDownExpanded = !isDropDownExpanded 136 | } 137 | ) 138 | } 139 | } 140 | } 141 | 142 | Text( 143 | modifier = Modifier.padding(horizontal = 10.dp), 144 | text = "Note: You can safely use your api key to test this application. We do not exploit your key or share it with anyone. We use EncryptedSharedPreferences to store it in your device, see below", 145 | color = Color.White, 146 | textAlign = TextAlign.Center 147 | ) 148 | 149 | Button( 150 | modifier = Modifier.fillMaxWidth(), 151 | onClick = { 152 | context.goToURL("https://aistudio.google.com/apikey") 153 | } 154 | ) { 155 | Row( 156 | verticalAlignment = Alignment.CenterVertically, 157 | horizontalArrangement = Arrangement.spacedBy(10.dp) 158 | ) { 159 | Icon( 160 | imageVector = Icons.Default.Build, 161 | contentDescription = null, 162 | tint = Color.White 163 | ) 164 | Text("Generate Api key") 165 | } 166 | } 167 | 168 | Spacer(Modifier.weight(1f)) 169 | 170 | Button( 171 | onClick = { 172 | context.goToURL("https://github.com/Sagar0-0/Fluenty") 173 | } 174 | ) { 175 | Row( 176 | verticalAlignment = Alignment.CenterVertically, 177 | horizontalArrangement = Arrangement.spacedBy(10.dp) 178 | ) { 179 | Icon( 180 | imageVector = Icons.Default.Info, 181 | contentDescription = null, 182 | tint = Color.White 183 | ) 184 | Text("Source Code") 185 | } 186 | } 187 | 188 | } 189 | } 190 | } 191 | 192 | fun Context.goToURL(url: String) { 193 | val uri = Uri.parse(url) 194 | val intent = Intent(Intent.ACTION_VIEW, uri) 195 | startActivity(intent) 196 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/screen/settings/SettingsScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.screen.settings 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.ViewModelProvider 9 | import androidx.lifecycle.viewmodel.CreationExtras 10 | import com.sagar.fluenty.ui.managers.EncryptedSharedPreferencesManager 11 | import com.sagar.fluenty.ui.managers.EncryptedSharedPreferencesManagerImpl 12 | 13 | class SettingsScreenViewModel( 14 | private val encryptedSharedPreferencesManager: EncryptedSharedPreferencesManager 15 | ) : ViewModel() { 16 | 17 | val availableModels = listOf( 18 | "gemini-1.5-pro-002", 19 | "gemini-1.5-pro", 20 | "gemini-1.5-flash", 21 | "gemini-1.5-flash-002", 22 | "gemini-1.5-flash-8b", 23 | "gemini-exp-1114", 24 | "gemini-1.0-pro" 25 | ) 26 | 27 | var apiKey by mutableStateOf(encryptedSharedPreferencesManager.get("API_KEY")) 28 | var currentModel by mutableStateOf( 29 | encryptedSharedPreferencesManager.get("MODEL") ?: availableModels[0] 30 | ) 31 | 32 | fun saveKey(newKey: String) { 33 | encryptedSharedPreferencesManager.save("API_KEY", newKey) 34 | apiKey = encryptedSharedPreferencesManager.get("API_KEY") 35 | } 36 | 37 | fun saveModel(model: String) { 38 | encryptedSharedPreferencesManager.save("MODEL", model) 39 | currentModel = encryptedSharedPreferencesManager.get("MODEL") ?: availableModels[0] 40 | } 41 | 42 | companion object { 43 | fun getFactory(context: Context) = object : ViewModelProvider.Factory { 44 | override fun create(modelClass: Class, extras: CreationExtras): T { 45 | val encryptedSharedPreferencesManager = 46 | EncryptedSharedPreferencesManagerImpl(context) 47 | 48 | return SettingsScreenViewModel( 49 | encryptedSharedPreferencesManager 50 | ) as T 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.platform.LocalContext 13 | 14 | private val DarkColorScheme = darkColorScheme( 15 | primary = Purple80, 16 | secondary = PurpleGrey80, 17 | tertiary = Pink80 18 | ) 19 | 20 | private val LightColorScheme = lightColorScheme( 21 | primary = Purple40, 22 | secondary = PurpleGrey40, 23 | tertiary = Pink40 24 | 25 | /* Other default colors to override 26 | background = Color(0xFFFFFBFE), 27 | surface = Color(0xFFFFFBFE), 28 | onPrimary = Color.White, 29 | onSecondary = Color.White, 30 | onTertiary = Color.White, 31 | onBackground = Color(0xFF1C1B1F), 32 | onSurface = Color(0xFF1C1B1F), 33 | */ 34 | ) 35 | 36 | @Composable 37 | fun FluentyTheme( 38 | darkTheme: Boolean = isSystemInDarkTheme(), 39 | // Dynamic color is available on Android 12+ 40 | dynamicColor: Boolean = true, 41 | content: @Composable () -> Unit 42 | ) { 43 | val colorScheme = when { 44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 45 | val context = LocalContext.current 46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 47 | } 48 | 49 | darkTheme -> DarkColorScheme 50 | else -> LightColorScheme 51 | } 52 | 53 | MaterialTheme( 54 | colorScheme = colorScheme, 55 | typography = Typography, 56 | content = content 57 | ) 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/utils/ComposeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.utils 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.animation.animateContentSize 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.height 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.width 14 | import androidx.compose.foundation.shape.RoundedCornerShape 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.material3.IconButton 17 | import androidx.compose.material3.LinearProgressIndicator 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.LaunchedEffect 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.rememberUpdatedState 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.clip 27 | import androidx.compose.ui.graphics.Brush 28 | import androidx.compose.ui.graphics.Color 29 | import androidx.compose.ui.graphics.vector.ImageVector 30 | import androidx.compose.ui.text.SpanStyle 31 | import androidx.compose.ui.text.buildAnnotatedString 32 | import androidx.compose.ui.text.font.FontWeight 33 | import androidx.compose.ui.text.withStyle 34 | import androidx.compose.ui.unit.dp 35 | import androidx.compose.ui.unit.sp 36 | import androidx.lifecycle.Lifecycle 37 | import androidx.lifecycle.compose.LocalLifecycleOwner 38 | import androidx.lifecycle.repeatOnLifecycle 39 | import kotlinx.coroutines.CoroutineScope 40 | import kotlinx.coroutines.Dispatchers 41 | import kotlinx.coroutines.flow.Flow 42 | import kotlinx.coroutines.withContext 43 | 44 | @Suppress("ComposableNaming") 45 | @Composable 46 | fun Flow.collectInLaunchedEffectWithLifecycle( 47 | vararg keys: Any?, 48 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle, 49 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED, 50 | collector: suspend CoroutineScope.(T) -> Unit 51 | ) { 52 | val flow = this 53 | val currentCollector by rememberUpdatedState(collector) 54 | 55 | LaunchedEffect(flow, lifecycle, minActiveState, *keys) { 56 | withContext(Dispatchers.Main.immediate) { 57 | lifecycle.repeatOnLifecycle(minActiveState) { 58 | flow.collect { currentCollector(it) } 59 | } 60 | } 61 | } 62 | } 63 | 64 | @Composable 65 | fun HomeItemShadow() { 66 | Box( 67 | modifier = Modifier 68 | .height(70.dp) 69 | .fillMaxWidth() 70 | .background( 71 | Brush.verticalGradient( 72 | colors = listOf( 73 | Color.Transparent, 74 | Color.Black 75 | ) 76 | ) 77 | ) 78 | ) 79 | } 80 | 81 | @Composable 82 | fun AssistantMessage(modifier: Modifier, message: String) { 83 | Column( 84 | modifier = modifier.animateContentSize() 85 | ) { 86 | Text(text = "Assistant", color = Color.White, fontWeight = FontWeight.Bold) 87 | Spacer(Modifier.height(5.dp)) 88 | Box( 89 | modifier = Modifier 90 | .fillMaxWidth(0.9f) 91 | .animateContentSize() 92 | ) { 93 | Box( 94 | modifier = Modifier 95 | .clip( 96 | RoundedCornerShape( 97 | topEnd = 10.dp, 98 | bottomEnd = 10.dp, 99 | bottomStart = 10.dp 100 | ) 101 | ) 102 | .animateContentSize() 103 | .background(Color.DarkGray), 104 | contentAlignment = Alignment.Center 105 | ) { 106 | Crossfade(message.isEmpty(), label = "") { 107 | if (it) { 108 | LinearProgressIndicator( 109 | modifier = Modifier 110 | .padding(10.dp) 111 | .width(50.dp) 112 | ) 113 | } else { 114 | Text( 115 | modifier = Modifier.padding(10.dp), 116 | text = message, fontSize = 16.sp, color = Color.White 117 | ) 118 | } 119 | } 120 | } 121 | } 122 | } 123 | } 124 | 125 | @Composable 126 | fun AssistantMessage(modifier: Modifier, message: String, indexToHighlight: Pair?) { 127 | Column( 128 | modifier = modifier.animateContentSize() 129 | ) { 130 | Text(text = "Assistant", color = Color.White, fontWeight = FontWeight.Bold) 131 | Spacer(Modifier.height(5.dp)) 132 | Box( 133 | modifier = Modifier 134 | .fillMaxWidth(0.9f) 135 | .animateContentSize() 136 | ) { 137 | Box( 138 | modifier = Modifier 139 | .clip( 140 | RoundedCornerShape( 141 | topEnd = 10.dp, 142 | bottomEnd = 10.dp, 143 | bottomStart = 10.dp 144 | ) 145 | ) 146 | .animateContentSize() 147 | .background(Color.DarkGray), 148 | contentAlignment = Alignment.Center 149 | ) { 150 | Crossfade(message.isEmpty(), label = "") { 151 | if (it) { 152 | LinearProgressIndicator( 153 | modifier = Modifier 154 | .padding(10.dp) 155 | .width(50.dp) 156 | ) 157 | } else { 158 | val annotatedString = remember(message, indexToHighlight) { 159 | buildAnnotatedString { 160 | if(indexToHighlight==null){ 161 | append(message) 162 | } else { 163 | append(message.substring(0, indexToHighlight.first)) 164 | withStyle(style = SpanStyle(background = Color.Blue.copy(0.5f))) { 165 | append(message.substring(indexToHighlight.first,indexToHighlight.second)) 166 | } 167 | append(message.substring(startIndex = indexToHighlight.second)) 168 | } 169 | } 170 | } 171 | Text( 172 | modifier = Modifier.padding(10.dp), 173 | text = annotatedString, 174 | fontSize = 16.sp, 175 | color = Color.White 176 | ) 177 | } 178 | } 179 | } 180 | } 181 | } 182 | } 183 | 184 | 185 | @Composable 186 | fun AppTopBar( 187 | modifier: Modifier = Modifier, 188 | text: String, 189 | leadingIcon: ImageVector? = null, 190 | trailingIcon: ImageVector? = null, 191 | onLeadingIconClick: () -> Unit = {}, 192 | onTrailingIconClick: () -> Unit = {}, 193 | ) { 194 | Row( 195 | modifier = modifier 196 | .fillMaxWidth() 197 | .background(Color.DarkGray.copy(0.2f)) 198 | .padding(vertical = 5.dp, horizontal = 5.dp), 199 | verticalAlignment = Alignment.CenterVertically 200 | ) { 201 | if (leadingIcon != null) { 202 | IconButton( 203 | onClick = onLeadingIconClick 204 | ) { 205 | Icon( 206 | imageVector = leadingIcon, 207 | contentDescription = null, 208 | tint = Color.White 209 | ) 210 | } 211 | } else { 212 | Spacer(Modifier.width(20.dp)) 213 | } 214 | 215 | Text( 216 | modifier = Modifier 217 | .weight(1f), 218 | text = text, 219 | color = Color.White, 220 | fontSize = 24.sp, 221 | fontWeight = FontWeight.Bold 222 | ) 223 | 224 | trailingIcon?.let { 225 | IconButton( 226 | onClick = onTrailingIconClick 227 | ) { 228 | Icon( 229 | imageVector = trailingIcon, 230 | contentDescription = null, 231 | tint = Color.White 232 | ) 233 | } 234 | } 235 | } 236 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sagar/fluenty/ui/utils/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package com.sagar.fluenty.ui.utils 2 | 3 | import com.google.gson.Gson 4 | 5 | data class ErrorResponse( 6 | val error: Error? = null 7 | ) 8 | 9 | data class Error( 10 | val code: Int? = null, 11 | val message: String? = null, 12 | val status: String? = null 13 | ) 14 | fun extractErrorMessage(errorString: String): String { 15 | // Assuming the JSON response starts after "Unexpected Response:" 16 | val startIndex = errorString.indexOf("{") 17 | val endIndex = errorString.lastIndexOf("}") 18 | 19 | if (startIndex != -1 && endIndex != -1) { 20 | val jsonPart = errorString.substring(startIndex, endIndex + 1) 21 | 22 | val gson = Gson() 23 | val errorResponse = gson.fromJson(jsonPart, ErrorResponse::class.java) 24 | 25 | return errorResponse.error?.message ?: "Unknown error occurred" 26 | } else { 27 | return "Unknown error occurred" 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/english.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/drawable/english.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/english_literature.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/drawable/english_literature.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/drawable/logo.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable/mic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/drawable/mic.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/pronunciation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/drawable/pronunciation.jpg -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sagar0-0/Fluenty/c411b484aca373b985bbef5736ccc97c5a82f1e0/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Fluenty 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |