From 5a6d90176d5ce14d8672709dc109a52ae97257f9 Mon Sep 17 00:00:00 2001 From: souravanand001 Date: Sat, 30 May 2026 13:57:06 +0530 Subject: [PATCH] code refactor started with step 1 --- .gitignore | 1 + .../java/com/app/assistant/AssistActivity.kt | 9 +- .../java/com/app/assistant/MainActivity.kt | 384 ++- .../TextClassifierHelper.kt | 43 +- .../db/DynamicConversationDbHelper.kt | 55 +- .../db/DynamicConversationRepository.kt | 126 +- .../com/app/assistant/db/EncryptionUtil.kt | 16 +- .../com/app/assistant/db/SyncStateList.kt | 8 +- .../java/com/app/assistant/model/Contact.kt | 6 +- .../com/app/assistant/model/Conversation.kt | 11 +- .../java/com/app/assistant/model/Group.kt | 5 +- .../repository/ContactsRepository.kt | 113 + .../repository/SettingsRepository.kt | 53 + .../assistant/repository/WeatherRepository.kt | 203 ++ .../translation/TranslatorManager.kt | 80 +- .../com/app/assistant/ui/screen/ChatScreen.kt | 703 +++--- .../java/com/app/assistant/ui/theme/Color.kt | 2 +- .../java/com/app/assistant/ui/theme/Theme.kt | 58 +- .../java/com/app/assistant/ui/theme/Type.kt | 22 +- .../java/com/app/assistant/util/Category.kt | 11 +- .../java/com/app/assistant/util/Constants.kt | 10 +- .../java/com/app/assistant/util/LockState.kt | 15 +- .../app/assistant/viewmodel/MainViewModel.kt | 2131 ++++++++--------- .../viewmodel/MainViewModelFactory.kt | 11 +- .../com/app/assistant/viewmodel/UIEvent.kt | 41 + gradle/gradle-daemon-jvm.properties | 13 + 26 files changed, 2451 insertions(+), 1679 deletions(-) rename app/src/main/java/com/app/assistant/{Classifier => classifier}/TextClassifierHelper.kt (66%) create mode 100644 app/src/main/java/com/app/assistant/repository/ContactsRepository.kt create mode 100644 app/src/main/java/com/app/assistant/repository/SettingsRepository.kt create mode 100644 app/src/main/java/com/app/assistant/repository/WeatherRepository.kt create mode 100644 app/src/main/java/com/app/assistant/viewmodel/UIEvent.kt create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/.gitignore b/.gitignore index 807b00e..fd0c34d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ out/ # Gradle files .gradle/ +.gradle_home/ build/ # Local configuration file (sdk path, etc) diff --git a/app/src/main/java/com/app/assistant/AssistActivity.kt b/app/src/main/java/com/app/assistant/AssistActivity.kt index 9a4d31f..34cbd59 100644 --- a/app/src/main/java/com/app/assistant/AssistActivity.kt +++ b/app/src/main/java/com/app/assistant/AssistActivity.kt @@ -30,7 +30,7 @@ class AssistActivity : ComponentActivity() { finish() } - private fun moveToMainActivity(){ + private fun moveToMainActivity() { val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION intent.putExtra("speak", true) @@ -47,8 +47,9 @@ class AssistActivity : ComponentActivity() { } else { this.window.addFlags( WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or - WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/app/assistant/MainActivity.kt b/app/src/main/java/com/app/assistant/MainActivity.kt index 52e5037..426d946 100644 --- a/app/src/main/java/com/app/assistant/MainActivity.kt +++ b/app/src/main/java/com/app/assistant/MainActivity.kt @@ -1,33 +1,406 @@ package com.app.assistant +import android.Manifest import android.app.KeyguardManager +import android.content.ComponentCallbacks2 import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.location.LocationManager +import android.media.AudioManager import android.os.Build import android.os.Bundle +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener import android.util.Log import android.view.WindowManager +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.app.assistant.ui.screen.SetupUI import com.app.assistant.viewmodel.MainViewModel import com.app.assistant.viewmodel.MainViewModelFactory +import com.app.assistant.viewmodel.UIEvent +import com.google.android.gms.common.api.ResolvableApiException +import com.google.android.gms.location.LocationAvailability +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.LocationSettingsRequest +import com.google.android.gms.location.LocationSettingsResponse +import com.google.android.gms.location.Priority +import com.google.android.gms.location.SettingsClient +import com.google.android.gms.tasks.Task +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Locale class MainActivity : ComponentActivity() { - private val viewModel: MainViewModel by viewModels{ + private val viewModel: MainViewModel by viewModels { MainViewModelFactory(application, intent.getBooleanExtra("speak", false)) } + private lateinit var textToSpeech: TextToSpeech + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) allowOnLockScreen() enableEdgeToEdge() + initializeTextToSpeech() + + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiEvent.collect { event -> + when (event) { + is UIEvent.RequestPermissions -> { + ActivityCompat.requestPermissions(this@MainActivity, event.permissions, event.requestCode) + } + + is UIEvent.StartIntent -> { + try { + startActivity(event.intent) + } catch (e: Exception) { + Log.e("MainActivity", "Error starting intent", e) + } + } + + is UIEvent.ShowToast -> { + Toast.makeText(this@MainActivity, event.message, Toast.LENGTH_SHORT).show() + } + + is UIEvent.SpeakText -> { + speakResponse(event.text) + } + + is UIEvent.StopSpeaking -> { + if (::textToSpeech.isInitialized) { + textToSpeech.stop() + } + } + + is UIEvent.StartSpeechRecognition -> { + startSpeechRecognition() + } + + is UIEvent.StopSpeechRecognition -> { + // no-op + } + + is UIEvent.GetLocationForWeather -> { + getCurrentLocationForWeather(event) + } + + is UIEvent.ResolveLocationSettings -> { + try { + event.exception.startResolutionForResult(this@MainActivity, 104) + } catch (sendEx: IntentSender.SendIntentException) { + Log.e("MainActivity", "Error showing location settings dialog: ${sendEx.message}") + } + } + } + } + } + } + setContent { SetupUI(viewModel) } } + private fun initializeTextToSpeech() { + textToSpeech = + TextToSpeech(application) { status -> + if (status == TextToSpeech.SUCCESS) { + Log.d("TextToSpeech", "Initialization Success") + } else { + Log.d("TextToSpeech", "Initialization Failed") + } + } + } + + private fun speakResponse(plaintext: String) { + if (!::textToSpeech.isInitialized) return + textToSpeech.speak(plaintext, TextToSpeech.QUEUE_FLUSH, null, "left_for_now_id") + textToSpeech.setOnUtteranceProgressListener( + object : UtteranceProgressListener() { + override fun onDone(utteranceId: String?) { + viewModel.setSpeaking(false) + } + + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + viewModel.setSpeaking(false) + } + + override fun onStart(utteranceId: String?) { + viewModel.setSpeaking(true) + } + }, + ) + } + + private fun startSpeechRecognition() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), 101) + return + } + + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + audioManager.mode = AudioManager.MODE_IN_CALL + audioManager.isBluetoothScoOn = true + audioManager.startBluetoothSco() + + val originalRingerMode = audioManager.ringerMode + + if (::textToSpeech.isInitialized && textToSpeech.isSpeaking) { + textToSpeech.stop() + } + + val speechRecognizer = SpeechRecognizer.createSpeechRecognizer(this) + val speechRecognizerIntent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + if (viewModel.getIsTranslationEnabled()) { + val localeCode = getLocaleCode(viewModel.getActiveLanguageCode()) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, localeCode) + } else { + putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) + } + } + + speechRecognizer.setRecognitionListener( + object : RecognitionListener { + override fun onReadyForSpeech(bundle: Bundle?) { + if (originalRingerMode == AudioManager.RINGER_MODE_NORMAL) { + audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE + } + } + + override fun onBeginningOfSpeech() { + viewModel.setListening(true) + } + + override fun onRmsChanged(v: Float) {} + + override fun onBufferReceived(bytes: ByteArray?) {} + + override fun onEndOfSpeech() { + viewModel.setListening(false) + if (originalRingerMode == AudioManager.RINGER_MODE_NORMAL) { + lifecycleScope.launch { + delay(800) + audioManager.ringerMode = originalRingerMode + } + } + } + + override fun onError(errorCode: Int) { + viewModel.setListening(false) + if (originalRingerMode == AudioManager.RINGER_MODE_NORMAL) { + lifecycleScope.launch { + delay(800) + audioManager.ringerMode = originalRingerMode + } + } + } + + override fun onResults(bundle: Bundle?) { + viewModel.setListening(false) + val recognizedText = bundle?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.get(0) ?: "" + viewModel.onSpeechRecognized(recognizedText) + } + + override fun onPartialResults(bundle: Bundle) { + val recognizedText = bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.get(0) ?: "" + viewModel.onSpeechPartialResult(recognizedText) + } + + override fun onEvent( + i: Int, + bundle: Bundle?, + ) {} + }, + ) + + speechRecognizer.startListening(speechRecognizerIntent) + + registerComponentCallbacks( + object : ComponentCallbacks2 { + override fun onTrimMemory(level: Int) { + if (level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) { + audioManager.stopBluetoothSco() + audioManager.isBluetoothScoOn = false + speechRecognizer.destroy() + } + } + + override fun onConfigurationChanged(newConfig: Configuration) {} + + override fun onLowMemory() {} + }, + ) + } + + private fun getCurrentLocationForWeather(event: UIEvent.GetLocationForWeather) { + val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager + val gpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + + if (!gpsEnabled) { + promptEnableLocation() + viewModel.onLocationFailed(event.loadingItemId, event.speak, "GPS_OFF") + } else { + val fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) + val locationRequest = + LocationRequest + .Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) + .setWaitForAccurateLocation(true) + .setMaxUpdates(1) + .build() + + val locationCallback = + object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + val location = locationResult.lastLocation + if (location != null) { + viewModel.onLocationReceived( + location.latitude, + location.longitude, + event.itemId, + event.loadingItemId, + event.speak, + event.categoryName, + event.prompt, + ) + } else { + viewModel.onLocationFailed(event.loadingItemId, event.speak, "UNAVAILABLE") + } + fusedLocationClient.removeLocationUpdates(this) + } + + override fun onLocationAvailability(locationAvailability: LocationAvailability) { + if (!locationAvailability.isLocationAvailable) { + viewModel.onLocationFailed(event.loadingItemId, event.speak, "SUGGEST_CITY") + } + } + } + + val permissionGranted = + ActivityCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + if (permissionGranted) { + fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper()) + } else { + viewModel.onLocationFailed(event.loadingItemId, event.speak, "PERMISSION") + } + } + } + + private fun promptEnableLocation() { + val locationRequest = + LocationRequest + .Builder(100, 10000) + .setMinUpdateIntervalMillis(5000) + .build() + + val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest) + val settingsClient: SettingsClient = LocationServices.getSettingsClient(this) + val task: Task = settingsClient.checkLocationSettings(builder.build()) + + task.addOnFailureListener { exception -> + if (exception is ResolvableApiException) { + try { + exception.startResolutionForResult(this@MainActivity, 104) + } catch (sendEx: IntentSender.SendIntentException) { + Log.d("Location", "Error showing location settings dialog: ${sendEx.message}") + } + } + } + } + + private fun getLocaleCode(languageCode: String): String { + val languageCountryMapping = + mapOf( + "af" to "ZA", + "sq" to "AL", + "ar" to "SA", + "be" to "BY", + "bg" to "BG", + "bn" to "BD", + "ca" to "ES", + "zh" to "CN", + "hr" to "HR", + "cs" to "CZ", + "da" to "DK", + "nl" to "NL", + "en" to "US", + "eo" to "EO", + "et" to "EE", + "fi" to "FI", + "fr" to "FR", + "gl" to "ES", + "ka" to "GE", + "de" to "DE", + "el" to "GR", + "gu" to "IN", + "ht" to "HT", + "he" to "IL", + "hi" to "IN", + "hu" to "HU", + "is" to "IS", + "id" to "ID", + "ga" to "IE", + "it" to "IT", + "ja" to "JP", + "kn" to "IN", + "ko" to "KR", + "lt" to "LT", + "lv" to "LV", + "mk" to "MK", + "mr" to "IN", + "ms" to "MY", + "mt" to "MT", + "no" to "NO", + "fa" to "IR", + "pl" to "PL", + "pt" to "BR", + "ro" to "RO", + "ru" to "RU", + "sk" to "SK", + "sl" to "SI", + "es" to "ES", + "sv" to "SE", + "sw" to "KE", + "tl" to "PH", + "ta" to "IN", + "te" to "IN", + "th" to "TH", + "tr" to "TR", + "uk" to "UA", + "ur" to "PK", + "vi" to "VN", + "cy" to "GB", + ) + val countryCode = languageCountryMapping[languageCode] ?: Locale.getDefault().country + return "$languageCode-$countryCode" + } + private fun allowOnLockScreen() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { // Android 8.1+ setShowWhenLocked(true) @@ -35,7 +408,7 @@ class MainActivity : ComponentActivity() { } else { window.addFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, ) } @@ -46,13 +419,16 @@ class MainActivity : ComponentActivity() { keyguardManager.requestDismissKeyguard(this, null) } } - }catch (ex: Exception){ + } catch (ex: Exception) { ex.message?.let { Log.d("Exception Occurred", it) } } } override fun onDestroy() { super.onDestroy() + if (::textToSpeech.isInitialized) { + textToSpeech.shutdown() + } viewModel.shutdownResources() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/app/assistant/Classifier/TextClassifierHelper.kt b/app/src/main/java/com/app/assistant/classifier/TextClassifierHelper.kt similarity index 66% rename from app/src/main/java/com/app/assistant/Classifier/TextClassifierHelper.kt rename to app/src/main/java/com/app/assistant/classifier/TextClassifierHelper.kt index 4abe4c1..cd01df1 100644 --- a/app/src/main/java/com/app/assistant/Classifier/TextClassifierHelper.kt +++ b/app/src/main/java/com/app/assistant/classifier/TextClassifierHelper.kt @@ -1,4 +1,4 @@ -package com.app.assistant.Classifier +package com.app.assistant.classifier import android.content.Context import android.os.SystemClock @@ -11,7 +11,7 @@ import java.util.concurrent.ScheduledThreadPoolExecutor class TextClassifierHelper( var currentModel: String = MOBILEBERT, val context: Context, - val listener: TextResultsListener + val listener: TextResultsListener, ) { private lateinit var textClassifier: TextClassifier private lateinit var executor: ScheduledThreadPoolExecutor @@ -21,29 +21,40 @@ class TextClassifierHelper( } fun initClassifier() { - val baseOptionsBuilder = BaseOptions.builder() - .setModelAssetPath(currentModel) + val baseOptionsBuilder = + BaseOptions + .builder() + .setModelAssetPath(currentModel) try { val baseOptions = baseOptionsBuilder.build() - val optionsBuilder = TextClassifier.TextClassifierOptions.builder() - .setBaseOptions(baseOptions) + val optionsBuilder = + TextClassifier.TextClassifierOptions + .builder() + .setBaseOptions(baseOptions) val options = optionsBuilder.build() textClassifier = TextClassifier.createFromOptions(context, options) } catch (e: IllegalStateException) { listener.onError( "Text classifier failed to initialize. See error logs for " + - "details" + "details", ) Log.e( - TAG, "Text classifier failed to load the task with error: " + e - .message + TAG, + "Text classifier failed to load the task with error: " + + e + .message, ) } } // Run text classification using MediaPipe Text Classifier API - fun classify(text: String, itemId: Long, loadingItemId: Long, speak: Boolean) { + fun classify( + text: String, + itemId: Long, + loadingItemId: Long, + speak: Boolean, + ) { executor = ScheduledThreadPoolExecutor(1) executor.execute { @@ -61,11 +72,19 @@ class TextClassifierHelper( interface TextResultsListener { fun onError(error: String) - fun onResult(results: TextClassifierResult, inferenceTime: Long, inputText: String, itemId: Long, loadingItemId: Long, speak: Boolean) + + fun onResult( + results: TextClassifierResult, + inferenceTime: Long, + inputText: String, + itemId: Long, + loadingItemId: Long, + speak: Boolean, + ) } companion object { const val TAG = "TextClassifierHelper" const val MOBILEBERT = "model.tflite" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt b/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt index 9f04d63..e774b39 100644 --- a/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt +++ b/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt @@ -6,9 +6,9 @@ import android.database.sqlite.SQLiteOpenHelper import com.app.assistant.model.Conversation import kotlin.reflect.full.memberProperties - -class DynamicConversationDbHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { - +class DynamicConversationDbHelper( + context: Context, +) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { companion object { private const val DATABASE_NAME = "conversation.db" private const val DATABASE_VERSION = 1 @@ -16,7 +16,7 @@ class DynamicConversationDbHelper(context: Context) : SQLiteOpenHelper(context, private const val COLUMN_IV = "iv" // Groups Table - private const val TABLE_GROUPS = "groups" + private const val TABLE_GROUPS = "`groups`" private const val COLUMN_GROUP_ID = "group_id" private const val COLUMN_TITLE = "title" private const val COLUMN_CREATED_AT = "created_at" @@ -26,20 +26,25 @@ class DynamicConversationDbHelper(context: Context) : SQLiteOpenHelper(context, } override fun onCreate(db: SQLiteDatabase?) { - val createGroupsTableQuery = """ + val createGroupsTableQuery = + """ CREATE TABLE $TABLE_GROUPS ( $COLUMN_GROUP_ID INTEGER PRIMARY KEY AUTOINCREMENT, $COLUMN_TITLE TEXT, $COLUMN_CREATED_AT INTEGER ) - """.trimIndent() + """.trimIndent() db?.execSQL(createGroupsTableQuery) val createMessageTableQuery = buildCreateTableQuery() db?.execSQL(createMessageTableQuery) } - override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) { + override fun onUpgrade( + db: SQLiteDatabase?, + oldVersion: Int, + newVersion: Int, + ) { db?.execSQL("DROP TABLE IF EXISTS $TABLE_MESSAGES") db?.execSQL("DROP TABLE IF EXISTS $TABLE_GROUPS") onCreate(db) @@ -47,31 +52,31 @@ class DynamicConversationDbHelper(context: Context) : SQLiteOpenHelper(context, private fun buildCreateTableQuery(): String { val fields = Conversation::class.memberProperties - val columns = fields.filterNot { it.name == "isLoading" }.joinToString(", ") { field -> - val fieldType = mapFieldTypeToSQLiteType(field.returnType.toString()) - if (field.name == "id") { - "${field.name} INTEGER PRIMARY KEY" - } else { - "${field.name} $fieldType" + val columns = + fields.filterNot { it.name == "isLoading" }.joinToString(", ") { field -> + val fieldType = mapFieldTypeToSQLiteType(field.returnType.toString()) + if (field.name == "id") { + "${field.name} INTEGER PRIMARY KEY" + } else { + "${field.name} $fieldType" + } } - } return """ - CREATE TABLE $TABLE_MESSAGES ( - $columns, - iv TEXT, - $COLUMN_GROUP_ID INTEGER, - FOREIGN KEY ($COLUMN_GROUP_ID) REFERENCES $TABLE_GROUPS($COLUMN_GROUP_ID) - ) - """.trimIndent() + CREATE TABLE $TABLE_MESSAGES ( + $columns, + iv TEXT, + $COLUMN_GROUP_ID INTEGER, + FOREIGN KEY ($COLUMN_GROUP_ID) REFERENCES $TABLE_GROUPS($COLUMN_GROUP_ID) + ) + """.trimIndent() } - private fun mapFieldTypeToSQLiteType(kotlinType: String): String { - return when (kotlinType) { + private fun mapFieldTypeToSQLiteType(kotlinType: String): String = + when (kotlinType) { "kotlin.String" -> "TEXT" "kotlin.Int", "kotlin.Long", "kotlin.Boolean" -> "INTEGER" "java.net.URI" -> "TEXT" else -> "TEXT" } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt b/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt index e651fd1..8f60de8 100644 --- a/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt +++ b/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt @@ -17,8 +17,9 @@ import java.util.Locale import kotlin.reflect.full.memberProperties import kotlin.reflect.full.primaryConstructor -class DynamicConversationRepository(private val context: Context) { - +class DynamicConversationRepository( + private val context: Context, +) { companion object { private const val TABLE_GROUPS = "groups" private const val COLUMN_GROUP_ID = "group_id" @@ -34,10 +35,11 @@ class DynamicConversationRepository(private val context: Context) { private suspend fun startNewChat(msg: String): Long { withContext(Dispatchers.IO) { val db = dbHelper.writableDatabase - val contentValues = ContentValues().apply { - put(COLUMN_TITLE, generateChatTitle(msg)) - put(COLUMN_CREATED_AT, System.nanoTime()) - } + val contentValues = + ContentValues().apply { + put(COLUMN_TITLE, generateChatTitle(msg)) + put(COLUMN_CREATED_AT, System.nanoTime()) + } currentGroupId = db.insert(TABLE_GROUPS, null, contentValues) } return currentGroupId @@ -58,14 +60,15 @@ class DynamicConversationRepository(private val context: Context) { val db = dbHelper.writableDatabase val (encryptedMap, iv) = encryptConversation(conversation) - val values = ContentValues().apply { - encryptedMap.forEach { (key, value) -> put(key, value) } - put("iv", iv) - put(COLUMN_GROUP_ID, currentGroupId) - } - try{ - db.insert(TABLE_MESSAGES, null, values) - }catch (e: Exception){ + val values = + ContentValues().apply { + encryptedMap.forEach { (key, value) -> put(key, value) } + put("iv", iv) + put(COLUMN_GROUP_ID, currentGroupId) + } + try { + db.insert(TABLE_MESSAGES, null, values) + } catch (e: Exception) { e.message?.let { Log.d("Test", it) } } } @@ -77,24 +80,28 @@ class DynamicConversationRepository(private val context: Context) { db.delete( TABLE_MESSAGES, "id = ?", - arrayOf(id.toString()) + arrayOf(id.toString()), ) } } - suspend fun updateMessage(oldConversation: Conversation, newConversation: Conversation) { + suspend fun updateMessage( + oldConversation: Conversation, + newConversation: Conversation, + ) { withContext(Dispatchers.IO) { val db = dbHelper.writableDatabase val (encryptedMap, iv) = encryptConversation(newConversation) - val values = ContentValues().apply { - encryptedMap.forEach { (key, value) -> put(key, value) } - put("iv", iv) - } + val values = + ContentValues().apply { + encryptedMap.forEach { (key, value) -> put(key, value) } + put("iv", iv) + } db.update( TABLE_MESSAGES, values, "id = ?", - arrayOf(oldConversation.id.toString()) + arrayOf(oldConversation.id.toString()), ) } } @@ -113,15 +120,16 @@ class DynamicConversationRepository(private val context: Context) { val groupList = mutableListOf() withContext(Dispatchers.IO) { val db = dbHelper.readableDatabase - val cursor: Cursor = db.query( - TABLE_GROUPS, - arrayOf(COLUMN_GROUP_ID, COLUMN_TITLE), - null, - null, - null, - null, - "$COLUMN_GROUP_ID DESC" // Sort by latest created - ) + val cursor: Cursor = + db.query( + TABLE_GROUPS, + arrayOf(COLUMN_GROUP_ID, COLUMN_TITLE), + null, + null, + null, + null, + "$COLUMN_GROUP_ID DESC", // Sort by latest created + ) while (cursor.moveToNext()) { val groupId = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_GROUP_ID)) @@ -133,20 +141,20 @@ class DynamicConversationRepository(private val context: Context) { return groupList } - suspend fun loadMessagesForGroup(groupId: Long): MutableList { val tempList = mutableListOf() withContext(Dispatchers.IO) { val db = dbHelper.readableDatabase - val cursor: Cursor = db.query( - TABLE_MESSAGES, - null, - "$COLUMN_GROUP_ID = ?", - arrayOf(groupId.toString()), - null, - null, - COLUMN_MESSAGE_ID // Sort by latest created - ) + val cursor: Cursor = + db.query( + TABLE_MESSAGES, + null, + "$COLUMN_GROUP_ID = ?", + arrayOf(groupId.toString()), + null, + null, + COLUMN_MESSAGE_ID, // Sort by latest created + ) while (cursor.moveToNext()) { val conversation = cursorToConversation(cursor) @@ -165,21 +173,31 @@ class DynamicConversationRepository(private val context: Context) { properties.filter { it.name != "isLoading" }.forEach { property -> val columnName = property.name val columnIndex = cursor.getColumnIndexOrThrow(columnName) - val value = when (property.returnType.toString()) { - "kotlin.String" -> cursor.getString(columnIndex)//?.let { EncryptionUtil.decrypt(it, iv) } - "kotlin.Int" -> cursor.getInt(columnIndex) - "kotlin.Long" -> cursor.getLong(columnIndex) - "kotlin.Boolean" -> cursor.getString(columnIndex).toBooleanStrictOrNull() ?: false - "java.net.URI" -> URI(cursor.getString(columnIndex)) - else -> null - } + val value = + when (property.returnType.toString()) { + "kotlin.String" -> cursor.getString(columnIndex) + + // ?.let { EncryptionUtil.decrypt(it, iv) } + + "kotlin.Int" -> cursor.getInt(columnIndex) + + "kotlin.Long" -> cursor.getLong(columnIndex) + + "kotlin.Boolean" -> cursor.getString(columnIndex).toBooleanStrictOrNull() ?: false + + "java.net.URI" -> URI(cursor.getString(columnIndex)) + + else -> null + } fieldMap[columnName] = value } return Conversation::class.constructors.first().callBy( - Conversation::class.primaryConstructor!!.parameters - .filter { it.name != "isLoading" } // Ignore "isLoading" - .associateWith { parameter -> fieldMap[parameter.name] } + Conversation::class + .primaryConstructor!! + .parameters + .filter { it.name != "isLoading" } // Ignore "isLoading" + .associateWith { parameter -> fieldMap[parameter.name] }, ) } @@ -191,11 +209,11 @@ class DynamicConversationRepository(private val context: Context) { properties.forEach { property -> val key = property.name val value = property.getter.call(conversation)?.toString() - if (value != null && key!="isLoading") { - //encryptedMap[key] = EncryptionUtil.encrypt(value, iv).first + if (value != null && key != "isLoading") { + // encryptedMap[key] = EncryptionUtil.encrypt(value, iv).first encryptedMap[key] = value } } return Pair(encryptedMap, iv) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/app/assistant/db/EncryptionUtil.kt b/app/src/main/java/com/app/assistant/db/EncryptionUtil.kt index 17474cf..0aac174 100644 --- a/app/src/main/java/com/app/assistant/db/EncryptionUtil.kt +++ b/app/src/main/java/com/app/assistant/db/EncryptionUtil.kt @@ -27,7 +27,10 @@ object EncryptionUtil { } // Encrypt a string and return encrypted text and IV - fun encrypt(input: String, ivString: String): Pair { + fun encrypt( + input: String, + ivString: String, + ): Pair { val iv = Base64.decode(ivString, Base64.DEFAULT) val cipher = Cipher.getInstance(TRANSFORMATION) val spec = GCMParameterSpec(TAG_LENGTH_BIT, iv) @@ -39,7 +42,10 @@ object EncryptionUtil { } // Decrypt an encrypted string using IV - fun decrypt(encryptedInput: String, ivString: String): String { + fun decrypt( + encryptedInput: String, + ivString: String, + ): String { val iv = Base64.decode(ivString, Base64.DEFAULT) val cipher = Cipher.getInstance(TRANSFORMATION) val spec = GCMParameterSpec(TAG_LENGTH_BIT, iv) @@ -58,12 +64,10 @@ object EncryptionUtil { } // Save and restore secret key as Base64 (for persistence) - fun getSecretKeyAsString(): String { - return Base64.encodeToString(secretKey.encoded, Base64.DEFAULT) - } + fun getSecretKeyAsString(): String = Base64.encodeToString(secretKey.encoded, Base64.DEFAULT) fun restoreSecretKeyFromString(keyString: String): SecretKey { val decodedKey = Base64.decode(keyString, Base64.DEFAULT) return SecretKeySpec(decodedKey, 0, decodedKey.size, ALGORITHM) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/app/assistant/db/SyncStateList.kt b/app/src/main/java/com/app/assistant/db/SyncStateList.kt index 799354f..b84b295 100644 --- a/app/src/main/java/com/app/assistant/db/SyncStateList.kt +++ b/app/src/main/java/com/app/assistant/db/SyncStateList.kt @@ -10,9 +10,8 @@ import kotlinx.coroutines.launch class SyncStateList( private val repository: DynamicConversationRepository, // Repository to handle DB operations - private val stateList: SnapshotStateList = mutableStateListOf() + private val stateList: SnapshotStateList = mutableStateListOf(), ) : MutableList by stateList { - override fun add(element: Conversation): Boolean { val result = stateList.add(element) if (result && !element.isLoading) { @@ -31,7 +30,10 @@ class SyncStateList( return result } - override fun set(index: Int, element: Conversation): Conversation { + override fun set( + index: Int, + element: Conversation, + ): Conversation { val oldElement = stateList[index] stateList[index] = element CoroutineScope(Dispatchers.IO).launch { diff --git a/app/src/main/java/com/app/assistant/model/Contact.kt b/app/src/main/java/com/app/assistant/model/Contact.kt index 528f44a..e916933 100644 --- a/app/src/main/java/com/app/assistant/model/Contact.kt +++ b/app/src/main/java/com/app/assistant/model/Contact.kt @@ -1,3 +1,7 @@ package com.app.assistant.model -data class Contact(val id: String ,val name: String, val phoneNumber: String) \ No newline at end of file +data class Contact( + val id: String, + val name: String, + val phoneNumber: String, +) diff --git a/app/src/main/java/com/app/assistant/model/Conversation.kt b/app/src/main/java/com/app/assistant/model/Conversation.kt index b9aa12e..582a210 100644 --- a/app/src/main/java/com/app/assistant/model/Conversation.kt +++ b/app/src/main/java/com/app/assistant/model/Conversation.kt @@ -2,4 +2,13 @@ package com.app.assistant.model import com.app.assistant.util.Category import java.net.URI -data class Conversation(var id: Long = System.currentTimeMillis() * 1_000_000 + (System.nanoTime() % 1_000_000), var englishText: String, var translatedText: String, val isMe: Boolean, val isLoading: Boolean = false, var category: String = Category.OTHER.name, val contentURL: String ="", val navigationURI: URI=URI("") ) \ No newline at end of file +data class Conversation( + var id: Long = System.currentTimeMillis() * 1_000_000 + (System.nanoTime() % 1_000_000), + var englishText: String, + var translatedText: String, + val isMe: Boolean, + val isLoading: Boolean = false, + var category: String = Category.OTHER.name, + val contentURL: String = "", + val navigationURI: URI = URI(""), +) diff --git a/app/src/main/java/com/app/assistant/model/Group.kt b/app/src/main/java/com/app/assistant/model/Group.kt index 59cd45f..0376804 100644 --- a/app/src/main/java/com/app/assistant/model/Group.kt +++ b/app/src/main/java/com/app/assistant/model/Group.kt @@ -1,3 +1,6 @@ package com.app.assistant.model -data class Group(val groupId: Long, val title: String) \ No newline at end of file +data class Group( + val groupId: Long, + val title: String, +) diff --git a/app/src/main/java/com/app/assistant/repository/ContactsRepository.kt b/app/src/main/java/com/app/assistant/repository/ContactsRepository.kt new file mode 100644 index 0000000..1e7d127 --- /dev/null +++ b/app/src/main/java/com/app/assistant/repository/ContactsRepository.kt @@ -0,0 +1,113 @@ +package com.app.assistant.repository + +import android.annotation.SuppressLint +import android.content.Context +import android.provider.ContactsContract +import com.app.assistant.model.Contact +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.max + +class ContactsRepository( + private val context: Context, +) { + @SuppressLint("Range") + suspend fun searchContact(prompt: String): Contact? { + val contentResolver = context.contentResolver + val contactList = mutableListOf() + + withContext(Dispatchers.IO) { + val cursor = + contentResolver.query( + ContactsContract.Data.CONTENT_URI, + arrayOf( + ContactsContract.Data.CONTACT_ID, + ContactsContract.Data.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER, + ), + "${ContactsContract.Data.MIMETYPE} =? AND ${ContactsContract.Data.CONTACT_ID} IS NOT NULL", + arrayOf(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE), + null, + ) + + cursor?.use { contactCursor -> + while (contactCursor.moveToNext()) { + val contactId = contactCursor.getString(contactCursor.getColumnIndex(ContactsContract.Data.CONTACT_ID)) + val name = contactCursor.getString(contactCursor.getColumnIndex(ContactsContract.Data.DISPLAY_NAME)) + val phoneNumber = contactCursor.getString(contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)) + contactList.add(Contact(contactId, name, phoneNumber)) + } + } + } + + val contactName = cleanAndExtractName(prompt) + return findBestMatch(contactName, contactList) + } + + private fun cleanAndExtractName(command: String): String { + val stopWords = setOf("can", "you", "make", "call", "dial", "number", "to", "please", "the", "a", "on") + val words = command.lowercase().trim().split(" ") + return words.filter { !stopWords.contains(it) }.joinToString(" ") + } + + private fun findBestMatch( + inputString: String, + nameList: List, + similarityThreshold: Double = 0.7, + ): Contact? { + if (inputString.isEmpty()) { + return null + } + val inputLower = inputString.lowercase().trim() + var exactMatch: Contact? = null + var bestSubstringMatch: Contact? = null + var bestFuzzyMatch: Contact? = null + var bestFuzzyScore = 0.0 + + for (contact in nameList) { + val nameLower = contact.name.lowercase().trim() + + // Check for exact match + if (inputLower == nameLower) { + return contact + } + + // Check for exact match within tokens + val tokens = nameLower.split(" ") + for (token in tokens) { + when { + inputLower == token -> { + exactMatch = contact + } + + token.contains(inputLower) -> { + bestSubstringMatch = contact + } + + else -> { + val similarityScore = stringSimilarity(inputLower, token) + if (similarityScore > bestFuzzyScore) { + bestFuzzyScore = similarityScore + bestFuzzyMatch = contact + } + } + } + } + } + + return when { + exactMatch != null -> exactMatch + bestSubstringMatch != null -> bestSubstringMatch + bestFuzzyScore >= similarityThreshold -> bestFuzzyMatch + else -> null + } + } + + private fun stringSimilarity( + a: String, + b: String, + ): Double { + val matcher = a.commonPrefixWith(b).length.toDouble() / max(a.length, b.length).toDouble() + return matcher + } +} diff --git a/app/src/main/java/com/app/assistant/repository/SettingsRepository.kt b/app/src/main/java/com/app/assistant/repository/SettingsRepository.kt new file mode 100644 index 0000000..d639cb2 --- /dev/null +++ b/app/src/main/java/com/app/assistant/repository/SettingsRepository.kt @@ -0,0 +1,53 @@ +package com.app.assistant.repository + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys + +class SettingsRepository( + context: Context, +) { + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + + private val securedPreferences: SharedPreferences = + EncryptedSharedPreferences.create( + "secure_prefs", + MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), + context.applicationContext, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + fun getIsTranslationEnabled(): Boolean = sharedPreferences.getBoolean("is_translation_enabled", false) + + fun setTranslationEnabled(enabled: Boolean) { + sharedPreferences.edit().putBoolean("is_translation_enabled", enabled).apply() + } + + fun getActiveLanguageCode(): String = sharedPreferences.getString("active_language_code", "en") ?: "en" + + fun setActiveLanguageCode(languageCode: String) { + sharedPreferences.edit().putString("active_language_code", languageCode).apply() + } + + fun saveKeys( + youtubeApiKey: String, + chatApiKey: String, + ) { + with(securedPreferences.edit()) { + if (youtubeApiKey.isNotBlank()) { + putString("youtube_api_key", youtubeApiKey) + } + if (chatApiKey.isNotBlank()) { + putString("chat_api_key", chatApiKey) + } + apply() + } + } + + fun getYoutubeApiKey(): String? = securedPreferences.getString("youtube_api_key", null) + + fun getChatApiKey(): String? = securedPreferences.getString("chat_api_key", null) +} diff --git a/app/src/main/java/com/app/assistant/repository/WeatherRepository.kt b/app/src/main/java/com/app/assistant/repository/WeatherRepository.kt new file mode 100644 index 0000000..5c0d069 --- /dev/null +++ b/app/src/main/java/com/app/assistant/repository/WeatherRepository.kt @@ -0,0 +1,203 @@ +package com.app.assistant.repository + +import android.content.Context +import android.location.Geocoder +import android.util.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.util.Locale + +class WeatherRepository( + private val context: Context, +) { + private val client = OkHttpClient() + + fun extractPlaceName(inputText: String): String { + // Pattern to match place names after "in" or "for", excluding trailing words like "today", "tomorrow", etc. + val pattern = + Regex( + "\\b(?:in|for)\\s+([A-Za-z]+(?:\\s+[A-Za-z]+)*)" + + "(?=\\b(?:\\s+(today|now|tomorrow|report|forecast|currently|evening|morning|afternoon)\\b|\\s*$))", + ) + + // Search for a match in the input text + val match = pattern.find(inputText) + + // Check if the captured group does not match excluded words (e.g., "tomorrow") and return it + val placeName = + match + ?.groups + ?.get(1) + ?.value + ?.trim() + return if (placeName != null && + !placeName.lowercase().matches(Regex("today|now|tomorrow|report|forecast|currently|evening|morning|afternoon")) + ) { + placeName + } else { + "" + } + } + + suspend fun getCoordinatesFromCity(cityName: String): Pair? { + val deferredResult = CompletableDeferred?>() + + val urlBuilder = + "https://geocoding-api.open-meteo.com/v1/search" + .toHttpUrlOrNull() + ?.newBuilder() + ?.addQueryParameter("name", cityName) + ?.build() + + val request = + Request + .Builder() + .url(urlBuilder.toString()) + .build() + + client.newCall(request).enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + Log.e("WeatherRepository", "Request failed: $e") + deferredResult.completeExceptionally(e) + } + + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + deferredResult.complete(extractCoordinates(body, cityName)) + } else { + Log.e("WeatherRepository", "Request unsuccessful: ${response.code}") + deferredResult.cancel() + } + } + }, + ) + + return deferredResult.await() + } + + private fun extractCoordinates( + body: String, + cityName: String, + ): Pair? { + if (body.isEmpty()) return null + val jsonObject = JSONObject(body) + var latitude: Double? = null + var longitude: Double? = null + + try { + val results = jsonObject.getJSONArray("results") + + for (i in 0 until results.length()) { + val result = results.getJSONObject(i) + if (result.getString("name").equals(cityName, ignoreCase = true)) { + latitude = result.getDouble("latitude") + longitude = result.getDouble("longitude") + break + } + } + + if ((latitude == null || longitude == null) && results.length() > 0) { + val firstResult = results.getJSONObject(0) + latitude = firstResult.getDouble("latitude") + longitude = firstResult.getDouble("longitude") + } + } catch (e: Exception) { + e.message?.let { Log.d("extractCoordinates Excepetion", it) } + } + + return if (latitude != null && longitude != null) Pair(latitude, longitude) else null + } + + fun getCityNameFromLocation( + latitude: Double, + longitude: Double, + ): String? { + val geocoder = Geocoder(context, Locale.getDefault()) + return try { + val addresses = geocoder.getFromLocation(latitude, longitude, 1) + if (addresses?.isNotEmpty() == true) { + addresses[0].locality // This gives the city name + } else { + null + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + suspend fun getWeatherData( + latitude: Double, + longitude: Double, + ): JSONObject? { + val deferredResult = CompletableDeferred() + + val urlBuilder = + "https://api.open-meteo.com/v1/forecast" + .toHttpUrlOrNull() + ?.newBuilder() + ?.addQueryParameter("latitude", latitude.toString()) + ?.addQueryParameter("longitude", longitude.toString()) + ?.addQueryParameter("daily", "temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max") + ?.addQueryParameter("hourly", "temperature_2m,precipitation,windspeed_10m") + ?.addQueryParameter("timezone", "auto") + ?.addQueryParameter("forecast_days", "10") + ?.build() + + val request = + Request + .Builder() + .url(urlBuilder.toString()) + .build() + + client.newCall(request).enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + Log.e("WeatherRepository", "Weather data request failed: $e") + deferredResult.complete(null) + } + + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + try { + deferredResult.complete(JSONObject(body)) + } catch (e: JSONException) { + Log.e("WeatherRepository", "Error parsing JSON: $e") + deferredResult.complete(null) + } + } else { + Log.e("WeatherRepository", "Weather data request unsuccessful: ${response.code}") + deferredResult.complete(null) + } + } + }, + ) + + return deferredResult.await() + } +} diff --git a/app/src/main/java/com/app/assistant/translation/TranslatorManager.kt b/app/src/main/java/com/app/assistant/translation/TranslatorManager.kt index 6e98f65..b5f3c2c 100644 --- a/app/src/main/java/com/app/assistant/translation/TranslatorManager.kt +++ b/app/src/main/java/com/app/assistant/translation/TranslatorManager.kt @@ -10,13 +10,16 @@ import com.google.mlkit.nl.translate.Translator import com.google.mlkit.nl.translate.TranslatorOptions class TranslatorManager { - private var toEnglishTranslator: Translator? = null private var fromEnglishTranslator: Translator? = null private val modelManager = RemoteModelManager.getInstance() - fun setupTranslators(selectedLanguageCode: String, onResult: (Boolean) -> Unit) { - modelManager.getDownloadedModels(TranslateRemoteModel::class.java) + fun setupTranslators( + selectedLanguageCode: String, + onResult: (Boolean) -> Unit, + ) { + modelManager + .getDownloadedModels(TranslateRemoteModel::class.java) .addOnSuccessListener { downloadedModels -> val downloadedLanguages = downloadedModels.map { it.language }.toSet() @@ -29,14 +32,14 @@ class TranslatorManager { if (!success) { onResult(false) return@downloadLanguageModel - }else{ + } else { // Download English model if not already downloaded if (!isEnglishModelDownloaded) { downloadLanguageModel(TranslateLanguage.ENGLISH) { successEnglish -> if (!successEnglish) { onResult(false) return@downloadLanguageModel - }else{ + } else { onResult(true) } } @@ -53,8 +56,7 @@ class TranslatorManager { // Set up the translators for the selected language and English initializeTranslators(selectedLanguageCode) - } - .addOnFailureListener { e -> + }.addOnFailureListener { e -> Log.e("Translator", "Failed to get downloaded models: ${e.message}") onResult(false) } @@ -63,57 +65,69 @@ class TranslatorManager { // Helper function to initialize translators for the selected language private fun initializeTranslators(selectedLanguageCode: String) { // Set up translator for selected language -> English - val toEnglishOptions = TranslatorOptions.Builder() - .setSourceLanguage(selectedLanguageCode) - .setTargetLanguage(TranslateLanguage.ENGLISH) - .build() + val toEnglishOptions = + TranslatorOptions + .Builder() + .setSourceLanguage(selectedLanguageCode) + .setTargetLanguage(TranslateLanguage.ENGLISH) + .build() toEnglishTranslator = Translation.getClient(toEnglishOptions) // Set up translator for English -> selected language - val fromEnglishOptions = TranslatorOptions.Builder() - .setSourceLanguage(TranslateLanguage.ENGLISH) - .setTargetLanguage(selectedLanguageCode) - .build() + val fromEnglishOptions = + TranslatorOptions + .Builder() + .setSourceLanguage(TranslateLanguage.ENGLISH) + .setTargetLanguage(selectedLanguageCode) + .build() fromEnglishTranslator = Translation.getClient(fromEnglishOptions) } - // Use this method to translate from selected language to English - fun translateToEnglish(text: String, callback: (translatedText: String?) -> Unit) { - toEnglishTranslator?.translate(text) + fun translateToEnglish( + text: String, + callback: (translatedText: String?) -> Unit, + ) { + toEnglishTranslator + ?.translate(text) ?.addOnSuccessListener { translatedText -> callback(translatedText) - } - ?.addOnFailureListener { e -> + }?.addOnFailureListener { e -> Log.e("Translator", "Translation to English failed: ${e.message}") callback(null) } } // Use this method to translate from English to the selected language - fun translateFromEnglish(text: String, callback: (translatedText: String?) -> Unit) { - fromEnglishTranslator?.translate(text) + fun translateFromEnglish( + text: String, + callback: (translatedText: String?) -> Unit, + ) { + fromEnglishTranslator + ?.translate(text) ?.addOnSuccessListener { translatedText -> callback(translatedText) - } - ?.addOnFailureListener { e -> + }?.addOnFailureListener { e -> Log.e("Translator", "Translation from English failed: ${e.message}") callback(null) } } - private fun downloadLanguageModel(languageCode: String, onDownloadComplete: (Boolean) -> Unit) { + private fun downloadLanguageModel( + languageCode: String, + onDownloadComplete: (Boolean) -> Unit, + ) { val languageModel = TranslateRemoteModel.Builder(languageCode).build() val conditions = DownloadConditions.Builder().build() - modelManager.download(languageModel, conditions) + modelManager + .download(languageModel, conditions) .addOnSuccessListener { Log.d("Translator", "Language model for $languageCode downloaded successfully.") onDownloadComplete(true) - } - .addOnFailureListener { e -> + }.addOnFailureListener { e -> Log.e("Translator", "Failed to download language model for $languageCode: ${e.message}") onDownloadComplete(false) } @@ -122,17 +136,17 @@ class TranslatorManager { // Delete the previously downloaded language model private fun deletePreviousLanguageModel(languageCode: String) { val languageModel = TranslateRemoteModel.Builder(languageCode).build() - modelManager.deleteDownloadedModel(languageModel) + modelManager + .deleteDownloadedModel(languageModel) .addOnSuccessListener { Log.d("Translator", "Language model for $languageCode deleted successfully.") - } - .addOnFailureListener { e -> + }.addOnFailureListener { e -> Log.e("Translator", "Failed to delete language model for $languageCode: ${e.message}") } } - fun closeTranslator(){ + fun closeTranslator() { toEnglishTranslator?.close() fromEnglishTranslator?.close() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt b/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt index bc02392..8cce031 100644 --- a/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt +++ b/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt @@ -126,7 +126,6 @@ import com.app.assistant.ui.theme.AssistantTheme import com.app.assistant.util.Category import com.app.assistant.viewmodel.MainViewModel import kotlinx.coroutines.currentCoroutineContext -import org.commonmark.node.Text as CText import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -148,11 +147,11 @@ import org.commonmark.node.StrongEmphasis import org.commonmark.node.ThematicBreak import org.commonmark.parser.Parser import kotlin.math.hypot +import org.commonmark.node.Text as CText @OptIn(ExperimentalMaterial3Api::class) @Composable fun SetupUI(viewModel: MainViewModel) { - AssistantTheme { var showCopyIcon by remember { mutableStateOf(false) } var selectedItemIndex by remember { mutableStateOf(null) } @@ -166,9 +165,10 @@ fun SetupUI(viewModel: MainViewModel) { val isCustomUI by viewModel.isCustomUI.collectAsState() val isCustomUIHalfPage by viewModel.isCustomUIHalfPage.collectAsState() - val drawerState = remember(isCustomUI) { - DrawerState(DrawerValue.Closed) - } + val drawerState = + remember(isCustomUI) { + DrawerState(DrawerValue.Closed) + } val scope = rememberCoroutineScope() val groupList by viewModel.groupList.collectAsState(initial = emptyList()) @@ -178,14 +178,17 @@ fun SetupUI(viewModel: MainViewModel) { ModalNavigationDrawer( drawerState = drawerState, drawerContent = { - if (isCustomUI) null else { + if (isCustomUI) { + null + } else { ModalDrawerSheet { Text("Chats", modifier = Modifier.padding(16.dp)) Column( - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - .verticalScroll(rememberScrollState()) + modifier = + Modifier + .padding(horizontal = 8.dp) + .weight(1f) + .verticalScroll(rememberScrollState()), ) { groupList.forEach { group -> NavigationDrawerItem( @@ -194,7 +197,7 @@ fun SetupUI(viewModel: MainViewModel) { onClick = { viewModel.loadMessagesFromGroup(group.groupId) scope.launch { drawerState.close() } - } + }, ) } } @@ -204,52 +207,53 @@ fun SetupUI(viewModel: MainViewModel) { icon = { Icon( painter = (painterResource(id = R.drawable.ic_settings)), - contentDescription = "Settings" + contentDescription = "Settings", ) }, selected = false, onClick = { showSettingsDialog = true scope.launch { drawerState.close() } - } + }, ) NavigationDrawerItem( label = { Text(text = "Start new chat") }, icon = { Icon( painter = (painterResource(id = R.drawable.ic_add_new_chat)), - contentDescription = "Start new chat" + contentDescription = "Start new chat", ) }, selected = false, onClick = { viewModel.newChat() scope.launch { drawerState.close() } - } + }, ) } } - } + }, ) { - val scaffoldContent = @Composable{ + val scaffoldContent = @Composable { Scaffold( - containerColor = if(isCustomUIHalfPage) Color.Transparent else MaterialTheme.colorScheme.background, - modifier = if (isCustomUI) { - Modifier - .height(screenHeight / 2) - .padding(16.dp, 16.dp, 16.dp, 8.dp) - .clip(RoundedCornerShape(24.dp)) - .background(Color.Transparent, shape = RoundedCornerShape(16.dp)) - .clickable { - if (!isCustomUIHalfPage) { - Modifier.clickable(onClick = {}) - } else { - backPressedDispatcher?.onBackPressed() + containerColor = if (isCustomUIHalfPage) Color.Transparent else MaterialTheme.colorScheme.background, + modifier = + if (isCustomUI) { + Modifier + .height(screenHeight / 2) + .padding(16.dp, 16.dp, 16.dp, 8.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.Transparent, shape = RoundedCornerShape(16.dp)) + .clickable { + if (!isCustomUIHalfPage) { + Modifier.clickable(onClick = {}) + } else { + backPressedDispatcher?.onBackPressed() + } } - } - } else { - Modifier - }, + } else { + Modifier + }, topBar = { if (!isCustomUI) { TopAppBar( @@ -263,7 +267,7 @@ fun SetupUI(viewModel: MainViewModel) { }) { Icon( painter = (painterResource(id = R.drawable.ic_menu)), - contentDescription = "Menu" + contentDescription = "Menu", ) } }, @@ -272,20 +276,26 @@ fun SetupUI(viewModel: MainViewModel) { if (showCopyIcon) { IconButton(onClick = { selectedItemIndex?.let { index -> - val textToCopy = when { - viewModel.getIsTranslationEnabled() -> viewModel.chatList.getOrNull( - index - )?.translatedText + val textToCopy = + when { + viewModel.getIsTranslationEnabled() -> { + viewModel.chatList + .getOrNull( + index, + )?.translatedText + } - else -> viewModel.chatList.getOrNull(index)?.englishText - } ?: "" + else -> { + viewModel.chatList.getOrNull(index)?.englishText + } + } ?: "" clipboardManager.setText(AnnotatedString(textToCopy)) selectedItemIndex = null } }) { Icon( painter = painterResource(id = R.drawable.ic_content_copy), - contentDescription = "Copy" + contentDescription = "Copy", ) } IconButton(onClick = { @@ -293,7 +303,7 @@ fun SetupUI(viewModel: MainViewModel) { }) { Icon( painter = painterResource(id = R.drawable.ic_delete), - contentDescription = "Delete" + contentDescription = "Delete", ) } } @@ -302,54 +312,55 @@ fun SetupUI(viewModel: MainViewModel) { }) { Icon( painter = (painterResource(id = R.drawable.ic_translate)), - contentDescription = "Translate" + contentDescription = "Translate", ) } if (viewModel.chatList.isNotEmpty()) { IconButton(onClick = { clearShowDialog = true }) { Icon( painter = (painterResource(id = R.drawable.ic_restart)), - contentDescription = "Restart Chat" + contentDescription = "Restart Chat", ) } } - } + }, ) } - if(!isCustomUIHalfPage && isCustomUI){ + if (!isCustomUIHalfPage && isCustomUI) { Box( - modifier = Modifier - .fillMaxWidth() - .height(24.dp) - .then( - Modifier - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { /* Optional: trigger on drag start */ }, - onDragEnd = { - viewModel.expandToFullScreen() - }, - onDrag = { change, dragAmount -> - //track dragAmount if needed - } - ) - }) - .clickable { viewModel.expandToFullScreen() }, - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxWidth() + .height(24.dp) + .then( + Modifier + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { /* Optional: trigger on drag start */ }, + onDragEnd = { + viewModel.expandToFullScreen() + }, + onDrag = { change, dragAmount -> + // track dragAmount if needed + }, + ) + }, + ).clickable { viewModel.expandToFullScreen() }, + contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .width(36.dp) - .height(4.dp) - .background( - color = Color.Gray, - shape = RoundedCornerShape(2.dp) - ) + modifier = + Modifier + .width(36.dp) + .height(4.dp) + .background( + color = Color.Gray, + shape = RoundedCornerShape(2.dp), + ), ) } - } - } + }, ) { innerPadding -> MyLayout( modifier = Modifier.padding(innerPadding), @@ -359,7 +370,7 @@ fun SetupUI(viewModel: MainViewModel) { selectedItemIndex = selectedItemIndex, onSelectedItemChange = { newIndex -> selectedItemIndex = newIndex }, viewModel = viewModel, - isCustomUI = isCustomUI + isCustomUI = isCustomUI, ) } } @@ -380,34 +391,34 @@ fun SetupUI(viewModel: MainViewModel) { if (tapped) { backPressedDispatcher?.onBackPressed() } - } + }, ) Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = animatedBoxAlpha)) - .imePadding() - .animateContentSize() - .clickable( - enabled = !tapped, - onClick = { - tapped = true - } - ), - contentAlignment = Alignment.BottomCenter + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = animatedBoxAlpha)) + .imePadding() + .animateContentSize() + .clickable( + enabled = !tapped, + onClick = { + tapped = true + }, + ), + contentAlignment = Alignment.BottomCenter, ) { scaffoldContent() } - } - else{ + } else { scaffoldContent() } } if (showBottomSheet) { var searchQuery by remember { mutableStateOf("") } - val filteredItems = viewModel.languages.filter {it.first.contains(searchQuery, true)}.map { it } + val filteredItems = viewModel.languages.filter { it.first.contains(searchQuery, true) }.map { it } val isLanguageLoading by viewModel.isLanguageLoading.collectAsState() val isTranslationEnabled by viewModel.isTranslationEnabled.collectAsState() @@ -415,63 +426,72 @@ fun SetupUI(viewModel: MainViewModel) { onDismissRequest = { showBottomSheet = false }, - sheetState = sheetState + sheetState = sheetState, ) { Column( modifier = Modifier.fillMaxSize(), ) { - Row(modifier = Modifier - .fillMaxWidth() - .padding(16.dp, 0.dp, 16.dp, 16.dp)) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp, 0.dp, 16.dp, 16.dp), + ) { Text( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically), - text = "Chat in different languages" + modifier = + Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = "Chat in different languages", ) - Switch(checked = isTranslationEnabled, + Switch( + checked = isTranslationEnabled, onCheckedChange = { viewModel.updateTranslationEnabled(it) }, - modifier = Modifier.align( - Alignment.CenterVertically)) + modifier = + Modifier.align(Alignment.CenterVertically), + ) } - TextField( - modifier = Modifier - .fillMaxWidth() - .padding(0.dp) - .imePadding(), + modifier = + Modifier + .fillMaxWidth() + .padding(0.dp) + .imePadding(), value = searchQuery, placeholder = { Text("Search...") }, singleLine = true, onValueChange = { searchQuery = it }, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent - ), - enabled = isTranslationEnabled + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + enabled = isTranslationEnabled, ) - if(isLanguageLoading) + if (isLanguageLoading) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(0.dp, 16.dp, 0.dp, 0.dp), + modifier = + Modifier + .fillMaxSize() + .padding(0.dp, 16.dp, 0.dp, 0.dp), ) { items(items = filteredItems) { lang -> Text( text = lang.first, - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = isTranslationEnabled) { - if (isTranslationEnabled) { - viewModel.onItemSelected(lang.second) - } - } - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = isTranslationEnabled) { + if (isTranslationEnabled) { + viewModel.onItemSelected(lang.second) + } + }.padding(16.dp), ) } } @@ -495,11 +515,11 @@ fun SetupUI(viewModel: MainViewModel) { TextButton(onClick = { clearShowDialog = false }) { Text("No") } - } + }, ) } - if(deleteShowDialog){ + if (deleteShowDialog) { AlertDialog( onDismissRequest = { deleteShowDialog = false @@ -524,7 +544,7 @@ fun SetupUI(viewModel: MainViewModel) { }) { Text("No") } - } + }, ) } @@ -539,22 +559,23 @@ fun SetupUI(viewModel: MainViewModel) { onSave = { ytKey, chKey -> viewModel.saveKeys(ytKey, chKey) showSettingsDialog = false - } + }, ) } BackHandler(enabled = selectedItemIndex != null) { - selectedItemIndex = null // Clear selection instead of handling system back + selectedItemIndex = null // Clear selection instead of handling system back } } } @Composable +@Suppress("ktlint:standard:function-naming") fun SettingsDialog( initialYoutubeKey: String, initialChatKey: String, onDismiss: () -> Unit, - onSave: (String, String) -> Unit + onSave: (String, String) -> Unit, ) { var apiKey1 by rememberSaveable { mutableStateOf(initialYoutubeKey) } var apiKey2 by rememberSaveable { mutableStateOf(initialChatKey) } @@ -571,13 +592,13 @@ fun SettingsDialog( TextField( value = apiKey1, onValueChange = { apiKey1 = it }, - label = { Text("YouTube API Key") } + label = { Text("YouTube API Key") }, ) Spacer(modifier = Modifier.height(8.dp)) TextField( value = apiKey2, onValueChange = { apiKey2 = it }, - label = { Text("Chat API Key") } + label = { Text("Chat API Key") }, ) Spacer(modifier = Modifier.height(16.dp)) Text("App Version: $versionName") @@ -592,18 +613,19 @@ fun SettingsDialog( Button(onClick = onDismiss) { Text("Cancel") } - } + }, ) } @Composable +@Suppress("ktlint:standard:function-naming") fun MyLayout( modifier: Modifier = Modifier, onShowCopyIconChange: (Boolean) -> Unit, selectedItemIndex: Int?, onSelectedItemChange: (Int) -> Unit, viewModel: MainViewModel, - isCustomUI: Boolean + isCustomUI: Boolean, ) { val boxes = viewModel.chatList val keyboardController = LocalSoftwareKeyboardController.current @@ -611,12 +633,13 @@ fun MyLayout( val scrollState = rememberLazyListState() val context = LocalContext.current - val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - context.getSystemService(VibratorManager::class.java)?.defaultVibrator - } else { - @Suppress("DEPRECATION") - context.getSystemService(Vibrator::class.java) - } + val vibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Vibrator::class.java) + } LaunchedEffect(Unit) { viewModel.showToastEvent.collectLatest { message: String -> @@ -640,13 +663,16 @@ fun MyLayout( Column(modifier = modifier.fillMaxSize()) { LazyColumn( - modifier = Modifier - .weight(1f) - .padding(0.dp, 0.dp, 0.dp, 0.dp), - verticalArrangement = if (isCustomUI) - Arrangement.spacedBy(8.dp, Alignment.Bottom) - else - Arrangement.spacedBy(8.dp), + modifier = + Modifier + .weight(1f) + .padding(0.dp, 0.dp, 0.dp, 0.dp), + verticalArrangement = + if (isCustomUI) { + Arrangement.spacedBy(8.dp, Alignment.Bottom) + } else { + Arrangement.spacedBy(8.dp) + }, state = scrollState, ) { itemsIndexed(boxes) { index, conversation -> @@ -655,7 +681,7 @@ fun MyLayout( index = index, isSelected = index == selectedItemIndex, onLongClick = { newIndex -> onSelectedItemChange(newIndex) }, - viewModel = viewModel + viewModel = viewModel, ) } } @@ -664,7 +690,7 @@ fun MyLayout( UserInputField( focusManager = focusManager, keyboardController = keyboardController, - viewModel = viewModel + viewModel = viewModel, ) } } @@ -677,16 +703,17 @@ fun MyLayout( } @Composable +@Suppress("ktlint:standard:function-naming") private fun UserInputField( focusManager: FocusManager, keyboardController: SoftwareKeyboardController, - viewModel: MainViewModel + viewModel: MainViewModel, ) { val isSpeaking by viewModel.isSpeaking.collectAsState() val isListening by viewModel.isListening.collectAsState() val containerColor = TextFieldDefaults.colors().unfocusedContainerColor - val rippleColor = adjustContrast(containerColor) + val rippleColor = adjustContrast(containerColor) // State for current radius + alpha val rippleRadius = remember { Animatable(0f) } @@ -697,10 +724,11 @@ private fun UserInputField( LaunchedEffect(isListening, textFieldSize) { if (isListening && textFieldSize != IntSize.Zero) { - val maxRadius = hypot( - textFieldSize.width / 2f, - textFieldSize.height / 2f - ) + val maxRadius = + hypot( + textFieldSize.width / 2f, + textFieldSize.height / 2f, + ) while (currentCoroutineContext().isActive) { // keep running until LaunchedEffect cancels rippleRadius.snapTo(0f) rippleAlpha.snapTo(0.5f) @@ -709,12 +737,12 @@ private fun UserInputField( launch { rippleRadius.animateTo( targetValue = maxRadius, - animationSpec = tween(1000, easing = LinearEasing) + animationSpec = tween(1000, easing = LinearEasing), ) } rippleAlpha.animateTo( targetValue = 0f, - animationSpec = tween(1000, easing = LinearEasing) + animationSpec = tween(1000, easing = LinearEasing), ) } } else { @@ -723,40 +751,42 @@ private fun UserInputField( } } - Box( - modifier = Modifier - .fillMaxWidth() - .imePadding() - .padding(8.dp) - .clip(RoundedCornerShape(20)) // clip ripple inside - .onGloballyPositioned { coords -> textFieldSize = coords.size } + modifier = + Modifier + .fillMaxWidth() + .imePadding() + .padding(8.dp) + .clip(RoundedCornerShape(20)) // clip ripple inside + .onGloballyPositioned { coords -> textFieldSize = coords.size }, ) { TextField( - modifier = Modifier - .fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth(), shape = RoundedCornerShape(20), value = viewModel.question.value, placeholder = { Text("Type here...") }, maxLines = 2, onValueChange = { viewModel.question.value = it }, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent - ), + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), trailingIcon = { Row(horizontalArrangement = Arrangement.End) { val context = LocalContext.current if (isSpeaking) { IconButton(onClick = { - if (viewModel.textToSpeech.isSpeaking) { + if (isSpeaking) { viewModel.stopTextToSpeech() } }) { Icon( painter = painterResource(id = R.drawable.ic_stop), - contentDescription = "Stop" + contentDescription = "Stop", ) } } else { @@ -770,18 +800,18 @@ private fun UserInputField( focusManager, keyboardController, context, - true + true, ) } }, onPartialResult = { recognizedText -> viewModel.question.value = recognizedText - } + }, ) }) { Icon( painter = painterResource(id = R.drawable.ic_mic), - contentDescription = "Mic" + contentDescription = "Mic", ) } } @@ -791,37 +821,40 @@ private fun UserInputField( focusManager, keyboardController, context, - false + false, ) } }) { Icon( imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send" + contentDescription = "Send", ) } } - } + }, ) Canvas( - modifier = Modifier - .matchParentSize() - .clip(RoundedCornerShape(20)) + modifier = + Modifier + .matchParentSize() + .clip(RoundedCornerShape(20)), ) { if (rippleAlpha.value > 0f) { drawCircle( color = rippleColor.copy(alpha = rippleAlpha.value), radius = rippleRadius.value, - center = center + center = center, ) } } } - } -fun adjustContrast(color: Color, factor: Float = 0.15f): Color { +fun adjustContrast( + color: Color, + factor: Float = 0.15f, +): Color { val isDark = color.luminance() < 0.5 return if (isDark) { // lighten in dark mode @@ -829,7 +862,7 @@ fun adjustContrast(color: Color, factor: Float = 0.15f): Color { red = (color.red + factor).coerceAtMost(1f), green = (color.green + factor).coerceAtMost(1f), blue = (color.blue + factor).coerceAtMost(1f), - alpha = color.alpha + alpha = color.alpha, ) } else { // darken in light mode @@ -837,49 +870,63 @@ fun adjustContrast(color: Color, factor: Float = 0.15f): Color { red = (color.red - factor).coerceAtLeast(0f), green = (color.green - factor).coerceAtLeast(0f), blue = (color.blue - factor).coerceAtLeast(0f), - alpha = color.alpha + alpha = color.alpha, ) } } - - @Composable +@Suppress("ktlint:standard:function-naming") fun ConversationItem( conversation: Conversation, index: Int, isSelected: Boolean, onLongClick: (Int) -> Unit, viewModel: MainViewModel, - backgroundColor: Color = if (conversation.isMe) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.surfaceContainerHighest, - cornerRadius: Dp = 20.dp + backgroundColor: Color = + if (conversation.isMe) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + cornerRadius: Dp = 20.dp, ) { val uriHandler = LocalUriHandler.current val isTranslateEnabled = viewModel.getIsTranslationEnabled() Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = if (conversation.isMe) Arrangement.Start else Arrangement.End + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = if (conversation.isMe) Arrangement.Start else Arrangement.End, ) { - if (conversation.isMe) + if (conversation.isMe) { Spacer(modifier = Modifier.weight(1f)) + } Card( Modifier .wrapContentWidth() .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.85f), shape = RoundedCornerShape(cornerRadius), - colors = CardDefaults.cardColors(containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else backgroundColor) + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + backgroundColor + }, + ), ) { if (conversation.isLoading) { LoadingDots() // Show loading animation when isLoading is true } else { - if(conversation.isMe) + if (conversation.isMe) { OtherCard(conversation, onLongClick, index, isTranslateEnabled) - else { + } else { when (conversation.category) { Category.CALL.name -> { MakeCall(uriHandler, conversation, isTranslateEnabled) @@ -921,20 +968,22 @@ fun ConversationItem( } } } - if (!conversation.isMe) + if (!conversation.isMe) { Spacer(modifier = Modifier.weight(1f)) + } } if (conversation.isMe) { Text( text = conversation.category, - modifier = Modifier - .padding(0.dp, 0.dp, 20.dp, 0.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(0.dp, 0.dp, 20.dp, 0.dp) + .fillMaxWidth(), maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.secondary, - textAlign = TextAlign.End + textAlign = TextAlign.End, ) } } @@ -944,21 +993,24 @@ fun ConversationItem( private fun StartNavigation( uriHandler: UriHandler, conversation: Conversation, - isTranslateEnabled: Boolean + isTranslateEnabled: Boolean, ) { Row( - modifier = Modifier - .padding(8.dp) - .clickable { - uriHandler.openUri(conversation.navigationURI.toString()) - }, horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = + Modifier + .padding(8.dp) + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Box( - modifier = Modifier - .background( - shape = CircleShape, - color = MaterialTheme.colorScheme.secondaryContainer - ), + modifier = + Modifier + .background( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + ), ) { Icon( modifier = Modifier.padding(8.dp), @@ -968,11 +1020,13 @@ private fun StartNavigation( } MarkdownText( - modifier = Modifier - .align(alignment = Alignment.CenterVertically) - .clickable { - uriHandler.openUri(conversation.navigationURI.toString()) - }, markdown = if(isTranslateEnabled) conversation.translatedText else conversation.englishText + modifier = + Modifier + .align(alignment = Alignment.CenterVertically) + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, + markdown = if (isTranslateEnabled) conversation.translatedText else conversation.englishText, ) } } @@ -981,15 +1035,16 @@ private fun StartNavigation( private fun PlaySong( uriHandler: UriHandler, conversation: Conversation, - isTranslateEnabled: Boolean + isTranslateEnabled: Boolean, ) { AsyncImage( - modifier = Modifier - .clickable { - uriHandler.openUri(conversation.navigationURI.toString()) - }, + modifier = + Modifier + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, model = conversation.contentURL, - contentDescription = if(isTranslateEnabled) conversation.translatedText else conversation.englishText + contentDescription = if (isTranslateEnabled) conversation.translatedText else conversation.englishText, ) } @@ -997,20 +1052,24 @@ private fun PlaySong( private fun MakeCall( uriHandler: UriHandler, conversation: Conversation, - isTranslateEnabled: Boolean + isTranslateEnabled: Boolean, ) { - Row(modifier = Modifier - .padding(8.dp) - .clickable { - uriHandler.openUri(conversation.navigationURI.toString()) - }, horizontalArrangement = Arrangement.spacedBy(8.dp) + Row( + modifier = + Modifier + .padding(8.dp) + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Box( - modifier = Modifier - .background( - shape = CircleShape, - color = MaterialTheme.colorScheme.secondaryContainer - ), + modifier = + Modifier + .background( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + ), ) { Icon( modifier = Modifier.padding(8.dp), @@ -1019,11 +1078,14 @@ private fun MakeCall( ) } - MarkdownText(modifier = Modifier - .align(alignment = Alignment.CenterVertically) - .clickable { - uriHandler.openUri(conversation.navigationURI.toString()) - }, markdown = if(isTranslateEnabled) conversation.translatedText else conversation.englishText + MarkdownText( + modifier = + Modifier + .align(alignment = Alignment.CenterVertically) + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, + markdown = if (isTranslateEnabled) conversation.translatedText else conversation.englishText, ) } } @@ -1032,12 +1094,14 @@ private fun MakeCall( private fun ShowWeather( uriHandler: UriHandler, conversation: Conversation, - isTranslateEnabled: Boolean -){ - MarkdownText(modifier = Modifier - .padding(16.dp) - .clickable { uriHandler.openUri(conversation.navigationURI.toString()) }, - markdown = if(isTranslateEnabled) conversation.translatedText else conversation.englishText + isTranslateEnabled: Boolean, +) { + MarkdownText( + modifier = + Modifier + .padding(16.dp) + .clickable { uriHandler.openUri(conversation.navigationURI.toString()) }, + markdown = if (isTranslateEnabled) conversation.translatedText else conversation.englishText, ) } @@ -1046,22 +1110,25 @@ private fun OtherCard( conversation: Conversation, onLongClick: (Int) -> Unit, index: Int, - isTranslateEnabled: Boolean + isTranslateEnabled: Boolean, ) { MarkdownText( - markdown = if (isTranslateEnabled && conversation.translatedText.isNotBlank()) - conversation.translatedText - else - conversation.englishText, - modifier = Modifier - .padding(16.dp, 8.dp) - .pointerInput(Unit) { - detectTapGestures( - onLongPress = { - onLongClick(index) - } - ) - } + markdown = + if (isTranslateEnabled && conversation.translatedText.isNotBlank()) { + conversation.translatedText + } else { + conversation.englishText + }, + modifier = + Modifier + .padding(16.dp, 8.dp) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + onLongClick(index) + }, + ) + }, ) } @@ -1073,31 +1140,37 @@ fun LoadingDots() { val dot1Offset by infiniteTransition.animateFloat( initialValue = 0f, targetValue = -10f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 400, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - initialStartOffset = StartOffset(0) // First dot starts immediately - ), label = "" + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(0), // First dot starts immediately + ), + label = "", ) val dot2Offset by infiniteTransition.animateFloat( initialValue = 0f, targetValue = -10f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 400, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - initialStartOffset = StartOffset(200) // Second dot starts with a delay - ), label = "" + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(200), // Second dot starts with a delay + ), + label = "", ) val dot3Offset by infiniteTransition.animateFloat( initialValue = 0f, targetValue = -10f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 400, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - initialStartOffset = StartOffset(400) // Third dot starts with a larger delay - ), label = "" + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(400), // Third dot starts with a larger delay + ), + label = "", ) // Unicode character for big dot (⏺ or ● for larger circles) @@ -1108,7 +1181,7 @@ fun LoadingDots() { Text( text = dotChar, modifier = Modifier.offset(y = dot1Offset.dp), - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) Spacer(modifier = Modifier.width(4.dp)) @@ -1116,7 +1189,7 @@ fun LoadingDots() { Text( text = dotChar, modifier = Modifier.offset(y = dot2Offset.dp), - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) Spacer(modifier = Modifier.width(4.dp)) @@ -1124,7 +1197,7 @@ fun LoadingDots() { Text( text = dotChar, modifier = Modifier.offset(y = dot3Offset.dp), - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) } } @@ -1132,14 +1205,15 @@ fun LoadingDots() { @Composable fun MarkdownText( modifier: Modifier = Modifier, - markdown: String + markdown: String, ) { - val annotatedText = remember(markdown) { - parseMarkdownToAnnotatedString(markdown) - } + val annotatedText = + remember(markdown) { + parseMarkdownToAnnotatedString(markdown) + } Text( text = annotatedText, - modifier = modifier + modifier = modifier, ) } @@ -1155,7 +1229,10 @@ fun parseMarkdownToAnnotatedString(markdown: String): AnnotatedString { * Recursively processes nodes. We only append a newline if there's a *next sibling*, * preventing extra blank lines at the very end. */ -private fun processNodes(node: Node, builder: AnnotatedString.Builder) { +private fun processNodes( + node: Node, + builder: AnnotatedString.Builder, +) { var child = node.firstChild while (child != null) { val nextSibling = child.next @@ -1163,18 +1240,21 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { is CText -> { builder.append(child.literal) } + is Emphasis -> { val start = builder.length processNodes(child, builder) val end = builder.length builder.addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) } + is StrongEmphasis -> { val start = builder.length processNodes(child, builder) val end = builder.length builder.addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) } + is Code -> { // Inline code (single backticks) val start = builder.length @@ -1184,9 +1264,10 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { builder.addStyle( SpanStyle(fontFamily = FontFamily.Monospace), start, - end + end, ) } + is Link -> { val start = builder.length processNodes(child, builder) @@ -1196,15 +1277,16 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { tag = "URL", annotation = child.destination, start = start, - end = end + end = end, ) // Style the link text builder.addStyle( SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline), start, - end + end, ) } + is Paragraph -> { // Process paragraph content processNodes(child, builder) @@ -1213,6 +1295,7 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { builder.append("\n") } } + is Heading -> { // Insert a newline before headings (for spacing), unless it's the first node if (builder.isNotEmpty() && !builder.toString().endsWith("\n")) { @@ -1222,20 +1305,22 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { processNodes(child, builder) val end = builder.length // Map heading level to style - val headingStyle = when (child.level) { - 1 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp) - 2 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp) - 3 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 20.sp) - 4 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp) - 5 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 16.sp) - else -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 14.sp) - } + val headingStyle = + when (child.level) { + 1 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp) + 2 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp) + 3 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 20.sp) + 4 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp) + 5 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 16.sp) + else -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 14.sp) + } builder.addStyle(headingStyle, start, end) // Only append a newline if there's another sibling if (nextSibling != null) { builder.append("\n") } } + is BlockQuote -> { // Optional: style block quotes in italics or different color val start = builder.length @@ -1244,12 +1329,13 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { builder.addStyle( SpanStyle(color = Color.Gray, fontStyle = FontStyle.Italic), start, - end + end, ) if (nextSibling != null) { builder.append("\n") } } + is BulletList -> { var listItem = child.firstChild while (listItem != null) { @@ -1265,6 +1351,7 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { builder.append("\n") } } + is OrderedList -> { var index = child.startNumber var listItem = child.firstChild @@ -1281,6 +1368,7 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { builder.append("\n") } } + is FencedCodeBlock -> { // Code block with triple backticks val start = builder.length @@ -1290,44 +1378,49 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { // Always dark background, white text builder.addStyle( SpanStyle( - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, ), start, - end + end, ) // Only append a newline if there's another sibling if (nextSibling != null) { builder.append("\n") } } + is IndentedCodeBlock -> { val start = builder.length builder.append(child.literal) val end = builder.length builder.addStyle( SpanStyle( - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, ), start, - end + end, ) if (nextSibling != null) { builder.append("\n") } } + is ThematicBreak -> { builder.append("\n----------------\n") if (nextSibling != null) { builder.append("\n") } } + is SoftLineBreak -> { // Usually rendered as a space in Markdown builder.append(" ") } + is HardLineBreak -> { builder.append("\n") } + else -> { processNodes(child, builder) } @@ -1337,6 +1430,4 @@ private fun processNodes(node: Node, builder: AnnotatedString.Builder) { } /** Quick helper to check if the builder already has text. */ -private fun AnnotatedString.Builder.isNotEmpty(): Boolean { - return this.length > 0 -} \ No newline at end of file +private fun AnnotatedString.Builder.isNotEmpty(): Boolean = this.length > 0 diff --git a/app/src/main/java/com/app/assistant/ui/theme/Color.kt b/app/src/main/java/com/app/assistant/ui/theme/Color.kt index 05ffa41..581271f 100644 --- a/app/src/main/java/com/app/assistant/ui/theme/Color.kt +++ b/app/src/main/java/com/app/assistant/ui/theme/Color.kt @@ -8,4 +8,4 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) diff --git a/app/src/main/java/com/app/assistant/ui/theme/Theme.kt b/app/src/main/java/com/app/assistant/ui/theme/Theme.kt index fa44c69..55a67a5 100644 --- a/app/src/main/java/com/app/assistant/ui/theme/Theme.kt +++ b/app/src/main/java/com/app/assistant/ui/theme/Theme.kt @@ -11,17 +11,18 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 +private val DarkColorScheme = + darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + ) +private val LightColorScheme = + lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, /* Other default colors to override background = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE), @@ -30,28 +31,35 @@ private val LightColorScheme = lightColorScheme( onTertiary = Color.White, onBackground = Color(0xFF1C1B1F), onSurface = Color(0xFF1C1B1F), - */ -) + */ + ) @Composable fun AssistantTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColorScheme - else -> LightColorScheme - } + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> { + DarkColorScheme + } + + else -> { + LightColorScheme + } + } MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content + colorScheme = colorScheme, + typography = Typography, + content = content, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/app/assistant/ui/theme/Type.kt b/app/src/main/java/com/app/assistant/ui/theme/Type.kt index 22515d0..1ab9d3f 100644 --- a/app/src/main/java/com/app/assistant/ui/theme/Type.kt +++ b/app/src/main/java/com/app/assistant/ui/theme/Type.kt @@ -7,14 +7,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), /* Other default text styles to override titleLarge = TextStyle( fontFamily = FontFamily.Default, @@ -30,5 +32,5 @@ val Typography = Typography( lineHeight = 16.sp, letterSpacing = 0.5.sp ) - */ -) \ No newline at end of file + */ + ) diff --git a/app/src/main/java/com/app/assistant/util/Category.kt b/app/src/main/java/com/app/assistant/util/Category.kt index 06c211c..0198f3f 100644 --- a/app/src/main/java/com/app/assistant/util/Category.kt +++ b/app/src/main/java/com/app/assistant/util/Category.kt @@ -1,5 +1,12 @@ package com.app.assistant.util enum class Category { - SONGS, CALL, ALARM, REMINDER, OTHER, NAVIGATION, SETTINGS, WEATHER -} \ No newline at end of file + SONGS, + CALL, + ALARM, + REMINDER, + OTHER, + NAVIGATION, + SETTINGS, + WEATHER, +} diff --git a/app/src/main/java/com/app/assistant/util/Constants.kt b/app/src/main/java/com/app/assistant/util/Constants.kt index 1d6d051..54b7d8b 100644 --- a/app/src/main/java/com/app/assistant/util/Constants.kt +++ b/app/src/main/java/com/app/assistant/util/Constants.kt @@ -1,6 +1,10 @@ package com.app.assistant.util object Constants { - const val MAIN_CONTEXT = "You are my smart assistant who answers just like normal human response, not too long. Don't refer yourself as an AI." - val CATEGORY_CONTEXT = "You are a assistant who can classify commands based on previous conversation, from these categories. "+ enumValues().joinToString(",") { it.name } +". Just say the category name." -} \ No newline at end of file + const val MAIN_CONTEXT = + "You are my smart assistant who answers just like normal human response, not too long. Don't refer yourself as an AI." + val CATEGORY_CONTEXT = + "You are a assistant who can classify commands based on previous conversation, from these categories. " + + enumValues().joinToString(",") { it.name } + + ". Just say the category name." +} diff --git a/app/src/main/java/com/app/assistant/util/LockState.kt b/app/src/main/java/com/app/assistant/util/LockState.kt index 631b639..5229706 100644 --- a/app/src/main/java/com/app/assistant/util/LockState.kt +++ b/app/src/main/java/com/app/assistant/util/LockState.kt @@ -4,14 +4,19 @@ sealed class LockState { object None : LockState() // Default state when no task is locked data class LockAlarm( - var day: String? = null, // Already processed day (e.g., "tomorrow") - var time: String? = null // Time to be provided + // Already processed day (e.g., "tomorrow") + var day: String? = null, + // Time to be provided + var time: String? = null, ) : LockState() data class LockReminder( - var day: String? = null, // Already processed day (e.g., "tomorrow") - var time: String? = null, // Time to be provided - var context: String? = null // Message for reminder + // Already processed day (e.g., "tomorrow") + var day: String? = null, + // Time to be provided + var time: String? = null, + // Message for reminder + var context: String? = null, ) : LockState() object LockNavigation : LockState() // No additional data needed diff --git a/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt b/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt index ac4d96a..3b6359c 100644 --- a/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt @@ -1,72 +1,38 @@ package com.app.assistant.viewmodel import android.Manifest -import android.Manifest.permission.CALL_PHONE -import android.Manifest.permission.READ_CONTACTS -import android.Manifest.permission.RECORD_AUDIO -import android.annotation.SuppressLint -import android.app.Activity import android.app.Application -import android.content.ComponentCallbacks2 -import android.content.Context import android.content.Intent -import android.content.IntentSender -import android.content.SharedPreferences import android.content.pm.PackageManager -import android.content.res.Configuration -import android.location.Geocoder -import android.location.LocationManager -import android.media.AudioManager -import android.media.MediaPlayer import android.net.Uri -import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.provider.AlarmClock -import android.provider.ContactsContract -import android.speech.RecognitionListener -import android.speech.RecognizerIntent -import android.speech.SpeechRecognizer -import android.speech.tts.TextToSpeech -import android.speech.tts.UtteranceProgressListener import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.SoftwareKeyboardController -import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.app.assistant.BuildConfig -import com.app.assistant.Classifier.TextClassifierHelper +import com.app.assistant.classifier.TextClassifierHelper import com.app.assistant.db.DynamicConversationRepository import com.app.assistant.db.SyncStateList -import com.app.assistant.model.Contact import com.app.assistant.model.Conversation import com.app.assistant.model.Group +import com.app.assistant.repository.ContactsRepository +import com.app.assistant.repository.SettingsRepository +import com.app.assistant.repository.WeatherRepository import com.app.assistant.translation.TranslatorManager import com.app.assistant.util.Category import com.app.assistant.util.Constants.MAIN_CONTEXT import com.app.assistant.util.LockState -import com.google.android.gms.common.api.ResolvableApiException -import com.google.android.gms.location.LocationAvailability -import com.google.android.gms.location.LocationCallback -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationResult -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.LocationSettingsRequest -import com.google.android.gms.location.LocationSettingsResponse -import com.google.android.gms.location.Priority -import com.google.android.gms.location.SettingsClient -import com.google.android.gms.tasks.Task import com.google.mediapipe.tasks.text.textclassifier.TextClassifierResult import com.google.mlkit.nl.translate.TranslateLanguage import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow @@ -81,38 +47,34 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.json.JSONArray -import org.json.JSONException import org.json.JSONObject import java.io.IOException import java.lang.reflect.Modifier import java.net.URI -import java.net.URLEncoder import java.net.UnknownHostException -import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -import kotlin.math.max -import androidx.core.net.toUri -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys - -class MainViewModel(application: Application, private val speak: Boolean) : AndroidViewModel(application) { - private var globalContext: Context? = application - private val sharedPreferences: SharedPreferences = - application.getSharedPreferences("app_prefs", Application.MODE_PRIVATE) +class MainViewModel( + application: Application, + private val speak: Boolean, + private val settingsRepository: SettingsRepository, + private val contactsRepository: ContactsRepository, + private val weatherRepository: WeatherRepository, +) : AndroidViewModel(application) { var question = mutableStateOf("") + // DB reference private val repository = DynamicConversationRepository(application) var chatList = SyncStateList(repository) private set var currentGroupId: Long = -1L private set - var languages : List> + var languages: List> var showBottomSheet = mutableStateOf(false) private set private val _isLanguageLoading = MutableStateFlow(false) @@ -121,23 +83,26 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr val showToastEvent = _showToastEvent.asSharedFlow() private val translatorManager = TranslatorManager() - //For custom ui + // For custom ui private val _isCustomUI = MutableStateFlow(false) val isCustomUI: StateFlow = _isCustomUI - //For custom ui half page + // For custom ui half page private val _isCustomUIHalfPage = MutableStateFlow(false) - val isCustomUIHalfPage : StateFlow = _isCustomUIHalfPage + val isCustomUIHalfPage: StateFlow = _isCustomUIHalfPage // MutableStateFlow for isTranslationEnabled - private val _isTranslationEnabled = MutableStateFlow( - sharedPreferences.getBoolean("is_translation_enabled", false) - ) + private val _isTranslationEnabled = + MutableStateFlow( + settingsRepository.getIsTranslationEnabled(), + ) val isTranslationEnabled: StateFlow = _isTranslationEnabled + // MutableStateFlow for activeLanguageCode - private val _activeLanguageCode = MutableStateFlow( - sharedPreferences.getString("active_language_code", "en") ?: "en" - ) + private val _activeLanguageCode = + MutableStateFlow( + settingsRepository.getActiveLanguageCode(), + ) private var _isSpeaking = MutableStateFlow(false) val isSpeaking: StateFlow = _isSpeaking @@ -147,21 +112,18 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr private val _groupList = MutableStateFlow>(emptyList()) val groupList: StateFlow> = _groupList.asStateFlow() - private val securedPreferences = EncryptedSharedPreferences.create( - "secure_prefs", // A name for the preference file - MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), // Master key for encryption - application.applicationContext, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) + // UI Event flow + private val _uiEvent = MutableSharedFlow() + val uiEvent: SharedFlow = _uiEvent.asSharedFlow() - // Text-to-Speech and Translators - lateinit var textToSpeech: TextToSpeech + // Callback properties for SpeechToText + var speechResultCallback: ((String) -> Unit)? = null + var speechPartialResultCallback: ((String) -> Unit)? = null - //Classifier + // Classifier private lateinit var classifierHelper: TextClassifierHelper - //LockState + // LockState var lockState: LockState = LockState.None set(value) { // Automatically clear any stored data when lockState is set to None @@ -172,54 +134,57 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } // Classifier listener - private val listener = object : - TextClassifierHelper.TextResultsListener { - override fun onResult( - results: TextClassifierResult, - inferenceTime: Long, - inputText: String, - itemId: Long, - loadingItemId: Long, - speak: Boolean - ) { - processClassifierResponse(inferenceTime, results, inputText, itemId, loadingItemId, speak) - } + private val listener = + object : + TextClassifierHelper.TextResultsListener { + override fun onResult( + results: TextClassifierResult, + inferenceTime: Long, + inputText: String, + itemId: Long, + loadingItemId: Long, + speak: Boolean, + ) { + processClassifierResponse(inferenceTime, results, inputText, itemId, loadingItemId, speak) + } - override fun onError(error: String) { - Log.d("Classifier error", "Unable to classify$error") + override fun onError(error: String) { + Log.d("Classifier error", "Unable to classify$error") + } } - } init { - if(speak) { + if (speak) { _isCustomUI.value = true _isCustomUIHalfPage.value = true _isListening.value = true } - initializeTextToSpeech() languages = getPublicStaticFinalStringsWithNames(TranslateLanguage::class.java) - if(speak) + if (speak) { startSpeechRecognition() + } initializeTextClassifier() - initializeTranslator(); - loadGroup(); + initializeTranslator() + loadGroup() } - fun expandToFullScreen(){ - if(_isCustomUI.value) + fun expandToFullScreen() { + if (_isCustomUI.value) { _isCustomUI.value = false - if(_isCustomUIHalfPage.value) + } + if (_isCustomUIHalfPage.value) { _isCustomUIHalfPage.value = false + } } - private fun loadGroup(){ + private fun loadGroup() { viewModelScope.launch { _groupList.value = repository.loadAllGroups().toList() } } - fun loadMessagesFromGroup(groupId: Long){ - viewModelScope.launch{ + fun loadMessagesFromGroup(groupId: Long) { + viewModelScope.launch { val newMessages = repository.loadMessagesForGroup(groupId) chatList.clear() chatList.addAll(newMessages) @@ -227,50 +192,53 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } } - private fun initializeTranslator(){ - if(getIsTranslationEnabled() && getActiveLanguageCode() != ""){ + private fun initializeTranslator() { + if (getIsTranslationEnabled() && getActiveLanguageCode() != "") { setupTranslator(getActiveLanguageCode()) } } - private fun initializeTextClassifier(){ + private fun initializeTextClassifier() { // Create the classification helper that will do the heavy lifting - classifierHelper = TextClassifierHelper( - context = getApplication().applicationContext, - listener = listener - ) + classifierHelper = + TextClassifierHelper( + context = getApplication().applicationContext, + listener = listener, + ) classifierHelper.initClassifier() } // Function to update isTranslationEnabled and persist the value fun updateTranslationEnabled(enabled: Boolean) { _isTranslationEnabled.value = enabled - viewModelScope.launch { - sharedPreferences.edit().putBoolean("is_translation_enabled", enabled).apply() - } + settingsRepository.setTranslationEnabled(enabled) } // Function to get the current value of isTranslationEnabled - fun getIsTranslationEnabled(): Boolean { - return _isTranslationEnabled.value - } + fun getIsTranslationEnabled(): Boolean = _isTranslationEnabled.value // Function to update activeLanguageCode and persist the value fun updateActiveLanguageCode(languageCode: String) { _activeLanguageCode.value = languageCode - viewModelScope.launch { - sharedPreferences.edit().putString("active_language_code", languageCode).apply() - } + settingsRepository.setActiveLanguageCode(languageCode) } // Function to get the current value of ActiveLanguageCode - fun getActiveLanguageCode(): String { - return _activeLanguageCode.value + fun getActiveLanguageCode(): String = _activeLanguageCode.value + + fun setSpeaking(speaking: Boolean) { + _isSpeaking.value = speaking } - fun stopTextToSpeech(){ - textToSpeech.stop() - _isSpeaking.value = false; + fun setListening(listening: Boolean) { + _isListening.value = listening + } + + fun stopTextToSpeech() { + viewModelScope.launch { + _uiEvent.emit(UIEvent.StopSpeaking) + } + _isSpeaking.value = false } private fun getPublicStaticFinalStringsWithNames(clazz: Class<*>): List> { @@ -295,29 +263,15 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr return publicStaticFinalStringsWithNames } - private fun initializeTextToSpeech() { - textToSpeech = TextToSpeech(getApplication()) { status -> - if (status == TextToSpeech.SUCCESS) { - Log.d("TextToSpeech", "Initialization Success") - } else { - Log.d("TextToSpeech", "Initialization Failed") - } - } - } - fun shutdownResources() { - textToSpeech.shutdown() translatorManager.closeTranslator() - globalContext = null } fun processQuestion( focusManager: FocusManager? = null, keyboardController: SoftwareKeyboardController? = null, - context: Context, - speak: Boolean = false + speak: Boolean = false, ) { - globalContext = context val originalQuestion = question.value focusManager?.clearFocus() keyboardController?.hide() @@ -325,29 +279,30 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr viewModelScope.launch { val itemId: Long - val newItem: Conversation = if(getIsTranslationEnabled()){ - Conversation(englishText = "", translatedText = originalQuestion, isMe = true) - - }else{ - Conversation(englishText = originalQuestion, translatedText = "", isMe = true) - } + val newItem: Conversation = + if (getIsTranslationEnabled()) { + Conversation(englishText = "", translatedText = originalQuestion, isMe = true) + } else { + Conversation(englishText = originalQuestion, translatedText = "", isMe = true) + } chatList.add(newItem) - if(_isCustomUIHalfPage.value) + if (_isCustomUIHalfPage.value) { _isCustomUIHalfPage.value = false + } itemId = newItem.id val loadingItem = Conversation(englishText = "", translatedText = "", isMe = false, isLoading = true) chatList.add(loadingItem) val loadingItemId = loadingItem.id - //_conversationListUI.add(loadingItem) - val translatedQuestionInEnglish = if (getIsTranslationEnabled()) { - translatorManager.translateToEnglishSuspend(originalQuestion) ?: originalQuestion - } else { - originalQuestion - } + val translatedQuestionInEnglish = + if (getIsTranslationEnabled()) { + translatorManager.translateToEnglishSuspend(originalQuestion) ?: originalQuestion + } else { + originalQuestion + } val processedQuestion = cleanAndPunctuate(translatedQuestionInEnglish) chatList.indexOfFirst { it.id == itemId }.takeIf { it != -1 }?.let { index -> @@ -355,11 +310,11 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr chatList.set(index, updatedItem) } - if(lockState != LockState.None && isNegativeOrNotRequired(processedQuestion)){ + if (lockState != LockState.None && isNegativeOrNotRequired(processedQuestion)) { lockState = LockState.None classifierHelper.classify(processedQuestion, itemId, loadingItemId, speak) - }else{ - when(val state = lockState){ + } else { + when (val state = lockState) { is LockState.LockAlarm -> handleAlarmLockState(itemId, loadingItemId, speak, state) is LockState.LockReminder -> handleReminderLockState(itemId, loadingItemId, speak, state) is LockState.LockNavigation -> TODO() @@ -369,29 +324,69 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } } + fun processQuestion( + focusManager: FocusManager? = null, + keyboardController: SoftwareKeyboardController? = null, + context: android.content.Context, + speak: Boolean = false, + ) { + processQuestion(focusManager, keyboardController, speak) + } + private fun processClassifierResponse( inferenceTime: Long, results: TextClassifierResult, inputText: String, itemId: Long, loadingItemId: Long, - speak: Boolean + speak: Boolean, ) { - val categories = results.classificationResult() - .classifications().first() - .categories() + val categories = + results + .classificationResult() + .classifications() + .first() + .categories() // Define words for each category using Category enum - val categoryWords = mapOf( - Category.CALL to listOf("call", "phone", "ring", "connect", "need", "get", "dial"), - Category.SONGS to listOf("music", "song", "play", "tune", "listen"), - Category.ALARM to listOf("alarm", "wake", "set", "remind", "morning"), - Category.REMINDER to listOf("remind", "notify", "alert", "remember"), - Category.NAVIGATION to listOf("navigate","find","show","directions","take me to","where is","get to","go to","navigation to","way"), - Category.WEATHER to listOf("sunny", "cloudy", "rain", "temperature", "umbrella", "weather", "outside", "report", "forecast", "carry", "going to", "how is", "will it be"), - Category.SETTINGS to emptyList(), - Category.OTHER to emptyList() // "OTHER" doesn't need specific words - ) + val categoryWords = + mapOf( + Category.CALL to listOf("call", "phone", "ring", "connect", "need", "get", "dial"), + Category.SONGS to listOf("music", "song", "play", "tune", "listen"), + Category.ALARM to listOf("alarm", "wake", "set", "remind", "morning"), + Category.REMINDER to listOf("remind", "notify", "alert", "remember"), + Category.NAVIGATION to + listOf( + "navigate", + "find", + "show", + "directions", + "take me to", + "where is", + "get to", + "go to", + "navigation to", + "way", + ), + Category.WEATHER to + listOf( + "sunny", + "cloudy", + "rain", + "temperature", + "umbrella", + "weather", + "outside", + "report", + "forecast", + "carry", + "going to", + "how is", + "will it be", + ), + Category.SETTINGS to emptyList(), + Category.OTHER to emptyList(), // "OTHER" doesn't need specific words + ) // Find the category with the highest score val highestCategory = categories.maxByOrNull { it.score() } @@ -402,33 +397,34 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr val containsWord = wordsToMatch.any { word -> word in inputText.lowercase() } // Set final category based on word verification - val finalCategory = if (wordsToMatch.isNotEmpty() && !containsWord) { - // Force the category to "OTHER" if no matching words found for the category - categories.firstOrNull { it.categoryName().equals(Category.OTHER.name, ignoreCase = true) } - } else { - // Keep the highest category if matching words found or no words defined - highestCategory - } + val finalCategory = + if (wordsToMatch.isNotEmpty() && !containsWord) { + // Force the category to "OTHER" if no matching words found for the category + categories.firstOrNull { it.categoryName().equals(Category.OTHER.name, ignoreCase = true) } + } else { + // Keep the highest category if matching words found or no words defined + highestCategory + } val categoryFromString = getCategoryFromString(finalCategory?.categoryName()?.uppercase() ?: "").toString() - CallCommand(categoryFromString, itemId, loadingItemId, speak) + callCommand(categoryFromString, itemId, loadingItemId, speak) if (finalCategory != null) { - chatList.find{ it.id == itemId }?.category = getCategoryFromString(finalCategory.categoryName()).name + chatList.find { it.id == itemId }?.category = getCategoryFromString(finalCategory.categoryName()).name Log.d("Classifier Inference Result", finalCategory.categoryName()) } } - private fun CallCommand( + private fun callCommand( categoryFromString: String, itemId: Long, loadingItemId: Long, - speak: Boolean + speak: Boolean, ) { when (categoryFromString) { Category.CALL.name -> { - Call(itemId, loadingItemId, speak, Category.CALL) + callContact(itemId, loadingItemId, speak, Category.CALL) } Category.OTHER.name -> { @@ -436,55 +432,57 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } Category.SETTINGS.name -> { - // TODO callAI(loadingItemId, speak, Category.OTHER) } Category.SONGS.name -> { - Song(itemId, loadingItemId, speak, Category.SONGS) + playSong(itemId, loadingItemId, speak, Category.SONGS) } Category.NAVIGATION.name -> { - Navigation(itemId, loadingItemId, speak, Category.NAVIGATION) + navigate(itemId, loadingItemId, speak, Category.NAVIGATION) } Category.WEATHER.name -> { - Weather(itemId, loadingItemId, speak, Category.WEATHER) + fetchWeather(itemId, loadingItemId, speak, Category.WEATHER) } Category.REMINDER.name -> { - Reminder(itemId, loadingItemId, speak, Category.REMINDER) + setReminder(itemId, loadingItemId, speak, Category.REMINDER) } Category.ALARM.name -> { - Alarm(itemId, loadingItemId, speak, Category.ALARM) + setAlarm(itemId, loadingItemId, speak, Category.ALARM) } } } // Function to map a category name to a Category enum - private fun getCategoryFromString(categoryName: String): Category { - return Category.entries.find { it.name == categoryName.uppercase() } ?: Category.OTHER - } + private fun getCategoryFromString(categoryName: String): Category = + Category.entries.find { it.name == categoryName.uppercase() } ?: Category.OTHER - private fun callAI(loadingItemId: Long, speak: Boolean, category: Category) { + private fun callAI( + loadingItemId: Long, + speak: Boolean, + category: Category, + ) { viewModelScope.launch { - val response = withContext(Dispatchers.IO) { - extractFromAI(sendQuestionToAPI_LM(MAIN_CONTEXT)) - } + val response = + withContext(Dispatchers.IO) { + extractFromAI(sendQuestionToApiLm(MAIN_CONTEXT)) + } processResponse(response, loadingItemId, speak, category = category) } } // Suspending function for translation - private suspend fun TranslatorManager.translateToEnglishSuspend(text: String): String? { - return suspendCoroutine { continuation -> + private suspend fun TranslatorManager.translateToEnglishSuspend(text: String): String? = + suspendCoroutine { continuation -> translateToEnglish(text) { translatedText -> continuation.resume(translatedText) } } - } private fun cleanAndPunctuate(input: String): String { val trimmedInput = input.trim() @@ -512,12 +510,11 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr loadingItemId: Long, speak: Boolean, category: Category, - contentURL: String ="", - navigationURI: URI = URI("") + contentURL: String = "", + navigationURI: URI = URI(""), ) { response?.let { withContext(Dispatchers.Main) { - val plaintext = markdownToPlainText(it) if (getIsTranslationEnabled()) { @@ -525,45 +522,48 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr val finalText = translatedText ?: plaintext chatList.removeAt(chatList.indexOfFirst { it.id == loadingItemId }) addConversationItem(plaintext, finalText, false, category, contentURL, navigationURI) - if(speak) + if (speak) { speakResponse(finalText) + } } } else { chatList.removeAt(chatList.indexOfFirst { it.id == loadingItemId }) addConversationItem(response, "", false, category, contentURL, navigationURI) - if(speak) + if (speak) { speakResponse(plaintext) + } } } } } - private fun addConversationItem(englishText: String, translatedText: String, isUser: Boolean, category: Category, contentURL: String ="", navigationURI: URI = URI("")) { - val conversation = Conversation(englishText = englishText, translatedText = translatedText, isMe = isUser, category = category.name, contentURL = contentURL, navigationURI = navigationURI) + private fun addConversationItem( + englishText: String, + translatedText: String, + isUser: Boolean, + category: Category, + contentURL: String = "", + navigationURI: URI = URI(""), + ) { + val conversation = + Conversation( + englishText = englishText, + translatedText = translatedText, + isMe = isUser, + category = category.name, + contentURL = contentURL, + navigationURI = navigationURI, + ) chatList.add(conversation) - if(chatList.size<=2) + if (chatList.size <= 2) { loadGroup() + } } - private fun speakResponse( - plaintext: String - ) { - textToSpeech.speak(plaintext, TextToSpeech.QUEUE_FLUSH, null, "left_for_now_id") - textToSpeech.setOnUtteranceProgressListener(object : UtteranceProgressListener() { - - override fun onDone(utteranceId: String?) { - _isSpeaking.value = false - } - - @Deprecated("Deprecated in Java") - override fun onError(utteranceId: String?) { - _isSpeaking.value = false - } - - override fun onStart(utteranceId: String?) { - _isSpeaking.value = true - } - }) + private fun speakResponse(plaintext: String) { + viewModelScope.launch { + _uiEvent.emit(UIEvent.SpeakText(plaintext)) + } } // Function to clear the conversation list @@ -574,59 +574,74 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } } - //Function to start new chat - fun newChat(){ + // Function to start new chat + fun newChat() { viewModelScope.launch { chatList.clear() repository.currentGroupId = -1L } } - fun deleteMessage(index: Int){ - if(index!=null){ + fun deleteMessage(index: Int) { + if (index != null) { chatList.removeAt(index) } } - private suspend fun youtubeApiCall(query: String): Pair{ + private suspend fun youtubeApiCall(query: String): Pair { val client = OkHttpClient() val deferredResult = CompletableDeferred>() - val urlBuilder = "https://www.googleapis.com/youtube/v3/search".toHttpUrlOrNull()?.newBuilder() - ?.addQueryParameter("key", loadYoutubeKey()) - ?.addQueryParameter("q", query) - ?.addQueryParameter("type", "video") - ?.addQueryParameter("part", "snippet") - ?.addQueryParameter("maxResults","1") - ?.build() + val urlBuilder = + "https://www.googleapis.com/youtube/v3/search" + .toHttpUrlOrNull() + ?.newBuilder() + ?.addQueryParameter("key", loadYoutubeKey()) + ?.addQueryParameter("q", query) + ?.addQueryParameter("type", "video") + ?.addQueryParameter("part", "snippet") + ?.addQueryParameter("maxResults", "1") + ?.build() - val request = Request.Builder() - .url(urlBuilder.toString()) - .build() + val request = + Request + .Builder() + .url(urlBuilder.toString()) + .build() - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - println("Request failed: $e") - deferredResult.completeExceptionally(e) - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - val body = response.body?.string() - if(body != null) - deferredResult.complete(extractVideoIdAndThumbnail(body)) - else - deferredResult.complete(Pair("","")) - } else { - println("Request unsuccessful: ${response.code}") - if(response.code == 403) - deferredResult.complete(Pair("Missing API Key","")) - else - deferredResult.complete(Pair("","")) + client.newCall(request).enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + println("Request failed: $e") + deferredResult.completeExceptionally(e) } - response.close(); - } - }) + + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body = response.body?.string() + if (body != null) { + deferredResult.complete(extractVideoIdAndThumbnail(body)) + } else { + deferredResult.complete(Pair("", "")) + } + } else { + println("Request unsuccessful: ${response.code}") + if (response.code == 403) { + deferredResult.complete(Pair("Missing API Key", "")) + } else { + deferredResult.complete(Pair("", "")) + } + } + response.close() + } + }, + ) return deferredResult.await() } @@ -642,10 +657,12 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr // Make sure it's a video, not a channel if (idObject.optString("kind") == "youtube#video") { val videoId = idObject.optString("videoId", "") - val thumbnailUrl = firstItem.optJSONObject("snippet") - ?.optJSONObject("thumbnails") - ?.optJSONObject("high") - ?.optString("url", "") ?: "" + val thumbnailUrl = + firstItem + .optJSONObject("snippet") + ?.optJSONObject("thumbnails") + ?.optJSONObject("high") + ?.optString("url", "") ?: "" return Pair(videoId, thumbnailUrl) } @@ -655,29 +672,38 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } private suspend fun sendToAI(messagesArray: JSONArray): String { - val client = OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() + val client = + OkHttpClient + .Builder() + .connectTimeout( + 60, + TimeUnit.SECONDS, + ).readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() - val requestBodyJson = JSONObject().apply { - put("model", "llama-3.3-70b-versatile") - put("messages", messagesArray) - put("temperature", 1) - put("top_p", 1) - put("stop", null as Any?) - } + val requestBodyJson = + JSONObject().apply { + put("model", "llama-3.3-70b-versatile") + put("messages", messagesArray) + put("temperature", 1) + put("top_p", 1) + put("stop", null as Any?) + } - val requestBody = requestBodyJson.toString() - .toRequestBody("application/json".toMediaTypeOrNull()) + val requestBody = + requestBodyJson + .toString() + .toRequestBody("application/json".toMediaTypeOrNull()) - val request = Request.Builder() - .url("https://api.groq.com/openai/v1/chat/completions") - .addHeader("Authorization", "Bearer ${loadChatKey()}") - .addHeader("Content-Type", "application/json") - .post(requestBody) - .build() + val request = + Request + .Builder() + .url("https://api.groq.com/openai/v1/chat/completions") + .addHeader("Authorization", "Bearer ${loadChatKey()}") + .addHeader("Content-Type", "application/json") + .post(requestBody) + .build() return withContext(Dispatchers.IO) { try { @@ -731,211 +757,46 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } } + private suspend fun sendQuestionToApiLm(system_context: String): String { + val messagesArray = JSONArray() - private suspend fun sendQuestionToAPI_LM(system_context: String): String{ - val messagesArray = JSONArray() + messagesArray.put(JSONObject().put("role", "system").put("content", system_context)) - messagesArray.put(JSONObject().put("role", "system").put("content", system_context)) - - if(chatList.isNotEmpty()) { - for (item in chatList) { - val role = if (item.isMe) "user" else "assistant" - val messageObject = JSONObject().put("role", role).put("content", item.englishText) - messagesArray.put(messageObject) + if (chatList.isNotEmpty()) { + for (item in chatList) { + val role = if (item.isMe) "user" else "assistant" + val messageObject = JSONObject().put("role", role).put("content", item.englishText) + messagesArray.put(messageObject) + } } - } - val response = sendToAI(messagesArray) - return response + val response = sendToAI(messagesArray) + return response } private fun startSpeechRecognition() { - globalContext?.let { context -> - startSpeechToText( - context, - onResult = { recognizedText -> - question.value = recognizedText - if (question.value.isNotEmpty()) { - processQuestion(context = context, speak = true) // Process the question - } - }, - onPartialResult = { recognizedText -> - question.value = recognizedText - } - ) + viewModelScope.launch { + _uiEvent.emit(UIEvent.StartSpeechRecognition) } } - fun startSpeechToText(context: Context, onResult: (String) -> Unit, onPartialResult: (String) -> Unit) { - if (ContextCompat.checkSelfPermission(context, RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - // Request the RECORD_AUDIO permission. - ActivityCompat.requestPermissions(context as Activity, arrayOf(RECORD_AUDIO), 101) - return - } - - // Ensure Bluetooth SCO is started for better compatibility with Bluetooth headsets - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - audioManager.mode = AudioManager.MODE_IN_CALL - audioManager.isBluetoothScoOn = true - audioManager.startBluetoothSco() - - val originalRingerMode = audioManager.ringerMode // Save the current ringer mode - - - if(textToSpeech.isSpeaking) - textToSpeech.stop() - - val speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context) - val speechRecognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) - speechRecognizerIntent.putExtra( - RecognizerIntent.EXTRA_LANGUAGE_MODEL, - RecognizerIntent.LANGUAGE_MODEL_FREE_FORM - ) - speechRecognizerIntent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true); - if (getIsTranslationEnabled()) - speechRecognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE,getLocaleCode(getActiveLanguageCode())) - else - speechRecognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) - - speechRecognizer.setRecognitionListener(object : RecognitionListener { - override fun onReadyForSpeech(bundle: Bundle?) { - // Put the phone in vibrate mode temporarily to suppress default beep - if(originalRingerMode == AudioManager.RINGER_MODE_NORMAL) - audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE - } - override fun onBeginningOfSpeech() { - _isListening.value = true - } - override fun onRmsChanged(v: Float) {} - override fun onBufferReceived(bytes: ByteArray?) {} - override fun onEndOfSpeech() { - _isListening.value = false - if(originalRingerMode == AudioManager.RINGER_MODE_NORMAL) { - CoroutineScope(Dispatchers.Default).launch { - delay(800) - audioManager.ringerMode = originalRingerMode - } - } - } - - override fun onError(errorCode: Int) { - val message = when (errorCode) { - SpeechRecognizer.ERROR_AUDIO -> "Audio recording error" - SpeechRecognizer.ERROR_CLIENT -> "Client side error" - SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Insufficient permissions" - SpeechRecognizer.ERROR_NETWORK -> "Network error" - SpeechRecognizer.ERROR_NO_MATCH -> "No match found" - SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognition service is busy" - SpeechRecognizer.ERROR_SERVER -> "Server error" - SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech input timeout" - else -> "Unknown error" - } - //Toast.makeText(context, "Error occurred: $message", Toast.LENGTH_SHORT).show() - _isListening.value = false - if(originalRingerMode == AudioManager.RINGER_MODE_NORMAL) { - CoroutineScope(Dispatchers.Default).launch { - delay(800) - audioManager.ringerMode = originalRingerMode - } - } - } - - override fun onResults(bundle: Bundle?) { - _isListening.value = false - val recognizedText = bundle?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.get(0) ?: "" - onResult(recognizedText) - } - - override fun onPartialResults(bundle: Bundle) { - val recognizedText = bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.get(0) ?: "" - onPartialResult(recognizedText) - } - override fun onEvent(i: Int, bundle: Bundle?) {} - - }) - speechRecognizer.startListening(speechRecognizerIntent) - - // Clean up resources when done - context.registerComponentCallbacks(object : ComponentCallbacks2 { - override fun onTrimMemory(level: Int) { - if (level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) { - // Stop Bluetooth SCO - audioManager.stopBluetoothSco() - audioManager.isBluetoothScoOn = false - speechRecognizer.destroy() - } - } - - override fun onConfigurationChanged(newConfig: Configuration) {} - override fun onLowMemory() {} - }) - } - - fun playCustomToneWithMutedBeep(context: Context, customToneResId: Int) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val originalRingerMode = audioManager.ringerMode // Save the current ringer mode - - if(originalRingerMode == AudioManager.RINGER_MODE_NORMAL) { - - // Put the phone in vibrate mode temporarily to suppress default beep - audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE - - // Play custom tone - val mediaPlayer = MediaPlayer.create(context, customToneResId) - mediaPlayer.setOnCompletionListener { - // Restore the original ringer mode after the tone finishes playing - audioManager.ringerMode = originalRingerMode - mediaPlayer.release() - } - mediaPlayer.start() + fun startSpeechToText( + context: android.content.Context, + onResult: (String) -> Unit, + onPartialResult: (String) -> Unit, + ) { + speechResultCallback = onResult + speechPartialResultCallback = onPartialResult + viewModelScope.launch { + _uiEvent.emit(UIEvent.StartSpeechRecognition) } } - @SuppressLint("ServiceCast") - fun MuteBeep(context: Context) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - //val vibrator = getSystemService(context, Vibrator::class.java) - - val originalRingerMode = audioManager.ringerMode // Save the current ringer mode - - // Put the phone in vibrate mode temporarily to suppress default beep - audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE - - // Vibrate for minimal duration -// vibrator?.let { -// if (Build.VERSION.SDK_INT >= 26) { -// it.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)) -// } else { -// @Suppress("DEPRECATION") -// it.vibrate(100) -// } -// } - - // Restore the original ringer mode after a short delay - Handler(Looper.getMainLooper()).postDelayed({ - audioManager.ringerMode = originalRingerMode - }, 800) // Wait for a short time to ensure vibration is completed + fun onSpeechRecognized(recognizedText: String) { + speechResultCallback?.invoke(recognizedText) } - - fun getLocaleCode(languageCode: String): String { - val languageCountryMapping = mapOf( - "af" to "ZA", "sq" to "AL", "ar" to "SA", "be" to "BY", "bg" to "BG", - "bn" to "BD", "ca" to "ES", "zh" to "CN", "hr" to "HR", "cs" to "CZ", - "da" to "DK", "nl" to "NL", "en" to "US", "eo" to "EO", "et" to "EE", - "fi" to "FI", "fr" to "FR", "gl" to "ES", "ka" to "GE", "de" to "DE", - "el" to "GR", "gu" to "IN", "ht" to "HT", "he" to "IL", "hi" to "IN", - "hu" to "HU", "is" to "IS", "id" to "ID", "ga" to "IE", "it" to "IT", - "ja" to "JP", "kn" to "IN", "ko" to "KR", "lt" to "LT", "lv" to "LV", - "mk" to "MK", "mr" to "IN", "ms" to "MY", "mt" to "MT", "no" to "NO", - "fa" to "IR", "pl" to "PL", "pt" to "BR", "ro" to "RO", "ru" to "RU", - "sk" to "SK", "sl" to "SI", "es" to "ES", "sv" to "SE", "sw" to "KE", - "tl" to "PH", "ta" to "IN", "te" to "IN", "th" to "TH", "tr" to "TR", - "uk" to "UA", "ur" to "PK", "vi" to "VN", "cy" to "GB" - ) - - // Retrieve the country code based on the language or fall back to Locale's default country - val countryCode = languageCountryMapping[languageCode] ?: Locale.getDefault().country - return "$languageCode-$countryCode" + fun onSpeechPartialResult(recognizedText: String) { + speechPartialResultCallback?.invoke(recognizedText) } fun markdownToPlainText(markdown: String): String { @@ -953,26 +814,25 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr return plainText.trim() } - override fun onCleared() { - super.onCleared() - shutdownResources() - } - // Update the selected item fun onItemSelected(selectedLanguageCode: String) { _isLanguageLoading.value = true setupTranslator(selectedLanguageCode, true) - triggerToast("Downloading translation model."); + triggerToast("Downloading translation model.") } - private fun setupTranslator(selectedLanguageCode: String, showCompletionToast: Boolean = false) { + private fun setupTranslator( + selectedLanguageCode: String, + showCompletionToast: Boolean = false, + ) { translatorManager.setupTranslators(selectedLanguageCode) { success -> if (success) { updateActiveLanguageCode(selectedLanguageCode) _isLanguageLoading.value = false showBottomSheet.value = false - if(showCompletionToast) - triggerToast("Download completed, its recommended to use selected language keyboard."); + if (showCompletionToast) { + triggerToast("Download completed, its recommended to use selected language keyboard.") + } } else { _isLanguageLoading.value = false triggerToast("Something went wrong, model download failed.") @@ -987,25 +847,26 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } private fun isNegativeOrNotRequired(phrase: String): Boolean { - val negativePatterns = listOf( - Regex("\\bno\\b", RegexOption.IGNORE_CASE), - Regex("\\bnah\\b", RegexOption.IGNORE_CASE), - Regex("\\bnot\\b.*", RegexOption.IGNORE_CASE), // Matches "Not now", "Not anymore", etc. - Regex("\\bnever\\b.*", RegexOption.IGNORE_CASE), // Matches "Never mind", "Never again", etc. - Regex("\\bforget\\b.*", RegexOption.IGNORE_CASE), // Matches "Forget it", "Forget about it" - Regex("\\bleave\\b.*", RegexOption.IGNORE_CASE), // Matches "Leave it" - Regex("\\bdrop\\b.*", RegexOption.IGNORE_CASE), // Matches "Drop it" - Regex("\\bcancel\\b", RegexOption.IGNORE_CASE), - Regex("\\babort\\b", RegexOption.IGNORE_CASE), - Regex("\\bstop\\b", RegexOption.IGNORE_CASE), - Regex("\\bquit\\b", RegexOption.IGNORE_CASE), - Regex("\\bdisregard\\b", RegexOption.IGNORE_CASE), - Regex("changed my mind", RegexOption.IGNORE_CASE), - Regex("don't bother", RegexOption.IGNORE_CASE), - Regex("let it go", RegexOption.IGNORE_CASE), - Regex("scratch that", RegexOption.IGNORE_CASE), - Regex("just kidding", RegexOption.IGNORE_CASE), - ) + val negativePatterns = + listOf( + Regex("\\bno\\b", RegexOption.IGNORE_CASE), + Regex("\\bnah\\b", RegexOption.IGNORE_CASE), + Regex("\\bnot\\b.*", RegexOption.IGNORE_CASE), // Matches "Not now", "Not anymore", etc. + Regex("\\bnever\\b.*", RegexOption.IGNORE_CASE), // Matches "Never mind", "Never again", etc. + Regex("\\bforget\\b.*", RegexOption.IGNORE_CASE), // Matches "Forget it", "Forget about it" + Regex("\\bleave\\b.*", RegexOption.IGNORE_CASE), // Matches "Leave it" + Regex("\\bdrop\\b.*", RegexOption.IGNORE_CASE), // Matches "Drop it" + Regex("\\bcancel\\b", RegexOption.IGNORE_CASE), + Regex("\\babort\\b", RegexOption.IGNORE_CASE), + Regex("\\bstop\\b", RegexOption.IGNORE_CASE), + Regex("\\bquit\\b", RegexOption.IGNORE_CASE), + Regex("\\bdisregard\\b", RegexOption.IGNORE_CASE), + Regex("changed my mind", RegexOption.IGNORE_CASE), + Regex("don't bother", RegexOption.IGNORE_CASE), + Regex("let it go", RegexOption.IGNORE_CASE), + Regex("scratch that", RegexOption.IGNORE_CASE), + Regex("just kidding", RegexOption.IGNORE_CASE), + ) return negativePatterns.any { it.containsMatchIn(phrase) } } @@ -1016,161 +877,73 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr if (lockState is LockState.LockAlarm) { (lockState as LockState.LockAlarm).day = null } - // Add similar reset logic for other lock states if needed in the future } - //For call - private fun Call(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ - if(chatList.isNotEmpty()) { - val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "")?:return - val permissions = arrayOf(READ_CONTACTS, CALL_PHONE) - val requiredPermissions = permissions.filter { - ContextCompat.checkSelfPermission(globalContext!!, it) != PackageManager.PERMISSION_GRANTED - } - - if (requiredPermissions.isNotEmpty()) { - ActivityCompat.requestPermissions( - globalContext as Activity, - requiredPermissions.toTypedArray(),102 - ) - viewModelScope.launch { - processResponse(getRandomResponse(ResponseStrings.permissionContactsCall), loadingItemId, speak, Category.OTHER) - } - return - } + // For call + private fun callContact( + itemId: Long, + loadingItemId: Long, + speak: Boolean, + category: Category, + ) { + if (chatList.isNotEmpty()) { + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "") ?: return viewModelScope.launch { - val (success, result) = searchContactAndMakeCall(prompt) + val requiredPermissions = + arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.CALL_PHONE, + ).filter { + ContextCompat.checkSelfPermission(getApplication(), it) != PackageManager.PERMISSION_GRANTED + } - if (success) { - val contact = result as Contact - processResponse(contact.name, loadingItemId, false, category, navigationURI = URI("tel:${contact.phoneNumber.replace(" ","")}")) + if (requiredPermissions.isNotEmpty()) { + _uiEvent.emit(UIEvent.RequestPermissions(requiredPermissions.toTypedArray(), 102)) + processResponse(getRandomResponse(ResponseStrings.permissionContactsCall), loadingItemId, speak, Category.OTHER) + return@launch + } + + val bestMatch = contactsRepository.searchContact(prompt) + + if (bestMatch != null) { + try { + val callIntent = + Intent(Intent.ACTION_CALL).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + data = Uri.parse("tel:${bestMatch.phoneNumber}") + } + _uiEvent.emit(UIEvent.StartIntent(callIntent)) + processResponse( + bestMatch.name, + loadingItemId, + false, + category, + navigationURI = URI("tel:${bestMatch.phoneNumber.replace(" ", "")}"), + ) + } catch (e: Exception) { + Log.e("MainViewModel", "Error making call:", e) + processResponse("Sorry, failed to make call. Please try again.", loadingItemId, speak, Category.OTHER) + } } else { - processResponse(result.toString(), loadingItemId, speak, Category.OTHER) + processResponse("I cannot find such contact, please try again.", loadingItemId, speak, Category.OTHER) } } } } - @SuppressLint("Range") - private suspend fun searchContactAndMakeCall(prompt: String): Pair { - val contentResolver = getApplication().contentResolver - - val contactList = mutableListOf() - - withContext(Dispatchers.IO) { - val cursor = contentResolver.query( - ContactsContract.Data.CONTENT_URI, - arrayOf( - ContactsContract.Data.CONTACT_ID, - ContactsContract.Data.DISPLAY_NAME, - ContactsContract.CommonDataKinds.Phone.NUMBER - ), - "${ContactsContract.Data.MIMETYPE} =? AND ${ContactsContract.Data.CONTACT_ID} IS NOT NULL", - arrayOf(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE), - null - ) - - cursor?.use { contactCursor -> - while (contactCursor.moveToNext()) { - val contactId = contactCursor.getString(contactCursor.getColumnIndex(ContactsContract.Data.CONTACT_ID)) - val name = contactCursor.getString(contactCursor.getColumnIndex(ContactsContract.Data.DISPLAY_NAME)) - val phoneNumber = contactCursor.getString(contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)) - contactList.add(Contact(contactId, name, phoneNumber)) - } - } - } - - val contactName = cleanAndExtractName(prompt) - - val bestMatch = findBestMatch(contactName, contactList) - - if (bestMatch != null) { - try { - val callIntent = Intent(Intent.ACTION_CALL) - callIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - callIntent.data = Uri.parse("tel:${bestMatch.phoneNumber}") - globalContext?.startActivity(callIntent) - return Pair(true, bestMatch) - } catch (e: Exception) { - Log.e("MainViewModel", "Error making call:", e) - return Pair(false, "Sorry, failed to make call. Please try again.") - } - } else { - return Pair(false, "I cannot find such contact, please try again.") - } - } - - private fun cleanAndExtractName(command: String): String { - val stopWords = setOf("can", "you", "make", "call", "dial", "number", "to", "please", "the", "a", "on") - val words = command.lowercase().trim().split(" ") - return words.filter { !stopWords.contains(it) }.joinToString(" ") - } - - private fun findBestMatch(inputString: String, nameList: List, similarityThreshold: Double = 0.7): Contact? { - if(inputString.isEmpty()){ - return null - } - val inputLower = inputString.lowercase().trim() - var exactMatch: Contact? = null - var bestSubstringMatch: Contact? = null - var bestFuzzyMatch: Contact? = null - var bestFuzzyScore = 0.0 - - for (contact in nameList) { - val nameLower = contact.name.lowercase().trim() - - // Check for exact match - if (inputLower == nameLower) { - // Return immediately if there's an exact match - return contact - } - - // Check for exact match within tokens - val tokens = nameLower.split(" ") - for (token in tokens) { - when { - inputLower == token -> { - exactMatch = contact // Keep track of the exact match - } - token.contains(inputLower) -> { - bestSubstringMatch = contact // Keep track of the substring match if no exact match is found - } - else -> { - // Perform fuzzy matching as a last resort - val similarityScore = stringSimilarity(inputLower, token) - if (similarityScore > bestFuzzyScore) { - bestFuzzyScore = similarityScore - bestFuzzyMatch = contact - } - } - } - } - } - - // Return in order of priority: exact match, substring match, fuzzy match - return when { - exactMatch != null -> exactMatch - bestSubstringMatch != null -> bestSubstringMatch - bestFuzzyScore >= similarityThreshold -> bestFuzzyMatch - else -> null // No match found if similarity score is too low - } - } - - private fun stringSimilarity(a: String, b: String): Double { - val matcher = a.commonPrefixWith(b).length.toDouble() / max(a.length, b.length).toDouble() - return matcher - } - //End Call - - //For Song - private fun Song(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category) { + // For Song + private fun playSong( + itemId: Long, + loadingItemId: Long, + speak: Boolean, + category: Category, + ) { try { - val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "")?: return + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "") ?: return if (!prompt.lowercase().contains("play ")) { viewModelScope.launch { - processResponse(getRandomResponse(ResponseStrings.songNotFound),loadingItemId, speak, - Category.OTHER) + processResponse(getRandomResponse(ResponseStrings.songNotFound), loadingItemId, speak, Category.OTHER) } return } @@ -1179,55 +952,82 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr val (videoId, thumbnailUrl) = youtubeApiCall(searchQuery) if (videoId.isEmpty()) { processResponse(getRandomResponse(ResponseStrings.songNotFound), loadingItemId, speak, Category.OTHER) - } else if(videoId == "Missing API Key"){ - val intent = Intent(Intent.ACTION_SEARCH) - intent.setPackage("com.google.android.youtube") - intent.putExtra("query", searchQuery) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - globalContext?.startActivity(intent) + } else if (videoId == "Missing API Key") { + val intent = + Intent(Intent.ACTION_SEARCH).apply { + setPackage("com.google.android.youtube") + putExtra("query", searchQuery) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + _uiEvent.emit(UIEvent.StartIntent(intent)) processResponse("Your Youtube API key is missing or invalid.", loadingItemId, speak, Category.OTHER) } else { - val intent = Intent(Intent.ACTION_VIEW, "http://www.youtube.com/watch?v=$videoId".toUri()) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - globalContext?.startActivity(intent) - processResponse("Playing $searchQuery", loadingItemId, speak, category, thumbnailUrl, URI("http://www.youtube.com/watch?v=$videoId")) + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse("http://www.youtube.com/watch?v=$videoId")).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + _uiEvent.emit(UIEvent.StartIntent(intent)) + processResponse( + "Playing $searchQuery", + loadingItemId, + speak, + category, + thumbnailUrl, + URI("http://www.youtube.com/watch?v=$videoId"), + ) } } } catch (e: Exception) { Log.d("Exception", e.message.toString()) viewModelScope.launch { - processResponse(getRandomResponse(ResponseStrings.genericError),loadingItemId, speak, Category.OTHER) + processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) } } } - //End Song - //For Navigation - private fun Navigation(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ + // For Navigation + private fun navigate( + itemId: Long, + loadingItemId: Long, + speak: Boolean, + category: Category, + ) { try { if (chatList.isNotEmpty()) { - val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "")?:return + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "") ?: return val location = extractLocation(prompt) viewModelScope.launch { - if (location!=null) { - val gmmIntentUri = - Uri.parse("google.navigation:q=$location") - val mapIntent = Intent(Intent.ACTION_VIEW, gmmIntentUri) - mapIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - mapIntent.setPackage("com.google.android.apps.maps") - globalContext?.startActivity(mapIntent) - val encodedURI = URLEncoder.encode(location, StandardCharsets.UTF_8.toString()) - processResponse("Navigating to $location.", loadingItemId, speak, category, navigationURI = URI("google.navigation:q=$encodedURI")) - }else{ + if (location != null) { + val gmmIntentUri = Uri.parse("google.navigation:q=$location") + val mapIntent = + Intent(Intent.ACTION_VIEW, gmmIntentUri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + setPackage("com.google.android.apps.maps") + } + _uiEvent.emit(UIEvent.StartIntent(mapIntent)) + val encodedURI = + java.net.URLEncoder.encode( + location, + java.nio.charset.StandardCharsets.UTF_8 + .toString(), + ) + processResponse( + "Navigating to $location.", + loadingItemId, + speak, + category, + navigationURI = URI("google.navigation:q=$encodedURI"), + ) + } else { processResponse(getRandomResponse(ResponseStrings.locationNotFound), loadingItemId, speak, Category.OTHER) } } - }else{ + } else { viewModelScope.launch { processResponse(getRandomResponse(ResponseStrings.locationNotFound), loadingItemId, speak, Category.OTHER) } } - }catch (e: Exception){ + } catch (e: Exception) { Log.d("Exception", e.message.toString()) viewModelScope.launch { processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) @@ -1236,123 +1036,106 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } private fun extractLocation(command: String): String? { - // Regex pattern to capture location phrases based on patterns observed in the examples - val regex = Regex( - "(?<=to |find |show me |give me directions to |navigate me to |how do I get to |I'm on my way to |start a navigation to |where is |help me find |traffic like on the way to |way to )" + - "([A-Za-z0-9\\s&]+)" - ) + val regex = + Regex( + "(?<=to |find |show me |give me directions to |navigate me to |" + + "how do I get to |I'm on my way to |start a navigation to |" + + "where is |help me find |traffic like on the way to |way to )" + + "([A-Za-z0-9\\s&]+)", + ) - // Attempt to find the location in the command string val matchResult = regex.find(command) return matchResult?.value?.trim() } - //End Navigation - //For Weather - private fun Weather(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ + // For Weather + private fun fetchWeather( + itemId: Long, + loadingItemId: Long, + speak: Boolean, + category: Category, + ) { try { - if(chatList.isNotEmpty()){ - val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "")?: return - val location = extractPlaceName(prompt) - if(location.isNotEmpty()){ + if (chatList.isNotEmpty()) { + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "") ?: return + val location = weatherRepository.extractPlaceName(prompt) + if (location.isNotEmpty()) { viewModelScope.launch { - val coordinates = getCoordinatesFromCity(location) - if(coordinates!=null) { + val coordinates = weatherRepository.getCoordinatesFromCity(location) + if (coordinates != null) { val (lat, long) = coordinates - val weatherData = getWeatherData(lat, long) - if(weatherData!=null){ - weatherData.remove("latitude"); - weatherData.remove("longitude"); - val response = withContext(Dispatchers.IO) { - extractFromAI(sendQuestionToAI(prompt, weatherData.toString())) + val weatherData = weatherRepository.getWeatherData(lat, long) + if (weatherData != null) { + weatherData.remove("latitude") + weatherData.remove("longitude") + val response = + withContext(Dispatchers.IO) { + extractFromAI(sendQuestionToAI(prompt, weatherData.toString())) + } + if (!response.isNullOrEmpty()) { + processResponse( + response, + loadingItemId, + speak, + category, + navigationURI = URI("https://www.google.com/search?q=weather+$location"), + ) } - if(!response.isNullOrEmpty()){ - processResponse(response, loadingItemId, speak, category, navigationURI = URI("https://www.google.com/search?q=weather+${location}")) - } - }else{ - processResponse(getRandomResponse(ResponseStrings.weatherReportUnavailable), loadingItemId, speak, Category.OTHER) - } - }else{ - processResponse(getRandomResponse(ResponseStrings.locationNotFound), loadingItemId, speak, Category.OTHER) - } - } - }else{ - if (globalContext?.let { ContextCompat.checkSelfPermission(it, Manifest.permission.ACCESS_COARSE_LOCATION) } != PackageManager.PERMISSION_GRANTED) { - // Request permission if it hasn't been granted - ActivityCompat.requestPermissions( - globalContext as Activity, - arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), - 103 - ) - viewModelScope.launch { - processResponse(getRandomResponse(ResponseStrings.permissionLocation), loadingItemId, speak, Category.OTHER) - } - }else{ - val locationManager = globalContext?.getSystemService(Context.LOCATION_SERVICE) as LocationManager - val gpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) - - if (!gpsEnabled) { - promptEnableLocation() - viewModelScope.launch { - processResponse(getRandomResponse(ResponseStrings.locationServiceOff), loadingItemId, speak, Category.OTHER) + } else { + processResponse( + getRandomResponse(ResponseStrings.weatherReportUnavailable), + loadingItemId, + speak, + Category.OTHER, + ) } } else { - // GPS is on and permission is granted - val fusedLocationClient = LocationServices.getFusedLocationProviderClient(globalContext!!) - // Create a LocationRequest - val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000).setWaitForAccurateLocation(true).setMaxUpdates(1).build() - // Create a LocationCallback - val locationCallback = object : LocationCallback() { - override fun onLocationResult(locationResult: LocationResult) { - val location = locationResult.lastLocation - if (location != null) { - val lat = location.latitude - val long = location.longitude - val city = getCityNameFromLocation(lat, long) - // Process the location - viewModelScope.launch { - val weatherData = getWeatherData(lat, long) - if(weatherData!=null){ - weatherData.remove("latitude"); - weatherData.remove("longitude"); - val response = withContext(Dispatchers.IO) { - extractFromAI(sendQuestionToAI(prompt, weatherData.toString())) - } - if(!response.isNullOrEmpty()){ - processResponse(response, loadingItemId, speak, Category.WEATHER, navigationURI = URI("https://www.google.com/search?q=weather+${URLEncoder.encode(city, StandardCharsets.UTF_8.toString())}")) - } - }else{ - processResponse(getRandomResponse(ResponseStrings.weatherReportUnavailable), loadingItemId, speak, Category.OTHER) - } - } - } else { - // If location is null, handle the failure - viewModelScope.launch { - processResponse(getRandomResponse(ResponseStrings.weatherReportUnavailable),loadingItemId, speak, - Category.OTHER) - } - } - // Remove location updates after receiving a result - fusedLocationClient.removeLocationUpdates(this) - } + processResponse( + getRandomResponse(ResponseStrings.locationNotFound), + loadingItemId, + speak, + Category.OTHER, + ) + } + } + } else { + val isPermissionGranted = + ContextCompat.checkSelfPermission( + getApplication(), + Manifest.permission.ACCESS_COARSE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED - override fun onLocationAvailability(locationAvailability: LocationAvailability) { - if (!locationAvailability.isLocationAvailable) { - // Handle the case where location is unavailable - viewModelScope.launch { - processResponse(getRandomResponse(ResponseStrings.locationUnknownSuggestCity),loadingItemId, speak, - Category.OTHER) - } - } - } - } - // Request a single location update - fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper()) + if (!isPermissionGranted) { + viewModelScope.launch { + _uiEvent.emit( + UIEvent.RequestPermissions( + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), + 103, + ), + ) + processResponse( + getRandomResponse(ResponseStrings.permissionLocation), + loadingItemId, + speak, + Category.OTHER, + ) + } + } else { + viewModelScope.launch { + _uiEvent.emit( + UIEvent.GetLocationForWeather( + itemId, + loadingItemId, + speak, + category.name, + prompt, + ), + ) } } } } - }catch (e: Exception){ + } catch (e: Exception) { Log.d("Exception", e.message.toString()) viewModelScope.launch { processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) @@ -1360,207 +1143,138 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } } - private fun extractPlaceName(inputText: String): String { - // Pattern to match place names after "in" or "for", excluding trailing words like "today", "tomorrow", etc. - val pattern = Regex("\\b(?:in|for)\\s+([A-Za-z]+(?:\\s+[A-Za-z]+)*)(?=\\b(?:\\s+(today|now|tomorrow|report|forecast|currently|evening|morning|afternoon)\\b|\\s*$))") - - // Search for a match in the input text - val match = pattern.find(inputText) - - // Check if the captured group does not match excluded words (e.g., "tomorrow") and return it - val placeName = match?.groups?.get(1)?.value?.trim() - return if (placeName != null && !placeName.lowercase().matches(Regex("today|now|tomorrow|report|forecast|currently|evening|morning|afternoon"))) { - placeName - } else { - "" - } - } - - private suspend fun getCoordinatesFromCity(cityName: String): Pair? { - val client = OkHttpClient() - val deferredResult = CompletableDeferred?>() - - val urlBuilder = "https://geocoding-api.open-meteo.com/v1/search".toHttpUrlOrNull()?.newBuilder() - ?.addQueryParameter("name", cityName) - ?.build() - - val request = Request.Builder() - .url(urlBuilder.toString()) - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - println("Request failed: $e") - deferredResult.completeExceptionally(e) - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - val body = response.body?.string()?: return - deferredResult.complete(extractCoordinates(body, cityName)) - } else { - println("Request unsuccessful: ${response.code}") - deferredResult.cancel() - } - } - }) - - return deferredResult.await() - } - - private fun extractCoordinates(body: String, cityName: String): Pair? { - val jsonObject = JSONObject(body) - var latitude: Double? = null - var longitude: Double? = null - - try { - - val results = jsonObject.getJSONArray("results") - - for (i in 0 until results.length()) { - val result = results.getJSONObject(i) - if (result.getString("name").equals(cityName, ignoreCase = true)) { - latitude = result.getDouble("latitude") - longitude = result.getDouble("longitude") - break - } - } - - if (latitude == null || longitude == null && results.length() > 0) { - val firstResult = results.getJSONObject(0) - latitude = firstResult.getDouble("latitude") - longitude = firstResult.getDouble("longitude") - } - }catch (e: Exception){ - e.message?.let { Log.d("extractCoordinates Excepetion", it) } - } - - return if (latitude!= null && longitude!= null) Pair(latitude, longitude) else null - } - - private fun getCityNameFromLocation(latitude: Double, longitude: Double): String? { - val geocoder = globalContext?.let { Geocoder(it, Locale.getDefault()) } - return try { - val addresses = geocoder?.getFromLocation(latitude, longitude, 1) - if (addresses?.isNotEmpty() == true) { - addresses[0].locality // This gives the city name - } else { - null - } - } catch (e: Exception) { - e.printStackTrace() - null - } - } - - private suspend fun getWeatherData(latitude: Double, longitude: Double): JSONObject? { - val client = OkHttpClient() - val deferredResult = CompletableDeferred() - - val urlBuilder = "https://api.open-meteo.com/v1/forecast".toHttpUrlOrNull()?.newBuilder() - ?.addQueryParameter("latitude", latitude.toString()) - ?.addQueryParameter("longitude", longitude.toString()) - ?.addQueryParameter("daily", "temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max") - ?.addQueryParameter("hourly", "temperature_2m,precipitation,windspeed_10m") - ?.addQueryParameter("timezone", "auto") - ?.addQueryParameter("forecast_days", "10") - ?.build() - - val request = Request.Builder() - .url(urlBuilder.toString()) - .build() - - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - println("Weather data request failed: $e") - deferredResult.complete(null) - } - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - val body = response.body?.string()?: return - try { - deferredResult.complete(JSONObject(body)) - } catch (e: JSONException) { - println("Error parsing JSON: $e") - deferredResult.complete(null) + fun onLocationReceived( + lat: Double, + long: Double, + itemId: Long, + loadingItemId: Long, + speak: Boolean, + categoryName: String, + prompt: String, + ) { + val category = Category.valueOf(categoryName) + val city = weatherRepository.getCityNameFromLocation(lat, long) ?: "your location" + viewModelScope.launch { + val weatherData = weatherRepository.getWeatherData(lat, long) + if (weatherData != null) { + weatherData.remove("latitude") + weatherData.remove("longitude") + val response = + withContext(Dispatchers.IO) { + extractFromAI(sendQuestionToAI(prompt, weatherData.toString())) } - } else { - println("Weather data request unsuccessful: ${response.code}") - deferredResult.complete(null) - } - } - }) - - return deferredResult.await() - } - - private fun promptEnableLocation() { - val locationRequest = LocationRequest.Builder(100, 10000) // 10-second interval - .setMinUpdateIntervalMillis(5000) // Minimum interval between updates - .build() - - val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest) - val settingsClient: SettingsClient = LocationServices.getSettingsClient(globalContext!!) - val task: Task = settingsClient.checkLocationSettings(builder.build()) - - task.addOnFailureListener { exception -> - if (exception is ResolvableApiException) { - // Show the native dialog to turn on GPS - try { - exception.startResolutionForResult(globalContext as Activity, 104) - } catch (sendEx: IntentSender.SendIntentException) { - Log.d("Location", "Error showing location settings dialog: ${sendEx.message}") + if (!response.isNullOrEmpty()) { + processResponse( + response, + loadingItemId, + speak, + category, + navigationURI = + URI( + "https://www.google.com/search?q=weather+${ + java.net.URLEncoder.encode( + city, + java.nio.charset.StandardCharsets.UTF_8 + .toString(), + ) + }", + ), + ) } + } else { + processResponse(getRandomResponse(ResponseStrings.weatherReportUnavailable), loadingItemId, speak, Category.OTHER) } } } - private suspend fun sendQuestionToAI(question: String, weatherData:String): String{ + fun onLocationFailed( + loadingItemId: Long, + speak: Boolean, + errorType: String, + ) { + viewModelScope.launch { + val responseText = + when (errorType) { + "GPS_OFF" -> getRandomResponse(ResponseStrings.locationServiceOff) + "UNAVAILABLE" -> getRandomResponse(ResponseStrings.weatherReportUnavailable) + else -> getRandomResponse(ResponseStrings.locationUnknownSuggestCity) + } + processResponse(responseText, loadingItemId, speak, Category.OTHER) + } + } + + private suspend fun sendQuestionToAI( + question: String, + weatherData: String, + ): String { val messagesArray = JSONArray() - val date = SimpleDateFormat("yyyy-MM-dd", Locale. getDefault(Locale. Category. FORMAT)).format(Calendar.getInstance().time) - val time = SimpleDateFormat("HH:mm:ss", Locale. getDefault(Locale. Category. FORMAT)).format(Calendar.getInstance().time) - val systemContext = "You are a smart weather assistant, up-to-date with the current date and time, which is $date at $time. Please respond with an answer to the user's question based on the latest weather data provided. No need to mention data or time; just answer naturally in 2 to 3 lines." + val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault(Locale.Category.FORMAT)).format(Calendar.getInstance().time) + val time = SimpleDateFormat("HH:mm:ss", Locale.getDefault(Locale.Category.FORMAT)).format(Calendar.getInstance().time) + val systemContext = + "You are a smart weather assistant, up-to-date with the current date and time, " + + "which is $date at $time. Please respond with an answer to the user's question based on " + + "the latest weather data provided. No need to mention data or time; just answer naturally in 2 to 3 lines." messagesArray.put(JSONObject().put("role", "system").put("content", systemContext)) messagesArray.put(JSONObject().put("role", "user").put("content", question + System.lineSeparator() + weatherData)) val response = sendToAI(messagesArray) return response } - //End Weather - //For Alarm - private fun Alarm(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ + // For Alarm + private fun setAlarm( + itemId: Long, + loadingItemId: Long, + speak: Boolean, + category: Category, + ) { try { if (chatList.isNotEmpty()) { - val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "")?:return - - val dayRegex = "(?i)\\b(today|tomorrow|next week|next weekend|((Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)( morning| evening| night| afternoon)?))\\b".toRegex(RegexOption.IGNORE_CASE) + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "") ?: return + val dayRegex = + ( + "(?i)\\b(today|tomorrow|next week|next weekend|" + + "((Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)" + + "( morning| evening| night| afternoon)?))\\b" + ).toRegex(RegexOption.IGNORE_CASE) val timeRegex = "(?i)\\b(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?\\b".toRegex(RegexOption.IGNORE_CASE) - - val relativeTimeRegex = "(?i)\\b(\\d+)?\\s*(?:and\\s*(half))?\\s*(hours?|hrs?|mins?|minutes?|seconds?|secs?)\\s*(?:and\\s*(\\d+))?\\s*(mins?|minutes?|secs?|seconds?)?\\s*(from now|later|next)?\\b".toRegex(RegexOption.IGNORE_CASE) - + val relativeTimeRegex = + ( + "(?i)\\b(\\d+)?\\s*(?:and\\s*(half))?\\s*" + + "(hours?|hrs?|mins?|minutes?|seconds?|secs?)\\s*(?:and\\s*(\\d+))?\\s*" + + "(mins?|minutes?|secs?|seconds?)?\\s*(from now|later|next)?\\b" + ).toRegex(RegexOption.IGNORE_CASE) // Extract day, time, and relative time from the prompt val dayMatch = dayRegex.find(prompt)?.value?.lowercase(Locale.ROOT) val timeMatch = timeRegex.find(prompt) val relativeTimeMatch = relativeTimeRegex.find(prompt) - if(timeMatch == null && relativeTimeMatch == null){ + if (timeMatch == null && relativeTimeMatch == null) { viewModelScope.launch { lockState = LockState.LockAlarm(day = dayMatch) - processResponse(getRandomResponse(ResponseStrings.promptForTime), loadingItemId, speak, Category.OTHER) + processResponse( + getRandomResponse(ResponseStrings.promptForTime), + loadingItemId, + speak, + Category.OTHER, + ) } - }else{ + } else { viewModelScope.launch { setAlarmFromPrompt(dayMatch, timeMatch, relativeTimeMatch) - processResponse(getRandomResponse(ResponseStrings.alarmSetSuccess), loadingItemId, speak, category) + processResponse( + getRandomResponse(ResponseStrings.alarmSetSuccess), + loadingItemId, + speak, + category, + ) } } } - }catch (e: Exception){ + } catch (e: Exception) { Log.d("Exception", e.message.toString()) viewModelScope.launch { processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) @@ -1568,9 +1282,14 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } } - private fun handleAlarmLockState (itemId: Long, loadingItemId: Long, speak: Boolean, state: LockState.LockAlarm){ - try{ - val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "")?:return + private fun handleAlarmLockState( + itemId: Long, + loadingItemId: Long, + speak: Boolean, + state: LockState.LockAlarm, + ) { + try { + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "") ?: return val timeRegex = "(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?".toRegex(RegexOption.IGNORE_CASE) val relativeTimeRegex = "(\\d+)\\s*(hours?|mins?|minutes?)\\s*(from now|later|next)?".toRegex(RegexOption.IGNORE_CASE) @@ -1578,19 +1297,19 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr val timeMatch = timeRegex.find(prompt) val relativeTimeMatch = relativeTimeRegex.find(prompt) - if(timeMatch == null && relativeTimeMatch == null){ + if (timeMatch == null && relativeTimeMatch == null) { viewModelScope.launch { processResponse(getRandomResponse(ResponseStrings.invalidTime), loadingItemId, speak, Category.OTHER) } return - }else{ - setAlarmFromPrompt(dayMatch, timeMatch, relativeTimeMatch) + } else { lockState = LockState.None viewModelScope.launch { + setAlarmFromPrompt(dayMatch, timeMatch, relativeTimeMatch) processResponse(getRandomResponse(ResponseStrings.alarmSetSuccess), loadingItemId, speak, Category.ALARM) } } - }catch (e: Exception){ + } catch (e: Exception) { Log.d("Exception", e.message.toString()) viewModelScope.launch { processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) @@ -1598,13 +1317,17 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } } - private fun setAlarmFromPrompt(dayMatch: String?, timeMatch: MatchResult?, relativeTimeMatch: MatchResult?) { + private suspend fun setAlarmFromPrompt( + dayMatch: String?, + timeMatch: MatchResult?, + relativeTimeMatch: MatchResult?, + ) { val calendar = Calendar.getInstance() if (relativeTimeMatch != null) { // Parse the first time unit val firstValue = relativeTimeMatch.groupValues[1].takeIf { it.isNotEmpty() }?.toIntOrNull() ?: 0 - val halfValue = if (relativeTimeMatch.groupValues[2] == "half") 0.5 else 0.0 // Handle "half" scenario + val halfValue = if (relativeTimeMatch.groupValues[2] == "half") 0.5 else 0.0 // Handle "half" scenario val firstUnit = relativeTimeMatch.groupValues[3].lowercase(Locale.ROOT) var secondValue = 0 @@ -1624,27 +1347,36 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr calendar.add(Calendar.MINUTE, 30) // Correctly add 30 minutes for "half" } } - firstUnit.contains("min") -> calendar.add(Calendar.MINUTE, firstValue) + + firstUnit.contains("min") -> { + calendar.add(Calendar.MINUTE, firstValue) + } } // Add second time unit if available when { secondUnit.contains("hour") || secondUnit.contains("hrs") -> calendar.add(Calendar.HOUR_OF_DAY, secondValue) - secondUnit.contains("minute") || secondUnit.contains("minutes") || secondUnit.contains("min") -> calendar.add(Calendar.MINUTE, secondValue) + + secondUnit.contains("minute") || + secondUnit.contains("minutes") || + secondUnit.contains("min") -> calendar.add(Calendar.MINUTE, secondValue) } - }else if (timeMatch != null) { + } else if (timeMatch != null) { // Handle absolute time val hour = timeMatch.groupValues[1].toInt() val minutes = timeMatch.groupValues[3].toIntOrNull() ?: 0 // Default minutes to 0 if missing val amPm = timeMatch.groupValues[4].uppercase(Locale.ROOT) // Convert time to 24-hour format - val hour24 = when { - amPm == "PM" && hour != 12 -> hour + 12 - amPm == "AM" && hour == 12 -> 0 - // If no AM/PM, assume 24-hour format - else -> hour - } + val hour24 = + when { + amPm == "PM" && hour != 12 -> hour + 12 + + amPm == "AM" && hour == 12 -> 0 + + // If no AM/PM, assume 24-hour format + else -> hour + } calendar.set(Calendar.HOUR_OF_DAY, hour24) calendar.set(Calendar.MINUTE, minutes) @@ -1655,20 +1387,32 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr // Adjust day and determine repeat days (existing logic) when { dayMatch == "today" -> { /* No change needed */ } - dayMatch == "tomorrow" -> calendar.add(Calendar.DAY_OF_MONTH, 1) - dayMatch == "next week" -> calendar.add(Calendar.DAY_OF_MONTH, 7) + + dayMatch == "tomorrow" -> { + calendar.add(Calendar.DAY_OF_MONTH, 1) + } + + dayMatch == "next week" -> { + calendar.add(Calendar.DAY_OF_MONTH, 7) + } + dayMatch == "next weekend" -> { val currentDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) val daysUntilSaturday = (Calendar.SATURDAY - currentDayOfWeek + 7) % 7 calendar.add(Calendar.DAY_OF_MONTH, daysUntilSaturday) } + dayMatch != null -> { - val dayMap = mapOf( - "sunday" to Calendar.SUNDAY, "monday" to Calendar.MONDAY, - "tuesday" to Calendar.TUESDAY, "wednesday" to Calendar.WEDNESDAY, - "thursday" to Calendar.THURSDAY, "friday" to Calendar.FRIDAY, - "saturday" to Calendar.SATURDAY - ) + val dayMap = + mapOf( + "sunday" to Calendar.SUNDAY, + "monday" to Calendar.MONDAY, + "tuesday" to Calendar.TUESDAY, + "wednesday" to Calendar.WEDNESDAY, + "thursday" to Calendar.THURSDAY, + "friday" to Calendar.FRIDAY, + "saturday" to Calendar.SATURDAY, + ) dayMap.entries.forEach { (dayName, dayConstant) -> if (dayMatch.contains(dayName)) { @@ -1688,36 +1432,51 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } // Create the intent to set the alarm - val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply { - putExtra(AlarmClock.EXTRA_MESSAGE, "New Alarm") - putExtra(AlarmClock.EXTRA_HOUR, calendar.get(Calendar.HOUR_OF_DAY)) - putExtra(AlarmClock.EXTRA_MINUTES, calendar.get(Calendar.MINUTE)) - putExtra(AlarmClock.EXTRA_SKIP_UI, true) + val intent = + Intent(AlarmClock.ACTION_SET_ALARM).apply { + putExtra(AlarmClock.EXTRA_MESSAGE, "New Alarm") + putExtra(AlarmClock.EXTRA_HOUR, calendar.get(Calendar.HOUR_OF_DAY)) + putExtra(AlarmClock.EXTRA_MINUTES, calendar.get(Calendar.MINUTE)) + putExtra(AlarmClock.EXTRA_SKIP_UI, true) - if (repeatDays.isNotEmpty()) { - putExtra(AlarmClock.EXTRA_DAYS, ArrayList(repeatDays)) + if (repeatDays.isNotEmpty()) { + putExtra(AlarmClock.EXTRA_DAYS, ArrayList(repeatDays)) + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - // Start the intent - globalContext?.startActivity(intent) + // Start the intent via UI event + _uiEvent.emit(UIEvent.StartIntent(intent)) } - //End Alarm - //For Reminder - private fun Reminder(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ + // For Reminder + private fun setReminder( + itemId: Long, + loadingItemId: Long, + speak: Boolean, + category: Category, + ) { try { if (chatList.isNotEmpty()) { - val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "")?:return + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "") ?: return - //val dayRegex = "(today|tomorrow|next week|next weekend|next month|((Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)( morning| evening)?))".toRegex(RegexOption.IGNORE_CASE) - val dayRegex = "(?i)\\b(today|tomorrow|next week|next weekend|((Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)( morning| evening| night| afternoon)?))\\b".toRegex(RegexOption.IGNORE_CASE) - //val timeRegex = "(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?".toRegex(RegexOption.IGNORE_CASE) + val dayRegex = + ( + "(?i)\\b(today|tomorrow|next week|next weekend|" + + "((Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)" + + "( morning| evening| night| afternoon)?))\\b" + ).toRegex(RegexOption.IGNORE_CASE) val timeRegex = "(?i)\\b(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?\\b".toRegex(RegexOption.IGNORE_CASE) - //val relativeTimeRegex = "(\\d+)\\s*(hours?|mins?|minutes?)\\s*(from now|later|next)?".toRegex(RegexOption.IGNORE_CASE) - val relativeTimeRegex = "(?i)\\b(\\d+)?\\s*(?:and\\s*(half))?\\s*(hours?|hrs?|mins?|minutes?|seconds?|secs?)\\s*(?:and\\s*(\\d+))?\\s*(mins?|minutes?|secs?|seconds?)?\\s*(from now|later|next)?\\b".toRegex(RegexOption.IGNORE_CASE) - val contextRegex = "remind me(?: to| for| of| about| to create)?\\s+(.*?)(?:\\s+(at|on|in)\\s+.*)?$".toRegex(RegexOption.IGNORE_CASE) + val relativeTimeRegex = + ( + "(?i)\\b(\\d+)?\\s*(?:and\\s*(half))?\\s*" + + "(hours?|hrs?|mins?|minutes?|seconds?|secs?)\\s*(?:and\\s*(\\d+))?\\s*" + + "(mins?|minutes?|secs?|seconds?)?\\s*(from now|later|next)?\\b" + ).toRegex(RegexOption.IGNORE_CASE) + val contextRegex = + "remind me(?: to| for| of| about| to create)?\\s+(.*?)(?:\\s+(at|on|in)\\s+.*)?$".toRegex( + RegexOption.IGNORE_CASE, + ) val contextMatch = contextRegex.find(prompt) val context = contextMatch?.groupValues?.get(1)?.trim() ?: "Reminder" @@ -1726,21 +1485,19 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr val relativeTimeMatch = relativeTimeRegex.find(prompt) val dayMatch = dayRegex.find(prompt)?.value?.lowercase() - if(timeMatch == null && relativeTimeMatch == null){ + if (timeMatch == null && relativeTimeMatch == null) { viewModelScope.launch { lockState = LockState.LockReminder(day = dayMatch, context = context) processResponse(getRandomResponse(ResponseStrings.promptForTime), loadingItemId, speak, Category.OTHER) } - }else{ - setReminderFromPrompt(dayMatch, timeMatch, relativeTimeMatch, context) - + } else { viewModelScope.launch { + setReminderFromPrompt(dayMatch, timeMatch, relativeTimeMatch, context) processResponse(getRandomResponse(ResponseStrings.reminderSetSuccess), loadingItemId, speak, category) } } - } - }catch (e: Exception){ + } catch (e: Exception) { Log.d("Exception", e.message.toString()) viewModelScope.launch { processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) @@ -1748,9 +1505,14 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } } - private fun handleReminderLockState (itemId: Long, loadingItemId: Long, speak: Boolean, state: LockState.LockReminder){ - try{ - val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "")?:return + private fun handleReminderLockState( + itemId: Long, + loadingItemId: Long, + speak: Boolean, + state: LockState.LockReminder, + ) { + try { + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "") ?: return val timeRegex = "(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?".toRegex(RegexOption.IGNORE_CASE) val relativeTimeRegex = "(\\d+)\\s*(hours?|mins?|minutes?)\\s*(from now|later|next)?".toRegex(RegexOption.IGNORE_CASE) @@ -1760,19 +1522,19 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr val timeMatch = timeRegex.find(prompt) val relativeTimeMatch = relativeTimeRegex.find(prompt) - if(timeMatch == null && relativeTimeMatch == null){ + if (timeMatch == null && relativeTimeMatch == null) { viewModelScope.launch { processResponse(getRandomResponse(ResponseStrings.invalidTime), loadingItemId, speak, Category.OTHER) } return - }else{ - setReminderFromPrompt(dayMatch, timeMatch, relativeTimeMatch, context) + } else { lockState = LockState.None viewModelScope.launch { + setReminderFromPrompt(dayMatch, timeMatch, relativeTimeMatch, context) processResponse(getRandomResponse(ResponseStrings.reminderSetSuccess), loadingItemId, speak, Category.ALARM) } } - }catch (e: Exception){ + } catch (e: Exception) { Log.d("Exception", e.message.toString()) viewModelScope.launch { processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) @@ -1780,13 +1542,18 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } } - private fun setReminderFromPrompt(dayMatch: String?, timeMatch: MatchResult?, relativeTimeMatch: MatchResult?,context: String) { + private suspend fun setReminderFromPrompt( + dayMatch: String?, + timeMatch: MatchResult?, + relativeTimeMatch: MatchResult?, + context: String, + ) { val calendar = Calendar.getInstance() if (relativeTimeMatch != null) { // Parse the first time unit val firstValue = relativeTimeMatch.groupValues[1].takeIf { it.isNotEmpty() }?.toIntOrNull() ?: 0 - val halfValue = if (relativeTimeMatch.groupValues[2] == "half") 0.5 else 0.0 // Handle "half" scenario + val halfValue = if (relativeTimeMatch.groupValues[2] == "half") 0.5 else 0.0 // Handle "half" scenario val firstUnit = relativeTimeMatch.groupValues[3].lowercase(Locale.ROOT) var secondValue = 0 @@ -1806,13 +1573,19 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr calendar.add(Calendar.MINUTE, 30) // Correctly add 30 minutes for "half" } } - firstUnit.contains("min") -> calendar.add(Calendar.MINUTE, firstValue) + + firstUnit.contains("min") -> { + calendar.add(Calendar.MINUTE, firstValue) + } } // Add second time unit if available when { secondUnit.contains("hour") || secondUnit.contains("hrs") -> calendar.add(Calendar.HOUR_OF_DAY, secondValue) - secondUnit.contains("minute") || secondUnit.contains("minutes") || secondUnit.contains("min") -> calendar.add(Calendar.MINUTE, secondValue) + + secondUnit.contains("minute") || + secondUnit.contains("minutes") || + secondUnit.contains("min") -> calendar.add(Calendar.MINUTE, secondValue) } } else if (timeMatch != null) { // Handle absolute time @@ -1821,12 +1594,15 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr val amPm = timeMatch.groupValues[4].uppercase(Locale.ROOT) // Convert time to 24-hour format - val hour24 = when { - amPm == "PM" && hour != 12 -> hour + 12 - amPm == "AM" && hour == 12 -> 0 - // If no AM/PM, assume 24-hour format - else -> hour - } + val hour24 = + when { + amPm == "PM" && hour != 12 -> hour + 12 + + amPm == "AM" && hour == 12 -> 0 + + // If no AM/PM, assume 24-hour format + else -> hour + } calendar.set(Calendar.HOUR_OF_DAY, hour24) calendar.set(Calendar.MINUTE, minutes) @@ -1837,20 +1613,32 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr // Adjust day and determine repeat days (existing logic) when { dayMatch == "today" -> { /* No change needed */ } - dayMatch == "tomorrow" -> calendar.add(Calendar.DAY_OF_MONTH, 1) - dayMatch == "next week" -> calendar.add(Calendar.DAY_OF_MONTH, 7) + + dayMatch == "tomorrow" -> { + calendar.add(Calendar.DAY_OF_MONTH, 1) + } + + dayMatch == "next week" -> { + calendar.add(Calendar.DAY_OF_MONTH, 7) + } + dayMatch == "next weekend" -> { val currentDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) val daysUntilSaturday = (Calendar.SATURDAY - currentDayOfWeek + 7) % 7 calendar.add(Calendar.DAY_OF_MONTH, daysUntilSaturday) } + dayMatch != null -> { - val dayMap = mapOf( - "sunday" to Calendar.SUNDAY, "monday" to Calendar.MONDAY, - "tuesday" to Calendar.TUESDAY, "wednesday" to Calendar.WEDNESDAY, - "thursday" to Calendar.THURSDAY, "friday" to Calendar.FRIDAY, - "saturday" to Calendar.SATURDAY - ) + val dayMap = + mapOf( + "sunday" to Calendar.SUNDAY, + "monday" to Calendar.MONDAY, + "tuesday" to Calendar.TUESDAY, + "wednesday" to Calendar.WEDNESDAY, + "thursday" to Calendar.THURSDAY, + "friday" to Calendar.FRIDAY, + "saturday" to Calendar.SATURDAY, + ) dayMap.entries.forEach { (dayName, dayConstant) -> if (dayMatch.contains(dayName)) { @@ -1870,211 +1658,194 @@ class MainViewModel(application: Application, private val speak: Boolean) : Andr } // Create the intent to set the alarm - val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply { - putExtra(AlarmClock.EXTRA_MESSAGE, context) - putExtra(AlarmClock.EXTRA_HOUR, calendar.get(Calendar.HOUR_OF_DAY)) - putExtra(AlarmClock.EXTRA_MINUTES, calendar.get(Calendar.MINUTE)) - putExtra(AlarmClock.EXTRA_SKIP_UI, true) - putExtra(AlarmClock.EXTRA_RINGTONE, AlarmClock.VALUE_RINGTONE_SILENT) + val intent = + Intent(AlarmClock.ACTION_SET_ALARM).apply { + putExtra(AlarmClock.EXTRA_MESSAGE, context) + putExtra(AlarmClock.EXTRA_HOUR, calendar.get(Calendar.HOUR_OF_DAY)) + putExtra(AlarmClock.EXTRA_MINUTES, calendar.get(Calendar.MINUTE)) + putExtra(AlarmClock.EXTRA_SKIP_UI, true) + putExtra(AlarmClock.EXTRA_RINGTONE, AlarmClock.VALUE_RINGTONE_SILENT) - if (repeatDays.isNotEmpty()) { - putExtra(AlarmClock.EXTRA_DAYS, ArrayList(repeatDays)) + if (repeatDays.isNotEmpty()) { + putExtra(AlarmClock.EXTRA_DAYS, ArrayList(repeatDays)) + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - // Start the intent - globalContext?.startActivity(intent) + // Start the intent via UI event + _uiEvent.emit(UIEvent.StartIntent(intent)) } - //End Reminder - private fun getRandomResponse(responses: List): String { - return responses.randomOrNull() ?: responses.firstOrNull() ?: "An unexpected error occurred." // Fallback - } + private fun getRandomResponse(responses: List): String = + responses.randomOrNull() ?: responses.firstOrNull() ?: "An unexpected error occurred." object ResponseStrings { + val permissionContactsCall = + listOf( + "I need permission to access your contacts and make phone call. Please allow and try again.", + "Hey, I’ll need access to your contacts and calling first. Can you allow that?", + "Looks like I don’t have permission to call yet. Please enable it and retry.", + "I can help with that, but I need contacts and call access first.", + "Please grant me contacts and calling permission so I can make the call for you.", + "I can’t make calls without your permission. Could you turn it on?", + ) - // Permission for making call Related - val permissionContactsCall = listOf( - "I need permission to access your contacts and make phone call. Please allow and try again.", - "Hey, I’ll need access to your contacts and calling first. Can you allow that?", - "Looks like I don’t have permission to call yet. Please enable it and retry.", - "I can help with that, but I need contacts and call access first.", - "Please grant me contacts and calling permission so I can make the call for you.", - "I can’t make calls without your permission. Could you turn it on?" - ) + val permissionLocation = + listOf( + "I need permission to access your location. Please try again.", + "I can get the weather, but I’ll need your location first.", + "Looks like location access isn’t granted. Please allow it and try again.", + "Hey, could you enable location permission so I can show the weather?", + "To fetch the forecast, I’ll need your location. Can you grant access?", + "Without your location, I can’t check the weather. Please turn it on.", + ) - // Location permission for weather report - val permissionLocation = listOf( - "I need permission to access your location. Please try again.", - "I can get the weather, but I’ll need your location first.", - "Looks like location access isn’t granted. Please allow it and try again.", - "Hey, could you enable location permission so I can show the weather?", - "To fetch the forecast, I’ll need your location. Can you grant access?", - "Without your location, I can’t check the weather. Please turn it on." - ) + val locationServiceOff = + listOf( + "I need to know your location for that. Please turn on your location and try again.", + "looks like location is off. Could you enable it?", + "I can’t continue without your location. Please turn it on.", + "Hey, I’ll need your location for this. Can you switch it on?", + "Looks like your location services are disabled. Please activate them.", + "Please turn on location, then I’ll be able to continue.", + ) - // Asking user to turn on location for weather report - val locationServiceOff = listOf( - "I need to know your location for that. Please turn on your location and try again.", - "looks like location is off. Could you enable it?", - "I can’t continue without your location. Please turn it on.", - "Hey, I’ll need your location for this. Can you switch it on?", - "Looks like your location services are disabled. Please activate them.", - "Please turn on location, then I’ll be able to continue." - ) + val callFailed = + listOf( + "Sorry, failed to make call. Please try again.", + "Oops, the call didn’t go through. Want to retry?", + "I couldn’t complete the call. Please try again.", + "Looks like that call failed. Give it another shot?", + "Something went wrong with the call. Please try again later.", + "I wasn’t able to connect the call. Can we try once more?", + ) - // Failure / Not Found Related - val callFailed = listOf( - "Sorry, failed to make call. Please try again.", - "Oops, the call didn’t go through. Want to retry?", - "I couldn’t complete the call. Please try again.", - "Looks like that call failed. Give it another shot?", - "Something went wrong with the call. Please try again later.", - "I wasn’t able to connect the call. Can we try once more?" - ) + val contactNotFound = + listOf( + "I cannot find such contact, please try again.", + "I didn’t find that contact in your list.", + "No contact matched that name, could you check and retry?", + "Sorry, I couldn’t locate that person in your contacts.", + "Looks like that name isn’t saved in your contacts.", + "I couldn’t find that contact. Maybe try with a different name?", + ) - // Contact not found - val contactNotFound = listOf( - "I cannot find such contact, please try again.", - "I didn’t find that contact in your list.", - "No contact matched that name, could you check and retry?", - "Sorry, I couldn’t locate that person in your contacts.", - "Looks like that name isn’t saved in your contacts.", - "I couldn’t find that contact. Maybe try with a different name?" - ) + val songNotFound = + listOf( + "I can not find such song, please try again.", + "Sorry, I couldn’t find that track.", + "no song matched your request. Want to try another?", + "I wasn’t able to locate that song. Please retry.", + "Looks like that song isn’t available right now.", + "I couldn’t find that one. Maybe try with a different title?", + ) - // Song not found - val songNotFound = listOf( - "I can not find such song, please try again.", - "Sorry, I couldn’t find that track.", - "no song matched your request. Want to try another?", - "I wasn’t able to locate that song. Please retry.", - "Looks like that song isn’t available right now.", - "I couldn’t find that one. Maybe try with a different title?" - ) + val locationNotFound = + listOf( + "I can not find such location, please try again.", + "Sorry, I couldn’t figure out where that is.", + "I wasn’t able to locate that place.", + "No results for that location. Can you check and try again?", + "Looks like that place isn’t on my map data.", + "I couldn’t find that spot. Maybe try with a different name?", + ) - // Location not found - val locationNotFound = listOf( - "I can not find such location, please try again.", - "Sorry, I couldn’t figure out where that is.", - "I wasn’t able to locate that place.", - "No results for that location. Can you check and try again?", - "Looks like that place isn’t on my map data.", - "I couldn’t find that spot. Maybe try with a different name?" - ) + val weatherReportUnavailable = + listOf( + "Seems weather report is not available, please try again.", + "Sorry, I couldn’t get the weather right now.", + "The weather service isn’t responding. Please try later.", + "Looks like weather data is down at the moment.", + "I wasn’t able to fetch the forecast. Can you retry later?", + "Weather info isn’t available right now. Please check back soon.", + ) - // Weather report unavailable - val weatherReportUnavailable = listOf( - "Seems weather report is not available, please try again.", - "Sorry, I couldn’t get the weather right now.", - "The weather service isn’t responding. Please try later.", - "Looks like weather data is down at the moment.", - "I wasn’t able to fetch the forecast. Can you retry later?", - "Weather info isn’t available right now. Please check back soon." - ) + val locationUnknownSuggestCity = + listOf( + "Your location is not available to me. Please try again with your city name.", + "I couldn’t detect your location. Could you tell me your city instead?", + "Looks like location services aren’t working. Please provide your city name.", + "I’m not getting your location right now. Can you enter your city?", + "Sorry, I can’t access your current location. A city name would help.", + "Your location seems unavailable. Please try with your city name.", + ) - // Location unknown, suggest city - val locationUnknownSuggestCity = listOf( - "Your location is not available to me. Please try again with your city name.", - "I couldn’t detect your location. Could you tell me your city instead?", - "Looks like location services aren’t working. Please provide your city name.", - "I’m not getting your location right now. Can you enter your city?", - "Sorry, I can’t access your current location. A city name would help.", - "Your location seems unavailable. Please try with your city name." - ) + val invalidTime = + listOf( + "That doesn't seems like an actual time. Please try again.", + "I didn’t recognize that as a valid time.", + "That time format looks off. Could you retry?", + "Sorry, I couldn’t understand that time input.", + "That doesn’t look like a proper time. Please try again.", + "Can you give me a valid time so I can continue?", + ) - // Invalid time - val invalidTime = listOf( - "That doesn't seems like an actual time. Please try again.", - "I didn’t recognize that as a valid time.", - "That time format looks off. Could you retry?", - "Sorry, I couldn’t understand that time input.", - "That doesn’t look like a proper time. Please try again.", - "Can you give me a valid time so I can continue?" - ) + val alarmSetSuccess = + listOf( + "Alarm set successfully...", + "Done! Your alarm is ready.", + "Great, I’ve set the alarm for you.", + "All set, your alarm has been scheduled.", + "Alarm saved successfully.", + "Okay, I’ve configured the alarm as requested.", + ) - // Alarm success - val alarmSetSuccess = listOf( - "Alarm set successfully...", - "Done! Your alarm is ready.", - "Great, I’ve set the alarm for you.", - "All set, your alarm has been scheduled.", - "Alarm saved successfully.", - "Okay, I’ve configured the alarm as requested." - ) + val reminderSetSuccess = + listOf( + "Reminder set successfully...", + "Done! Your reminder is ready.", + "Great, I’ve saved the reminder for you.", + "All set, your reminder has been scheduled.", + "Reminder saved successfully.", + "Okay, I’ve created the reminder as requested.", + ) - // Reminder success - val reminderSetSuccess = listOf( - "Reminder set successfully...", - "Done! Your reminder is ready.", - "Great, I’ve saved the reminder for you.", - "All set, your reminder has been scheduled.", - "Reminder saved successfully.", - "Okay, I’ve created the reminder as requested." - ) + val promptForTime = + listOf( + "Sure, at what time?", + "Alright, when should I set it?", + "Okay, what time would you like?", + "Got it, please tell me the time.", + "When do you want me to set it for?", + "Sure thing, what time works for you?", + ) - // Prompt for time - val promptForTime = listOf( - "Sure, at what time?", - "Alright, when should I set it?", - "Okay, what time would you like?", - "Got it, please tell me the time.", - "When do you want me to set it for?", - "Sure thing, what time works for you?" - ) - - // Generic error - val genericError = listOf( - "Something went wrong, please try again.", - "Oops, that didn’t work. Please retry.", - "I ran into an issue. Can you try again?", - "Sorry, something broke there. Please try once more.", - "That didn’t go through. Could you retry?", - "An error popped up. Let’s try that again." - ) + val genericError = + listOf( + "Something went wrong, please try again.", + "Oops, that didn’t work. Please retry.", + "I ran into an issue. Can you try again?", + "Sorry, something broke there. Please try once more.", + "That didn’t go through. Could you retry?", + "An error popped up. Let’s try that again.", + ) } - fun saveKeys(youtubeApiKey: String, chatApiKey: String) { + fun saveKeys( + youtubeApiKey: String, + chatApiKey: String, + ) { viewModelScope.launch(Dispatchers.IO) { - with(securedPreferences.edit()) { - if (youtubeApiKey.isNotBlank()) { - putString("youtube_api_key", youtubeApiKey) - Log.d("MainViewModel", "YouTube API Key saved securely.") - } - if (chatApiKey.isNotBlank()) { - putString("chat_api_key", chatApiKey) - Log.d("MainViewModel", "Chat API Key saved securely.") - } - apply() - } + settingsRepository.saveKeys(youtubeApiKey, chatApiKey) + Log.d("MainViewModel", "API Keys saved securely.") } } fun loadYoutubeKey(): String? { - // First, try to retrieve the key from secured SharedPreferences - var youtubeKey = securedPreferences.getString("youtube_api_key", null) - - // If the key is not in SharedPreferences, fall back to BuildConfig + var youtubeKey = settingsRepository.getYoutubeApiKey() if (youtubeKey.isNullOrBlank()) { Log.d("MainViewModel", "YouTube key not found in SharedPreferences, checking BuildConfig.") youtubeKey = BuildConfig.YOUTUBE_API_KEY } - return youtubeKey } fun loadChatKey(): String? { - // First, try to retrieve the key from secured SharedPreferences - var chatKey = securedPreferences.getString("chat_api_key", null) - - // If the key is not in SharedPreferences, fall back to BuildConfig + var chatKey = settingsRepository.getChatApiKey() if (chatKey.isNullOrBlank()) { Log.d("MainViewModel", "Chat key not found in SharedPreferences, checking BuildConfig.") chatKey = BuildConfig.GROQ_API_KEY } - return chatKey } - } diff --git a/app/src/main/java/com/app/assistant/viewmodel/MainViewModelFactory.kt b/app/src/main/java/com/app/assistant/viewmodel/MainViewModelFactory.kt index e8d4535..a867370 100644 --- a/app/src/main/java/com/app/assistant/viewmodel/MainViewModelFactory.kt +++ b/app/src/main/java/com/app/assistant/viewmodel/MainViewModelFactory.kt @@ -3,16 +3,21 @@ package com.app.assistant.viewmodel import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.app.assistant.repository.ContactsRepository +import com.app.assistant.repository.SettingsRepository +import com.app.assistant.repository.WeatherRepository class MainViewModelFactory( private val application: Application, - private val speak: Boolean + private val speak: Boolean, ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(MainViewModel::class.java)) { + val settingsRepo = SettingsRepository(application) + val contactsRepo = ContactsRepository(application) + val weatherRepo = WeatherRepository(application) @Suppress("UNCHECKED_CAST") - return MainViewModel(application, speak) as T + return MainViewModel(application, speak, settingsRepo, contactsRepo, weatherRepo) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/app/src/main/java/com/app/assistant/viewmodel/UIEvent.kt b/app/src/main/java/com/app/assistant/viewmodel/UIEvent.kt new file mode 100644 index 0000000..6236db2 --- /dev/null +++ b/app/src/main/java/com/app/assistant/viewmodel/UIEvent.kt @@ -0,0 +1,41 @@ +package com.app.assistant.viewmodel + +import android.content.Intent +import com.google.android.gms.common.api.ResolvableApiException + +sealed class UIEvent { + data class RequestPermissions( + val permissions: Array, + val requestCode: Int, + ) : UIEvent() + + data class StartIntent( + val intent: Intent, + ) : UIEvent() + + data class ShowToast( + val message: String, + ) : UIEvent() + + data class SpeakText( + val text: String, + ) : UIEvent() + + object StopSpeaking : UIEvent() + + object StartSpeechRecognition : UIEvent() + + object StopSpeechRecognition : UIEvent() + + data class GetLocationForWeather( + val itemId: Long, + val loadingItemId: Long, + val speak: Boolean, + val categoryName: String, + val prompt: String, + ) : UIEvent() + + data class ResolveLocationSettings( + val exception: ResolvableApiException, + ) : UIEvent() +} diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..5c34300 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21