diff --git a/server/src/main/kotlin/com/xebia/functional/xef/server/Server.kt b/server/src/main/kotlin/com/xebia/functional/xef/server/Server.kt index 92aecf7ee..2566c167f 100644 --- a/server/src/main/kotlin/com/xebia/functional/xef/server/Server.kt +++ b/server/src/main/kotlin/com/xebia/functional/xef/server/Server.kt @@ -59,7 +59,7 @@ object Server { requestTimeout = 0 // disabled } install(Auth) - install(Logging) { level = LogLevel.ALL } + install(Logging) { level = LogLevel.INFO } install(ClientContentNegotiation) } diff --git a/server/src/main/kotlin/com/xebia/functional/xef/server/http/routes/AssistantRoutes.kt b/server/src/main/kotlin/com/xebia/functional/xef/server/http/routes/AssistantRoutes.kt index 2fd517f6d..419efc5de 100644 --- a/server/src/main/kotlin/com/xebia/functional/xef/server/http/routes/AssistantRoutes.kt +++ b/server/src/main/kotlin/com/xebia/functional/xef/server/http/routes/AssistantRoutes.kt @@ -58,10 +58,15 @@ fun Routing.assistantRoutes(logger: KLogger) { val assistantsApi = openAI.assistants val response = assistantsApi.listAssistants(configure = { header("OpenAI-Beta", "assistants=v2") }) + call.respond(HttpStatusCode.OK, response) + } catch (e: SerializationException) { + val trace = e.stackTraceToString() + logger.error { "Serialization error: $trace" } + call.respond(HttpStatusCode.BadRequest, "Serialization error: $trace") } catch (e: Exception) { val trace = e.stackTraceToString() - logger.error { "Error listing assistants: $trace" } + logger.error { "Error retrieving assistants: $trace" } call.respond(HttpStatusCode.BadRequest, "Invalid request: $trace") } } @@ -76,7 +81,12 @@ fun Routing.assistantRoutes(logger: KLogger) { call.respond(HttpStatusCode.BadRequest, "Invalid assistant id") return@put } - val assistant = Assistant(id) + + val token = call.getToken().value + val openAI = OpenAI(Config(token = token), logRequests = true) + val assistantsApi = openAI.assistants + val assistant = Assistant(id, assistantsApi = assistantsApi) + val response = assistant.modify(request).get() logger.info { "Modified assistant: ${response.name} with id: ${response.id}" } call.respond(HttpStatusCode.OK, response) @@ -103,7 +113,8 @@ fun Routing.assistantRoutes(logger: KLogger) { } val openAI = OpenAI(Config(token = token.value), logRequests = true) val assistantsApi = openAI.assistants - val response = assistantsApi.deleteAssistant(id, configure = { header("OpenAI-Beta", "assistants=v2") }) + val response = + assistantsApi.deleteAssistant(id, configure = { header("OpenAI-Beta", "assistants=v2") }) logger.info { "Deleted assistant: with id: ${response.id}" } call.respond(status = HttpStatusCode.NoContent, response) } catch (e: Exception) { diff --git a/server/web/src/utils/api/chatCompletions.ts b/server/web/src/utils/api/chatCompletions.ts index 56cdba055..adf4fb61c 100644 --- a/server/web/src/utils/api/chatCompletions.ts +++ b/server/web/src/utils/api/chatCompletions.ts @@ -8,4 +8,4 @@ export function openai (settings: Settings): OpenAI { dangerouslyAllowBrowser: true, apiKey: settings.apiKey, // defaults to process.env["OPENAI_API_KEY"] }); -} \ No newline at end of file +} diff --git a/server/xefMobile/.kotlin/sessions/kotlin-compiler-859541645610285268.salive b/server/xefMobile/.kotlin/sessions/kotlin-compiler-10822383352460149637.salive similarity index 100% rename from server/xefMobile/.kotlin/sessions/kotlin-compiler-859541645610285268.salive rename to server/xefMobile/.kotlin/sessions/kotlin-compiler-10822383352460149637.salive diff --git a/server/xefMobile/composeApp/build.gradle.kts b/server/xefMobile/composeApp/build.gradle.kts index 3b024bef1..39fc77d4e 100644 --- a/server/xefMobile/composeApp/build.gradle.kts +++ b/server/xefMobile/composeApp/build.gradle.kts @@ -90,6 +90,11 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.3") implementation("io.ktor:ktor-client-serialization-jvm:2.3.11") implementation("com.google.accompanist:accompanist-permissions:0.34.0") + implementation("androidx.compose.runtime:runtime:1.6.7") + implementation("io.ktor:ktor-client-cio-jvm:2.3.11") + implementation("io.ktor:ktor-client-cio:2.3.11") + implementation("io.ktor:ktor-client-core-jvm:2.3.11") + implementation("androidx.compose.material3:material3:1.2.1") } } } diff --git a/server/xefMobile/composeApp/src/androidMain/AndroidManifest.xml b/server/xefMobile/composeApp/src/androidMain/AndroidManifest.xml index c6670f1a1..30c168eb5 100644 --- a/server/xefMobile/composeApp/src/androidMain/AndroidManifest.xml +++ b/server/xefMobile/composeApp/src/androidMain/AndroidManifest.xml @@ -1,6 +1,11 @@ + + + + + + diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/MainActivity.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/MainActivity.kt index 6278e6be5..025d57305 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/MainActivity.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/MainActivity.kt @@ -5,14 +5,21 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import com.server.movile.xef.android.ui.viewmodels.AuthViewModel import com.xef.xefMobile.services.ApiService +import com.xef.xefMobile.ui.viewmodels.SettingsViewModel class MainActivity : ComponentActivity() { private lateinit var authViewModel: AuthViewModel + private lateinit var settingsViewModel: SettingsViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) authViewModel = AuthViewModel(this, ApiService()) + settingsViewModel = SettingsViewModel(this) - setContent { XefAndroidApp(authViewModel = authViewModel) } + authViewModel.logout() + + setContent { + XefAndroidApp(authViewModel = authViewModel, settingsViewModel = settingsViewModel) + } } } diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/MainLayout.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/MainLayout.kt index 434256b95..7e6ae86ad 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/MainLayout.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/MainLayout.kt @@ -1,7 +1,6 @@ package com.xef.xefMobile import android.annotation.SuppressLint -import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -9,6 +8,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Menu import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,6 +40,14 @@ fun MainLayout( val CustomTextBlue = Color(0xFF0199D7) val context = LocalContext.current + val authToken by authViewModel.authToken.observeAsState() + + LaunchedEffect(authToken) { + if (authToken == null) { + navController.navigate(Screens.Login.screen) { popUpTo(0) { inclusive = true } } + } + } + ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = true, @@ -178,10 +188,11 @@ fun MainLayout( ) }, onClick = { - coroutineScope.launch { drawerState.close() } - authViewModel.logout() - Toast.makeText(context, "Logged out", Toast.LENGTH_SHORT).show() - navController.navigate(Screens.Login.screen) { popUpTo(0) { inclusive = true } } + coroutineScope.launch { + drawerState.close() + authViewModel.logout() + navController.navigate(Screens.Login.screen) { popUpTo(0) { inclusive = true } } + } } ) } diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/XefAndroidApp.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/XefAndroidApp.kt index 81352d59c..2aa786050 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/XefAndroidApp.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/XefAndroidApp.kt @@ -8,57 +8,94 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.server.movile.xef.android.ui.screens.* +import androidx.navigation.navArgument +import com.server.movile.xef.android.ui.screens.LoginScreen +import com.server.movile.xef.android.ui.screens.RegisterScreen import com.server.movile.xef.android.ui.screens.menu.AssistantScreen import com.server.movile.xef.android.ui.screens.menu.CreateAssistantScreen import com.server.movile.xef.android.ui.screens.navigationdrawercompose.HomeScreen import com.server.movile.xef.android.ui.viewmodels.IAuthViewModel import com.xef.xefMobile.ui.screens.Screens +import com.xef.xefMobile.ui.screens.SettingsScreen +import com.xef.xefMobile.ui.viewmodels.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class) @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable -fun XefAndroidApp(authViewModel: IAuthViewModel) { - val navigationController = rememberNavController() +fun XefAndroidApp(authViewModel: IAuthViewModel, settingsViewModel: SettingsViewModel) { + val navController = rememberNavController() val userName by authViewModel.userName.observeAsState("") NavHost( - navController = navigationController, + navController = navController, startDestination = Screens.Login.screen, modifier = Modifier.padding(top = 16.dp) ) { - composable(Screens.Login.screen) { LoginScreen(authViewModel, navigationController) } - composable(Screens.Register.screen) { RegisterScreen(authViewModel, navigationController) } + composable(Screens.Login.screen) { LoginScreen(authViewModel, navController) } + composable(Screens.Register.screen) { RegisterScreen(authViewModel, navController) } composable(Screens.Home.screen) { MainLayout( - navController = navigationController, + navController = navController, authViewModel = authViewModel, userName = userName.orEmpty() ) { - HomeScreen(authViewModel, navigationController) + HomeScreen(authViewModel, navController) } } composable(Screens.Assistants.screen) { MainLayout( - navController = navigationController, + navController = navController, authViewModel = authViewModel, userName = userName.orEmpty() ) { - AssistantScreen(navigationController, authViewModel) + AssistantScreen(navController, authViewModel, settingsViewModel) + } + } + composable( + route = Screens.CreateAssistantWithArgs.screen, + arguments = listOf(navArgument("assistantId") { type = NavType.StringType }) + ) { backStackEntry -> + val assistantId = backStackEntry.arguments?.getString("assistantId") + MainLayout( + navController = navController, + authViewModel = authViewModel, + userName = userName.orEmpty() + ) { + CreateAssistantScreen( + navController = navController, + authViewModel = authViewModel, + settingsViewModel = settingsViewModel, + assistantId = assistantId + ) } } composable(Screens.CreateAssistant.screen) { MainLayout( - navController = navigationController, + navController = navController, + authViewModel = authViewModel, + userName = userName.orEmpty() + ) { + CreateAssistantScreen( + navController = navController, + authViewModel = authViewModel, + settingsViewModel = settingsViewModel, + assistantId = null + ) + } + } + composable(Screens.Settings.screen) { + MainLayout( + navController = navController, authViewModel = authViewModel, userName = userName.orEmpty() ) { - CreateAssistantScreen(navigationController) + SettingsScreen(navController, settingsViewModel) } } - // ... other composable screens ... + // Add other composable screens here... } } diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/model/AssistantModel.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/model/AssistantModel.kt index 6a8e331b9..9e95225d5 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/model/AssistantModel.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/model/AssistantModel.kt @@ -1,7 +1,21 @@ package com.xef.xefMobile.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Serializable data class Assistant(val id: String, val name: String) +@Serializable +data class Assistant( + val id: String, + val name: String, + @SerialName("created_at") val createdAt: Long, + val description: String?, + val model: String, + val instructions: String, + val tools: List, + val temperature: Float, + @SerialName("top_p") val topP: Float +) + +@Serializable data class Tool(val type: String) @Serializable data class AssistantsResponse(val data: List) diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/services/ApiService.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/services/ApiService.kt index ce92f4db2..43129b2f8 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/services/ApiService.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/services/ApiService.kt @@ -1,6 +1,8 @@ package com.xef.xefMobile.services import android.util.Log +import com.server.movile.xef.android.ui.viewmodels.CreateAssistantRequest +import com.server.movile.xef.android.ui.viewmodels.ModifyAssistantRequest import com.xef.xefMobile.model.* import com.xef.xefMobile.network.client.HttpClientProvider import io.ktor.client.call.* @@ -14,7 +16,7 @@ class ApiService { return try { HttpClientProvider.client .post { - url("http://10.0.2.2:8081/register") + url("https://ace-asp-ghastly.ngrok-free.app/register") contentType(ContentType.Application.Json) setBody(request) } @@ -29,7 +31,7 @@ class ApiService { return try { val response: HttpResponse = HttpClientProvider.client.post { - url("http://10.0.2.2:8081/login") + url("https://ace-asp-ghastly.ngrok-free.app/login") contentType(ContentType.Application.Json) setBody(request) } @@ -48,18 +50,62 @@ class ApiService { return try { val response: HttpResponse = HttpClientProvider.client.get { - url("http://10.0.2.2:8081/v1/settings/assistants") + url("https://ace-asp-ghastly.ngrok-free.app/v1/settings/assistants") header(HttpHeaders.Authorization, "Bearer $authToken") - header("OpenAI-Beta", "assistants=v1") + header("OpenAI-Beta", "assistants=v2") } val responseBody: String = response.bodyAsText() Log.d("ApiService", "Assistants response body: $responseBody") - response.body() } catch (e: Exception) { Log.e("ApiService", "Fetching assistants failed: ${e.message}", e) throw e } } + + suspend fun createAssistant(authToken: String, request: CreateAssistantRequest): HttpResponse { + return try { + HttpClientProvider.client.post { + url("https://ace-asp-ghastly.ngrok-free.app/v1/settings/assistants") + contentType(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer $authToken") + setBody(request) + } + } catch (e: Exception) { + Log.e("ApiService", "Creating assistant failed: ${e.message}", e) + throw e + } + } + + suspend fun deleteAssistant(authToken: String, assistantId: String): HttpResponse { + return try { + HttpClientProvider.client.delete { + url("https://ace-asp-ghastly.ngrok-free.app/v1/settings/assistants/$assistantId") + header(HttpHeaders.Authorization, "Bearer $authToken") + header("OpenAI-Beta", "assistants=v2") + } + } catch (e: Exception) { + Log.e("ApiService", "Deleting assistant failed: ${e.message}", e) + throw e + } + } + + suspend fun updateAssistant( + authToken: String, + id: String, + request: ModifyAssistantRequest + ): HttpResponse { + return try { + HttpClientProvider.client.put { + url("https://ace-asp-ghastly.ngrok-free.app/v1/settings/assistants/$id") + contentType(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer $authToken") + setBody(request) + } + } catch (e: Exception) { + Log.e("ApiService", "Updating assistant failed: ${e.message}", e) + throw e + } + } } diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/composable/FilePickerDialog.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/composable/FilePickerDialog.kt index dcf40874a..d577af7e3 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/composable/FilePickerDialog.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/composable/FilePickerDialog.kt @@ -1,5 +1,6 @@ package com.xef.xefMobile.ui.composable +import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable @@ -16,8 +17,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.server.movile.xef.android.ui.themes.CustomColors import com.xef.xefMobile.ui.viewmodels.PathViewModel @@ -26,34 +26,47 @@ import com.xef.xefMobile.ui.viewmodels.PathViewModel fun FilePickerDialog( onDismissRequest: () -> Unit, customColors: CustomColors, - onFilesSelected: () -> Unit // Callback for when files are selected + onFilesSelected: () -> Unit, + mimeTypeFilter: String = "*/*", + isForCodeInterpreter: Boolean = false ) { val viewModel: PathViewModel = viewModel() - val state = viewModel.state + val state = + if (isForCodeInterpreter) viewModel.codeInterpreterState else viewModel.fileSearchState val context = LocalContext.current - val permissionState = - rememberPermissionState(permission = android.Manifest.permission.READ_EXTERNAL_STORAGE) + val permissions = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + listOf( + android.Manifest.permission.READ_MEDIA_IMAGES, + android.Manifest.permission.READ_MEDIA_VIDEO, + android.Manifest.permission.READ_MEDIA_AUDIO + ) + } else { + listOf(android.Manifest.permission.READ_EXTERNAL_STORAGE) + } + + val permissionState = rememberMultiplePermissionsState(permissions) var selectedFile by remember { mutableStateOf(null) } - SideEffect { - if (!permissionState.status.isGranted) { - permissionState.launchPermissionRequest() - } - } + LaunchedEffect(Unit) { permissionState.launchMultiplePermissionRequest() } val filePickerLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetMultipleContents(), - onResult = { uris -> - viewModel.onFilePathsListChange(uris, context) - if (uris.isNotEmpty()) { - onFilesSelected() // Call the callback when files are selected - selectedFile = state.filePaths.firstOrNull() + rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { + val takeFlags: Int = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, takeFlags) + if (isForCodeInterpreter) { + viewModel.onCodeInterpreterPathsChange(listOf(it), context) + } else { + viewModel.onFileSearchPathsChange(listOf(it), context) } + onFilesSelected() + selectedFile = state.filePaths.firstOrNull() } - ) + } AlertDialog( onDismissRequest = onDismissRequest, @@ -61,7 +74,7 @@ fun FilePickerDialog( Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(text = "Selected Files", fontWeight = FontWeight.Bold) Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider() + Divider() } }, text = { @@ -93,10 +106,10 @@ fun FilePickerDialog( } OutlinedButton( onClick = { - if (permissionState.status.isGranted) { - filePickerLauncher.launch("*/*") + if (permissionState.allPermissionsGranted) { + filePickerLauncher.launch(arrayOf(mimeTypeFilter)) } else { - permissionState.launchPermissionRequest() + permissionState.launchMultiplePermissionRequest() } }, colors = @@ -110,7 +123,11 @@ fun FilePickerDialog( if (selectedFile != null) { OutlinedButton( onClick = { - viewModel.removeFilePath(selectedFile!!) + if (isForCodeInterpreter) { + viewModel.removeCodeInterpreterPath(selectedFile!!) + } else { + viewModel.removeFileSearchPath(selectedFile!!) + } selectedFile = null }, colors = diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/composable/UriPathFinder.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/composable/UriPathFinder.kt index d6eba6bda..f3e70f1a3 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/composable/UriPathFinder.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/composable/UriPathFinder.kt @@ -2,12 +2,10 @@ package com.xef.xefMobile.ui.composable import android.content.ContentUris import android.content.Context -import android.database.Cursor import android.net.Uri import android.os.Environment import android.provider.DocumentsContract import android.provider.MediaStore -import java.lang.NumberFormatException class UriPathFinder { @@ -18,7 +16,7 @@ class UriPathFinder { isExternalStorageDocument(uri) -> handleExternalStorageDocument(uri) isDownloadsDocument(uri) -> handleDownloadsDocument(context, uri) isMediaDocument(uri) -> handleMediaDocument(context, uri) - else -> null + else -> getDataColumn(context, uri, null, null) } } "content".equals(uri.scheme, ignoreCase = true) -> getDataColumn(context, uri, null, null) @@ -29,37 +27,36 @@ class UriPathFinder { private fun handleExternalStorageDocument(uri: Uri): String? { val docId = DocumentsContract.getDocumentId(uri) - val split = docId.split(":").toTypedArray() + val split = docId.split(":") val type = split[0] return if ("primary".equals(type, ignoreCase = true)) { Environment.getExternalStorageDirectory().toString() + "/" + split[1] } else { - // Handle non-primary volumes (e.g., - // "content://com.android.externalstorage.documents/document/primary:...") - val storageDefinition = System.getenv("SECONDARY_STORAGE")?.split(":") - storageDefinition?.find { it.contains(type) }?.let { "$it/${split[1]}" } + null } } private fun handleDownloadsDocument(context: Context, uri: Uri): String? { val id = DocumentsContract.getDocumentId(uri) - return try { - val contentUri = - ContentUris.withAppendedId( - Uri.parse("content://downloads/public_downloads"), - java.lang.Long.valueOf(id) - ) - getDataColumn(context, contentUri, null, null) - } catch (e: NumberFormatException) { - // Handle the case where the id is not a pure number - null + return if (id.startsWith("raw:")) { + id.removePrefix("raw:") + } else { + try { + val contentUri = + ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), id.toLong()) + getDataColumn(context, contentUri, null, null) + } catch (e: NumberFormatException) { + null + } } } private fun handleMediaDocument(context: Context, uri: Uri): String? { val docId = DocumentsContract.getDocumentId(uri) - val split = docId.split(":").toTypedArray() + val split = docId.split(":") val type = split[0] + val id = split[1] + val contentUri: Uri? = when (type) { "image" -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI @@ -67,9 +64,8 @@ class UriPathFinder { "audio" -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI else -> null } - val selection = "_id=?" - val selectionArgs = arrayOf(split[1]) - return getDataColumn(context, contentUri, selection, selectionArgs) + + return contentUri?.let { getDataColumn(context, it, "_id=?", arrayOf(id)) } } private fun getDataColumn( @@ -78,13 +74,13 @@ class UriPathFinder { selection: String?, selectionArgs: Array? ): String? { - val cursor: Cursor? = + val cursor = uri?.let { context.contentResolver.query(it, arrayOf("_data"), selection, selectionArgs, null) } return cursor?.use { if (it.moveToFirst()) { - val columnIndex: Int = it.getColumnIndexOrThrow("_data") + val columnIndex = it.getColumnIndexOrThrow("_data") it.getString(columnIndex) } else { null @@ -92,15 +88,11 @@ class UriPathFinder { } } - private fun isExternalStorageDocument(uri: Uri): Boolean { - return "com.android.externalstorage.documents" == uri.authority - } + private fun isExternalStorageDocument(uri: Uri) = + "com.android.externalstorage.documents" == uri.authority - private fun isDownloadsDocument(uri: Uri): Boolean { - return "com.android.providers.downloads.documents" == uri.authority - } + private fun isDownloadsDocument(uri: Uri) = + "com.android.providers.downloads.documents" == uri.authority - private fun isMediaDocument(uri: Uri): Boolean { - return "com.android.providers.media.documents" == uri.authority - } + private fun isMediaDocument(uri: Uri) = "com.android.providers.media.documents" == uri.authority } diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/navigation/Navigation.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/navigation/Navigation.kt index ef4b14032..4c74dff91 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/navigation/Navigation.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/navigation/Navigation.kt @@ -1,18 +1,23 @@ package com.xef.xefMobile.ui.navigation import androidx.compose.runtime.Composable +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.server.movile.xef.android.ui.screens.LoginScreen import com.server.movile.xef.android.ui.screens.RegisterScreen import com.server.movile.xef.android.ui.screens.menu.AssistantScreen import com.server.movile.xef.android.ui.screens.menu.CreateAssistantScreen +import com.server.movile.xef.android.ui.screens.navigationdrawercompose.HomeScreen import com.server.movile.xef.android.ui.viewmodels.IAuthViewModel import com.xef.xefMobile.ui.screens.Screens +import com.xef.xefMobile.ui.screens.SettingsScreen +import com.xef.xefMobile.ui.viewmodels.SettingsViewModel @Composable -fun AppNavigator(authViewModel: IAuthViewModel) { +fun AppNavigator(authViewModel: IAuthViewModel, settingsViewModel: SettingsViewModel) { val navController = rememberNavController() NavHost(navController = navController, startDestination = Screens.Login.screen) { composable(Screens.Login.screen) { @@ -22,10 +27,37 @@ fun AppNavigator(authViewModel: IAuthViewModel) { RegisterScreen(authViewModel = authViewModel, navController = navController) } composable(Screens.Assistants.screen) { - AssistantScreen(navController = navController, authViewModel = authViewModel) + AssistantScreen( + navController = navController, + authViewModel = authViewModel, + settingsViewModel = settingsViewModel + ) + } + composable( + route = Screens.CreateAssistantWithArgs.screen, + arguments = listOf(navArgument("assistantId") { type = NavType.StringType }) + ) { backStackEntry -> + val assistantId = backStackEntry.arguments?.getString("assistantId") + CreateAssistantScreen( + navController = navController, + authViewModel = authViewModel, + settingsViewModel = settingsViewModel, + assistantId = assistantId + ) + } + composable(Screens.Settings.screen) { + SettingsScreen(navController = navController, settingsViewModel = settingsViewModel) + } + composable(Screens.Home.screen) { + HomeScreen(authViewModel = authViewModel, navController = navController) } composable(Screens.CreateAssistant.screen) { - CreateAssistantScreen(navController = navController) + CreateAssistantScreen( + navController = navController, + authViewModel = authViewModel, + settingsViewModel = settingsViewModel, + assistantId = null + ) } } } diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/LoginScreen.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/LoginScreen.kt index af9fd6fbb..583bce7a3 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/LoginScreen.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/LoginScreen.kt @@ -23,6 +23,7 @@ import com.xef.xefMobile.ui.screens.Screens @Composable fun LoginScreen(authViewModel: IAuthViewModel, navController: NavController) { val authToken by authViewModel.authToken.observeAsState() + val loginError by authViewModel.loginError.observeAsState() var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } @@ -52,6 +53,9 @@ fun LoginScreen(authViewModel: IAuthViewModel, navController: NavController) { if (errorMessage != null) { Text(text = errorMessage!!, color = Color.Red, textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(8.dp)) + } else if (loginError != null) { + Text(text = loginError!!, color = Color.Red, textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(8.dp)) } OutlinedTextField( @@ -75,12 +79,10 @@ fun LoginScreen(authViewModel: IAuthViewModel, navController: NavController) { onClick = { when { email.isBlank() -> { - errorMessage = "Email field is empty" - Log.d("LoginScreen", "Email field is empty") + errorMessage = "Please enter your email." } password.isBlank() -> { - errorMessage = "Password field is empty" - Log.d("LoginScreen", "Password field is empty") + errorMessage = "Please enter your password." } else -> { errorMessage = null diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/RegisterScreen.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/RegisterScreen.kt index d03b674c2..4eee9fd82 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/RegisterScreen.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/RegisterScreen.kt @@ -96,14 +96,7 @@ fun RegisterScreen(authViewModel: IAuthViewModel, navController: NavController) Button( onClick = { - errorMessage = - when { - name.isBlank() -> "Name is empty" - email.isBlank() -> "Email is empty" - password.isEmpty() -> "Password is empty" - password != rePassword -> "Passwords do not match" - else -> null - } + errorMessage = validateInputs(name, email, password, rePassword) if (errorMessage == null) { authViewModel.register(name, email, password) } @@ -122,3 +115,25 @@ fun RegisterScreen(authViewModel: IAuthViewModel, navController: NavController) } } } + +fun validateInputs(name: String, email: String, password: String, rePassword: String): String? { + if (name.isBlank()) return "Name is empty" + if (email.isBlank()) return "Email is empty" + if (!isValidEmail(email)) return "Email is not valid" + if (password.isEmpty()) return "Password is empty" + if (password != rePassword) return "Passwords do not match" + if (!isValidPassword(password)) return "Password does not meet criteria" + + return null +} + +fun isValidEmail(email: String): Boolean { + val emailPattern = "^[A-Za-z0-9+_.-]+@(.+)$" + return email.matches(emailPattern.toRegex()) +} + +fun isValidPassword(password: String): Boolean { + // Minimum 8 characters, at least one letter and one number + val passwordPattern = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}$" + return password.matches(passwordPattern.toRegex()) +} diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/Screens.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/Screens.kt index 351e12c1a..bc5e915fc 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/Screens.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/Screens.kt @@ -22,4 +22,8 @@ sealed class Screens(val screen: String) { object Settings : Screens("settingsScreen") object CreateAssistant : Screens("createAssistantScreen") + + object CreateAssistantWithArgs : Screens("createAssistantScreen/{assistantId}") { + fun createRoute(assistantId: String) = "createAssistantScreen/$assistantId" + } } diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/SettingsScreen.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/SettingsScreen.kt new file mode 100644 index 000000000..fea01f951 --- /dev/null +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/SettingsScreen.kt @@ -0,0 +1,90 @@ +package com.xef.xefMobile.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.xef.xefMobile.ui.viewmodels.SettingsViewModel +import kotlinx.coroutines.launch +import org.xef.xefMobile.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(navController: NavController, settingsViewModel: SettingsViewModel) { + val apiKey by settingsViewModel.apiKey.collectAsState() + var apiKeyInput by remember { mutableStateOf(apiKey) } + val coroutineScope = rememberCoroutineScope() + + val customColors = Color(0xFFADD8E6) + val CustomTextBlue = Color(0xFF0199D7) + + var showSnackbar by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(showSnackbar) { + if (showSnackbar) { + snackbarHostState.showSnackbar("API key saved successfully") + showSnackbar = false + navController.navigate(Screens.Home.screen) + } + } + + Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { innerPadding -> + Box( + modifier = Modifier.fillMaxSize().background(Color.White).padding(innerPadding).padding(16.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(text = "Settings", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.Black) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "These are xef-server settings.", fontSize = 16.sp, color = Color.Gray) + Spacer(modifier = Modifier.height(16.dp)) + TextField( + value = apiKeyInput, + onValueChange = { apiKeyInput = it }, + label = { Text("OpenAI API key") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + coroutineScope.launch { + settingsViewModel.setApiKey(apiKeyInput) + settingsViewModel.saveApiKey() + showSnackbar = true + } + }, + colors = ButtonDefaults.buttonColors(containerColor = customColors), + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.save_24dp), + contentDescription = "save settings icon", + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(CustomTextBlue) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "Save Settings", color = CustomTextBlue) + } + } + } + } + } +} diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/menu/AssistantScreen.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/menu/AssistantScreen.kt index 69352e4a8..fb642c87a 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/menu/AssistantScreen.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/menu/AssistantScreen.kt @@ -1,86 +1,129 @@ package com.server.movile.xef.android.ui.screens.menu +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import com.server.movile.xef.android.ui.themes.LocalCustomColors +import com.server.movile.xef.android.ui.viewmodels.AssistantViewModel import com.server.movile.xef.android.ui.viewmodels.IAuthViewModel -import com.xef.xefMobile.model.Assistant -import com.xef.xefMobile.services.ApiService -import com.xef.xefMobile.theme.theme.LocalCustomColors import com.xef.xefMobile.ui.screens.Screens -import kotlinx.coroutines.launch +import com.xef.xefMobile.ui.viewmodels.SettingsViewModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @Composable -fun AssistantScreen(navController: NavController, authViewModel: IAuthViewModel) { +fun AssistantScreen( + navController: NavController, + authViewModel: IAuthViewModel, + settingsViewModel: SettingsViewModel +) { val customColors = LocalCustomColors.current - val coroutineScope = rememberCoroutineScope() - var assistants by remember { mutableStateOf>(emptyList()) } - var loading by remember { mutableStateOf(true) } - var errorMessage by remember { mutableStateOf(null) } + val viewModel: AssistantViewModel = + viewModel(factory = AssistantViewModelFactory(authViewModel, settingsViewModel)) + val assistants by viewModel.assistants.collectAsState() + val loading by viewModel.loading.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() - val authToken = authViewModel.authToken.value ?: error("Auth token not found") - - LaunchedEffect(Unit) { - coroutineScope.launch { - try { - val response = ApiService().getAssistants(authToken) - assistants = response.data - } catch (e: Exception) { - errorMessage = "Failed to load assistants" - } finally { - loading = false - } - } - } + val sdf = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) } Box(modifier = Modifier.fillMaxSize()) { - if (loading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else if (errorMessage != null) { - Text( - text = errorMessage!!, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.align(Alignment.Center) - ) - } else { - Column( - modifier = Modifier.align(Alignment.TopCenter).padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + when { + loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + errorMessage != null -> { Text( - text = "Assistants", - fontWeight = FontWeight.Bold, - fontSize = 24.sp, + text = errorMessage ?: "", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.Center) ) - HorizontalDivider(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) - - Spacer(modifier = Modifier.height(16.dp)) + } + else -> { + Column( + modifier = Modifier.fillMaxSize().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally // Align title to center + ) { + Text( + text = "Assistants", + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + ) + HorizontalDivider(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) - assistants.forEach { assistant -> - Text(text = assistant.name, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(16.dp)) - Text(text = assistant.id) - Spacer(modifier = Modifier.height(8.dp)) + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(assistants) { assistant -> + Column( + modifier = + Modifier.fillMaxWidth().padding(vertical = 8.dp).clickable { + navController.navigate( + Screens.CreateAssistantWithArgs.createRoute(assistant.id) + ) + }, + horizontalAlignment = Alignment.Start // Align items to start + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = assistant.name.ifBlank { "Untitled assistant" }, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + Text( + text = sdf.format(Date(assistant.createdAt * 1000)), + fontSize = 14.sp, + color = Color.Gray, + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + Text(text = "ID: ${assistant.id}", fontSize = 14.sp) + } + HorizontalDivider(color = Color.Gray) + } + } } } + } - Button( - onClick = { navController.navigate(Screens.CreateAssistant.screen) }, - colors = - ButtonDefaults.buttonColors( - containerColor = customColors.buttonColor, - contentColor = MaterialTheme.colorScheme.onPrimary - ), - modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp) - ) { - Text(text = "Create New Assistant") - } + Button( + onClick = { navController.navigate(Screens.CreateAssistant.screen) }, + colors = + ButtonDefaults.buttonColors( + containerColor = customColors.buttonColor, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp) + ) { + Text(text = "Create New Assistant") + } + } +} + +class AssistantViewModelFactory( + private val authViewModel: IAuthViewModel, + private val settingsViewModel: SettingsViewModel +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(AssistantViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") return AssistantViewModel(authViewModel, settingsViewModel) as T } + throw IllegalArgumentException("Unknown ViewModel class") } } diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/menu/CreateAssistantScreen.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/menu/CreateAssistantScreen.kt index 5e2a74409..31abf0507 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/menu/CreateAssistantScreen.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/screens/menu/CreateAssistantScreen.kt @@ -1,37 +1,74 @@ package com.server.movile.xef.android.ui.screens.menu import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController +import com.server.movile.xef.android.ui.themes.CustomColors import com.server.movile.xef.android.ui.themes.LocalCustomColors +import com.server.movile.xef.android.ui.viewmodels.AssistantViewModel +import com.server.movile.xef.android.ui.viewmodels.AuthViewModel +import com.server.movile.xef.android.ui.viewmodels.IAuthViewModel +import com.server.movile.xef.android.ui.viewmodels.factory.AuthViewModelFactory +import com.xef.xefMobile.ui.composable.FilePickerDialog +import com.xef.xefMobile.ui.screens.Screens +import com.xef.xefMobile.ui.viewmodels.SettingsViewModel +import com.xef.xefMobile.ui.viewmodels.SettingsViewModelFactory +import kotlinx.coroutines.launch +import org.xef.xefMobile.R class CreateAssistantActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val assistantId = intent.getStringExtra("assistantId") setContent { - // Pass the NavController to CreateAssistantScreen val navController = rememberNavController() - CreateAssistantScreen(navController) + + val authViewModel: AuthViewModel = + viewModel(factory = AuthViewModelFactory(applicationContext)) + val settingsViewModel: SettingsViewModel = + viewModel(factory = SettingsViewModelFactory(applicationContext)) + + CreateAssistantScreen(navController, authViewModel, settingsViewModel, assistantId) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateAssistantScreen(navController: NavController) { +fun CreateAssistantScreen( + navController: NavController, + authViewModel: IAuthViewModel, + settingsViewModel: SettingsViewModel, + assistantId: String? +) { + val viewModel: AssistantViewModel = + viewModel(factory = AssistantViewModelFactory(authViewModel, settingsViewModel)) + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + val selectedAssistant by viewModel.selectedAssistant.collectAsState(initial = null) + var name by remember { mutableStateOf("") } var instructions by remember { mutableStateOf("") } var temperature by remember { mutableStateOf(1f) } @@ -39,85 +76,416 @@ fun CreateAssistantScreen(navController: NavController) { var fileSearchEnabled by remember { mutableStateOf(false) } var codeInterpreterEnabled by remember { mutableStateOf(false) } var model by remember { mutableStateOf("gpt-4-turbo") } - val list = listOf("gpt-4o", "gpt-4", "gpt-3.5-turbo-16K", "gpt-3.5-turbo-0125", "gpt-3.5-turbo") + val list = + listOf( + "gpt-4o", + "gpt-4o-2024-05-13", + "gpt-4", + "gpt-4-vision-preview", + "gpt-4-turbo-preview", + "gpt-4-2024-04-09", + "gpt-4-turbo", + "gpt-4-1106-preview", + "gpt-4-0613", + "gpt-4-0125-preview", + "gpt-4", + "gpt-3.5-turbo-16K", + "gpt-3.5-turbo-0125", + "gpt-3.5-turbo" + ) var isExpanded by remember { mutableStateOf(false) } var selectedText by remember { mutableStateOf(list[0]) } + var showFilePicker by remember { mutableStateOf(false) } + var showCodeInterpreterPicker by remember { mutableStateOf(false) } + var showAllItems by remember { mutableStateOf(false) } val customColors = LocalCustomColors.current - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier.padding(8.dp).fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(text = "Create Assistant", fontSize = 24.sp, modifier = Modifier.padding(bottom = 16.dp)) + LaunchedEffect(assistantId) { + if (assistantId != null) { + Log.d("CreateAssistantScreen", "Loading assistant details for id: $assistantId") + viewModel.loadAssistantDetails(assistantId) + } + } - TextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - TextField( - value = instructions, - onValueChange = { instructions = it }, - label = { Text("Instructions") }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, + LaunchedEffect(selectedAssistant) { + Log.d("CreateAssistantScreen", "Selected assistant changed: $selectedAssistant") + selectedAssistant?.let { assistant -> + name = assistant.name + instructions = assistant.instructions + temperature = assistant.temperature + topP = assistant.topP + model = assistant.model + selectedText = assistant.model + fileSearchEnabled = assistant.tools.any { it.type == "file_search" } + codeInterpreterEnabled = assistant.tools.any { it.type == "code_interpreter" } + } + } + + Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }, modifier = Modifier.fillMaxSize()) { + paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + LazyColumn( + modifier = Modifier.padding(8.dp).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally ) { - ExposedDropdownMenuBox( - expanded = isExpanded, - onExpandedChange = { isExpanded = !isExpanded }, - modifier = Modifier.fillMaxWidth() - ) { - TextField( - modifier = Modifier.fillMaxWidth().menuAnchor(), - value = selectedText, - onValueChange = {}, - readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) } + item { + Text( + text = if (assistantId == null) "Create Assistant" else "Update Assistant", + fontSize = 24.sp, + modifier = Modifier.padding(bottom = 16.dp) ) - ExposedDropdownMenu(expanded = isExpanded, onDismissRequest = { isExpanded = false }) { - list.forEachIndexed { index, text -> - DropdownMenuItem( - text = { Text(text = text) }, + } + + item { + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + item { Spacer(modifier = Modifier.height(12.dp)) } + + item { + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = instructions, + onValueChange = { instructions = it }, + label = { Text("Instructions") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + item { Spacer(modifier = Modifier.height(12.dp)) } + + item { + Box(modifier = Modifier.fillMaxWidth()) { + ExposedDropdownMenuBox( + expanded = isExpanded, + onExpandedChange = { isExpanded = !isExpanded }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().menuAnchor(), + value = selectedText, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) } + ) + ExposedDropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false } + ) { + val itemsToShow = if (showAllItems) list else list.take(5) + itemsToShow.forEachIndexed { index, text -> + DropdownMenuItem( + text = { Text(text = text) }, + onClick = { + selectedText = list[index] + model = list[index] + isExpanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } + if (!showAllItems) { + DropdownMenuItem( + text = { Text(text = "Show more", color = Color.Red) }, + onClick = { showAllItems = true }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } + } + } + } + } + + item { Spacer(modifier = Modifier.height(12.dp)) } + + item { + ToolsSection( + showFilePicker = showFilePicker, + onShowFilePickerChange = { showFilePicker = it }, + fileSearchEnabled = fileSearchEnabled, + onFileSearchEnabledChange = { fileSearchEnabled = it }, + showCodeInterpreterPicker = showCodeInterpreterPicker, + onShowCodeInterpreterPickerChange = { showCodeInterpreterPicker = it }, + codeInterpreterEnabled = codeInterpreterEnabled, + onCodeInterpreterEnabledChange = { codeInterpreterEnabled = it }, + customColors = customColors + ) + } + + item { Spacer(modifier = Modifier.height(12.dp)) } + + item { + Column { + Text(text = "MODEL CONFIGURATION") + HorizontalDivider(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) + } + } + + item { Spacer(modifier = Modifier.height(12.dp)) } + + item { + AssistantFloatField( + label = "Temperature", + value = temperature, + onValueChange = { temperature = it }, + valueRange = 0f..2f + ) + } + + item { + AssistantFloatField( + label = "Top P", + value = topP, + onValueChange = { topP = it }, + valueRange = 0f..1f + ) + } + + item { + Spacer(modifier = Modifier.height(12.dp)) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.weight(1f)) { + Button( + onClick = { navController.navigateUp() }, + colors = + ButtonDefaults.buttonColors( + containerColor = customColors.buttonColor, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( onClick = { - selectedText = list[index] - isExpanded = false + coroutineScope.launch { + if (assistantId == null) { + viewModel.createAssistant( + name = name, + instructions = instructions, + temperature = temperature, + topP = topP, + model = selectedText, + fileSearchEnabled = fileSearchEnabled, + codeInterpreterEnabled = codeInterpreterEnabled, + onSuccess = { + coroutineScope.launch { + snackbarHostState.showSnackbar("Assistant created successfully") + navController.navigate(Screens.Assistants.screen) + } + }, + onError = { errorMessage -> + Log.e("CreateAssistantScreen", errorMessage) + coroutineScope.launch { snackbarHostState.showSnackbar(errorMessage) } + } + ) + } else { + viewModel.updateAssistant( + id = assistantId, + name = name, + instructions = instructions, + temperature = temperature, + topP = topP, + model = selectedText, + fileSearchEnabled = fileSearchEnabled, + codeInterpreterEnabled = codeInterpreterEnabled, + onSuccess = { + coroutineScope.launch { + snackbarHostState.showSnackbar("Assistant updated successfully") + navController.navigate(Screens.Assistants.screen) + } + }, + onError = { errorMessage -> + Log.e("CreateAssistantScreen", errorMessage) + coroutineScope.launch { snackbarHostState.showSnackbar(errorMessage) } + } + ) + } + } }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding - ) + colors = + ButtonDefaults.buttonColors( + containerColor = customColors.buttonColor, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text(if (assistantId == null) "Create" else "Update") + } + } + if (assistantId != null) { + IconButton( + onClick = { + coroutineScope.launch { + viewModel.deleteAssistant( + assistantId, + onSuccess = { + coroutineScope.launch { + snackbarHostState.showSnackbar("Assistant deleted successfully") + navController.navigate(Screens.Assistants.screen) + } + }, + onError = { errorMessage -> + Log.e("CreateAssistantScreen", errorMessage) + coroutineScope.launch { snackbarHostState.showSnackbar(errorMessage) } + } + ) + } + }, + modifier = Modifier.size(48.dp).clip(CircleShape), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = Color.Gray, + contentColor = Color.White + ) + ) { + Icon( + painter = painterResource(id = R.drawable.delete_24dp), + contentDescription = "Delete Assistant", + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } } } } } - Spacer(modifier = Modifier.height(12.dp)) + } + + if (showFilePicker) { + FilePickerDialog( + onDismissRequest = { showFilePicker = false }, + customColors = customColors, + onFilesSelected = { showFilePicker = false }, + mimeTypeFilter = "*/*", + isForCodeInterpreter = false + ) + } + + if (showCodeInterpreterPicker) { + FilePickerDialog( + onDismissRequest = { showCodeInterpreterPicker = false }, + customColors = customColors, + onFilesSelected = { showCodeInterpreterPicker = false }, + mimeTypeFilter = "*/*", + isForCodeInterpreter = true + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AssistantFloatField( + label: String, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange +) { + val customColors = LocalCustomColors.current + Column(modifier = Modifier.fillMaxWidth()) { + Text(text = label, modifier = Modifier.padding(bottom = 2.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = 200, + modifier = Modifier.weight(3f), + colors = + SliderDefaults.colors( + thumbColor = customColors.sliderThumbColor, + activeTrackColor = customColors.sliderTrackColor + ) + ) + Spacer(modifier = Modifier.width(2.dp)) + TextField( + value = String.format("%.2f", value), + onValueChange = { + val newValue = it.toFloatOrNull() ?: 0f + onValueChange(newValue) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(60.dp).height(50.dp), + textStyle = LocalTextStyle.current.copy(fontSize = 12.sp) + ) + } + } +} + +@Composable +fun ExpandableContent( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + header: @Composable () -> Unit, + content: @Composable () -> Unit +) { + Column { + header() + if (expanded) { + content() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ToolsSection( + showFilePicker: Boolean, + onShowFilePickerChange: (Boolean) -> Unit, + fileSearchEnabled: Boolean, + onFileSearchEnabledChange: (Boolean) -> Unit, + showCodeInterpreterPicker: Boolean, + onShowCodeInterpreterPickerChange: (Boolean) -> Unit, + codeInterpreterEnabled: Boolean, + onCodeInterpreterEnabledChange: (Boolean) -> Unit, + customColors: CustomColors +) { + var expanded by remember { mutableStateOf(false) } + + ExpandableContent( + expanded = expanded, + onExpandedChange = { expanded = it }, + header = { Row(verticalAlignment = Alignment.CenterVertically) { Text(text = "TOOLS") - + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = + if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (expanded) "Collapse" else "Expand" + ) + } HorizontalDivider(modifier = Modifier.fillMaxWidth().padding(top = 10.dp)) } + } + ) { + Column { Spacer(modifier = Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedButton( - onClick = { /* handle cancel */}, + TextButton( + onClick = { onShowFilePickerChange(true) }, colors = ButtonDefaults.outlinedButtonColors( containerColor = Color.Transparent, contentColor = customColors.buttonColor ) ) { - Text("File Search") + Text("File Search +") } Spacer(modifier = Modifier.weight(1f)) Switch( checked = fileSearchEnabled, - onCheckedChange = { fileSearchEnabled = it }, + onCheckedChange = onFileSearchEnabledChange, colors = SwitchDefaults.colors( checkedThumbColor = customColors.sliderThumbColor, @@ -127,20 +495,20 @@ fun CreateAssistantScreen(navController: NavController) { } Spacer(modifier = Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedButton( - onClick = { /* handle cancel */}, + TextButton( + onClick = { onShowCodeInterpreterPickerChange(true) }, colors = ButtonDefaults.outlinedButtonColors( containerColor = Color.Transparent, contentColor = customColors.buttonColor ) ) { - Text("Code Interpreter") + Text("Code Interpreter +") } Spacer(modifier = Modifier.weight(1f)) Switch( checked = codeInterpreterEnabled, - onCheckedChange = { codeInterpreterEnabled = it }, + onCheckedChange = onCodeInterpreterEnabledChange, colors = SwitchDefaults.colors( checkedThumbColor = customColors.sliderThumbColor, @@ -149,104 +517,6 @@ fun CreateAssistantScreen(navController: NavController) { ) } Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedButton( - onClick = { /* handle cancel */}, - colors = - ButtonDefaults.outlinedButtonColors( - containerColor = Color.Transparent, - contentColor = customColors.buttonColor - ) - ) { - Text("Functions") - } - Spacer(modifier = Modifier.weight(1f)) - } - Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = "MODEL CONFIGURATION") - - HorizontalDivider(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) - } - Spacer(modifier = Modifier.width(8.dp)) - AssistantFloatField( - label = "Temperature", - value = temperature, - onValueChange = { temperature = it } - ) - - AssistantFloatField(label = "Top P", value = topP, onValueChange = { topP = it }) - - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { navController.navigateUp() }, - colors = - ButtonDefaults.buttonColors( - containerColor = customColors.buttonColor, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { - Text("Cancel") - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { /* handle create */}, - colors = - ButtonDefaults.buttonColors( - containerColor = customColors.buttonColor, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { - Text("Create") - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AssistantFloatField(label: String, value: Float, onValueChange: (Float) -> Unit) { - val customColors = LocalCustomColors.current - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = label, - modifier = Modifier.padding(bottom = 2.dp) // Reduce padding for the label - ) - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Slider( - value = value, - onValueChange = onValueChange, - valueRange = 0f..2f, - steps = 100, // This ensures the slider moves in increments of 0.02 - modifier = Modifier.weight(3f), - colors = - SliderDefaults.colors( - thumbColor = customColors.sliderThumbColor, - activeTrackColor = customColors.sliderTrackColor - ) - ) - Spacer( - modifier = Modifier.width(2.dp) - ) // Add a small spacer between the slider and text field - TextField( - value = String.format("%.2f", value), - onValueChange = { - val newValue = it.toFloatOrNull() ?: 0f - onValueChange(newValue) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(60.dp).height(50.dp), - textStyle = LocalTextStyle.current.copy(fontSize = 12.sp) // Optionally adjust text size - ) } } } - -@Preview(showBackground = false) -@Composable -fun CreateAssistantScreenPreview() { - // Create a mock NavController for the preview - val navController = rememberNavController() - CreateAssistantScreen(navController) -} diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/AssistantViewModel.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/AssistantViewModel.kt new file mode 100644 index 000000000..0f946a037 --- /dev/null +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/AssistantViewModel.kt @@ -0,0 +1,213 @@ +package com.server.movile.xef.android.ui.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xef.xefMobile.model.Assistant +import com.xef.xefMobile.services.ApiService +import com.xef.xefMobile.ui.viewmodels.SettingsViewModel +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.util.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +class AssistantViewModel( + private val authViewModel: IAuthViewModel, + private val settingsViewModel: SettingsViewModel +) : ViewModel() { + private val _assistants = MutableStateFlow>(emptyList()) + val assistants: StateFlow> = _assistants.asStateFlow() + + private val _loading = MutableStateFlow(true) + val loading: StateFlow = _loading.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _selectedAssistant = MutableStateFlow(null) + val selectedAssistant: StateFlow = _selectedAssistant.asStateFlow() + + private val apiService = ApiService() + + init { + fetchAssistants() + } + + fun fetchAssistants(onComplete: (() -> Unit)? = null) { + viewModelScope.launch { + _loading.value = true + _errorMessage.value = null + try { + val token = settingsViewModel.apiKey.value ?: throw Exception("API key not found") + val response = apiService.getAssistants(token) + _assistants.value = response.data + _loading.value = false + onComplete?.invoke() + } catch (e: Exception) { + _errorMessage.value = "Failed to load assistants: ${e.message}" + _loading.value = false + onComplete?.invoke() + } + } + } + + fun loadAssistantDetails(id: String) { + fetchAssistants { + viewModelScope.launch { + val assistant = getAssistantById(id) + if (assistant != null) { + _selectedAssistant.value = assistant + Log.d("AssistantViewModel", "Assistant details loaded: $assistant") + } else { + Log.d("AssistantViewModel", "Assistant not found with id: $id") + } + } + } + } + + private fun getAssistantById(id: String): Assistant? { + return assistants.value.find { it.id == id } + } + + @OptIn(InternalAPI::class) + fun createAssistant( + name: String, + instructions: String, + temperature: Float, + topP: Float, + model: String, + fileSearchEnabled: Boolean, + codeInterpreterEnabled: Boolean, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val token = settingsViewModel.apiKey.value ?: throw Exception("API key not found") + val tools = mutableListOf() + if (fileSearchEnabled) tools.add(Tool(type = "file_search")) + if (codeInterpreterEnabled) tools.add(Tool(type = "code_interpreter")) + + val request = + CreateAssistantRequest( + model = model, + name = name, + description = "This is an example assistant for testing purposes.", + instructions = instructions, + tools = tools, + metadata = mapOf("key1" to "value1", "key2" to "value2", "key3" to "value3"), + temperature = temperature, + top_p = topP + ) + + val response: HttpResponse = + apiService.createAssistant(authToken = token, request = request) + + if (response.status == HttpStatusCode.Created) { + fetchAssistants() + onSuccess() + } else { + onError("Failed to create assistant: ${response.status}") + } + } catch (e: Exception) { + onError("Error: ${e.message ?: "Unknown error"}") + } + } + } + + fun deleteAssistant(assistantId: String, onSuccess: () -> Unit, onError: (String) -> Unit) { + viewModelScope.launch { + try { + val token = settingsViewModel.apiKey.value ?: throw Exception("API key not found") + val response: HttpResponse = + apiService.deleteAssistant(authToken = token, assistantId = assistantId) + + if (response.status == HttpStatusCode.NoContent) { + fetchAssistants() + onSuccess() + } else { + onError("Failed to delete assistant: ${response.status}") + } + } catch (e: Exception) { + onError("Error: ${e.message ?: "Unknown error"}") + } + } + } + + @OptIn(InternalAPI::class) + fun updateAssistant( + id: String, + name: String, + instructions: String, + temperature: Float, + topP: Float, + model: String, + fileSearchEnabled: Boolean, + codeInterpreterEnabled: Boolean, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val token = settingsViewModel.apiKey.value ?: throw Exception("API key not found") + val tools = mutableListOf() + if (fileSearchEnabled) tools.add(Tool(type = "file_search")) + if (codeInterpreterEnabled) tools.add(Tool(type = "code_interpreter")) + + val request = + ModifyAssistantRequest( + model = model, + name = name, + description = "This is an example assistant for testing purposes.", + instructions = instructions, + tools = tools, + metadata = mapOf("key1" to "value1", "key2" to "value2", "key3" to "value3"), + temperature = temperature, + top_p = topP + ) + + val response: HttpResponse = + apiService.updateAssistant(authToken = token, id = id, request = request) + + if (response.status == HttpStatusCode.OK) { + fetchAssistants() + onSuccess() + } else { + onError("Failed to update assistant: ${response.status}") + } + } catch (e: Exception) { + onError("Error: ${e.message ?: "Unknown error"}") + } + } + } +} + +@Serializable +data class CreateAssistantRequest( + val model: String, + val name: String, + val description: String?, + val instructions: String, + val tools: List, + val metadata: Map, + val temperature: Float, + val top_p: Float +) + +@Serializable +data class ModifyAssistantRequest( + val model: String? = null, + val name: String? = null, + val description: String? = null, + val instructions: String? = null, + val tools: List? = null, + val metadata: Map? = null, + val temperature: Float? = null, + val top_p: Float? = null +) + +@Serializable data class Tool(val type: String) diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/AuthViewModel.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/AuthViewModel.kt index 1aed4dafc..af15147e9 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/AuthViewModel.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/AuthViewModel.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import retrofit2.HttpException -// Extension function to provide DataStore instance private val Context.dataStore by preferencesDataStore(name = "settings") class AuthViewModel(context: Context, private val apiService: ApiService) : @@ -39,6 +38,9 @@ class AuthViewModel(context: Context, private val apiService: ApiService) : private val _userName = MutableLiveData() override val userName: LiveData = _userName + private val _loginError = MutableLiveData() + override val loginError: LiveData = _loginError + init { loadAuthToken() } @@ -72,9 +74,10 @@ class AuthViewModel(context: Context, private val apiService: ApiService) : try { val loginResponse = apiService.loginUser(loginRequest) updateAuthToken(loginResponse.authToken) - updateUserName(loginResponse.user.name) // Extract user's name + updateUserName(loginResponse.user.name) _authToken.value = loginResponse.authToken _userName.value = loginResponse.user.name + _loginError.value = null } catch (e: Exception) { handleException(e) } finally { @@ -102,7 +105,7 @@ class AuthViewModel(context: Context, private val apiService: ApiService) : try { val registerResponse = apiService.registerUser(request) updateAuthToken(registerResponse.authToken) - updateUserName(name) // Directly use the name provided during registration + updateUserName(name) _authToken.value = registerResponse.authToken _userName.value = name } catch (e: Exception) { @@ -114,11 +117,19 @@ class AuthViewModel(context: Context, private val apiService: ApiService) : } private fun handleException(e: Exception) { - when (e) { - is IOException -> _errorMessage.postValue("Network error") - is HttpException -> _errorMessage.postValue("Unexpected server error: ${e.code()}") - else -> _errorMessage.postValue("An unexpected error occurred: ${e.message}") - } + val errorMessage = + when (e) { + is IOException -> "Network error" + is HttpException -> { + when (e.code()) { + 401 -> "Incorrect email or password" + 404 -> "Email not registered" + else -> "Unexpected server error" + } + } + else -> "An unexpected error occurred" + } + _loginError.postValue(errorMessage) } override fun logout() { @@ -127,11 +138,11 @@ class AuthViewModel(context: Context, private val apiService: ApiService) : withContext(Dispatchers.IO) { dataStore.edit { preferences -> preferences.remove(stringPreferencesKey("authToken")) - preferences.remove(stringPreferencesKey("userName")) // Add this line + preferences.remove(stringPreferencesKey("userName")) } } _authToken.postValue(null) - _userName.postValue(null) // Add this line + _userName.postValue(null) _errorMessage.postValue("Logged out successfully") } catch (e: Exception) { _errorMessage.postValue("Failed to sign out") diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/IAuthViewModel.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/IAuthViewModel.kt index 5cc84e2e5..86cb53267 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/IAuthViewModel.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/IAuthViewModel.kt @@ -7,6 +7,7 @@ interface IAuthViewModel { val isLoading: LiveData val errorMessage: LiveData val userName: LiveData + val loginError: LiveData // Add this line fun login(email: String, password: String) diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/PathViewModel.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/PathViewModel.kt index 53fc6d469..64f383b17 100644 --- a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/PathViewModel.kt +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/PathViewModel.kt @@ -13,25 +13,45 @@ import kotlinx.coroutines.launch class PathViewModel : ViewModel() { - var state by mutableStateOf(PathScreenState()) + var fileSearchState by mutableStateOf(PathScreenState()) + private set + + var codeInterpreterState by mutableStateOf(PathScreenState()) private set private val uriPathFinder = UriPathFinder() - fun onFilePathsListChange(list: List, context: Context) { + fun onFileSearchPathsChange(list: List, context: Context) { viewModelScope.launch { - val updatedList = state.filePaths.toMutableList() + val updatedList = fileSearchState.filePaths.toMutableList() val pathList = changeUriToPath(list, context) updatedList += pathList - state = state.copy(filePaths = updatedList) + fileSearchState = fileSearchState.copy(filePaths = updatedList) + } + } + + fun onCodeInterpreterPathsChange(list: List, context: Context) { + viewModelScope.launch { + val updatedList = codeInterpreterState.filePaths.toMutableList() + val pathList = changeUriToPath(list, context) + updatedList += pathList + codeInterpreterState = codeInterpreterState.copy(filePaths = updatedList) + } + } + + fun removeFileSearchPath(path: String) { + viewModelScope.launch { + val updatedList = fileSearchState.filePaths.toMutableList() + updatedList.remove(path) + fileSearchState = fileSearchState.copy(filePaths = updatedList) } } - fun removeFilePath(path: String) { + fun removeCodeInterpreterPath(path: String) { viewModelScope.launch { - val updatedList = state.filePaths.toMutableList() + val updatedList = codeInterpreterState.filePaths.toMutableList() updatedList.remove(path) - state = state.copy(filePaths = updatedList) + codeInterpreterState = codeInterpreterState.copy(filePaths = updatedList) } } diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/SettingsViewModel.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/SettingsViewModel.kt new file mode 100644 index 000000000..10dc0c8be --- /dev/null +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/SettingsViewModel.kt @@ -0,0 +1,24 @@ +package com.xef.xefMobile.ui.viewmodels + +import android.content.Context +import android.content.SharedPreferences +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class SettingsViewModel(context: Context) : ViewModel() { + private val preferences: SharedPreferences = + context.getSharedPreferences("settings", Context.MODE_PRIVATE) + private val _apiKey = MutableStateFlow(preferences.getString("api_key", "") ?: "") + val apiKey: StateFlow = _apiKey + + fun setApiKey(newApiKey: String) { + _apiKey.value = newApiKey + } + + fun saveApiKey() { + viewModelScope.launch { preferences.edit().putString("api_key", _apiKey.value).apply() } + } +} diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/factory/AuthViewModelFactory.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/factory/AuthViewModelFactory.kt new file mode 100644 index 000000000..ec206ffdb --- /dev/null +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/factory/AuthViewModelFactory.kt @@ -0,0 +1,16 @@ +package com.server.movile.xef.android.ui.viewmodels.factory + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.server.movile.xef.android.ui.viewmodels.AuthViewModel +import com.xef.xefMobile.services.ApiService + +class AuthViewModelFactory(private val context: Context) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(AuthViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") return AuthViewModel(context, apiService = ApiService()) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/factory/SettingsViewModelFactory.kt b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/factory/SettingsViewModelFactory.kt new file mode 100644 index 000000000..a6015417e --- /dev/null +++ b/server/xefMobile/composeApp/src/androidMain/kotlin/com/xef/xefMobile/ui/viewmodels/factory/SettingsViewModelFactory.kt @@ -0,0 +1,14 @@ +package com.xef.xefMobile.ui.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class SettingsViewModelFactory(private val context: Context) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") return SettingsViewModel(context) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/server/xefMobile/composeApp/src/main/res/drawable/save_24dp.xml b/server/xefMobile/composeApp/src/main/res/drawable/save_24dp.xml new file mode 100644 index 000000000..6fc751aef --- /dev/null +++ b/server/xefMobile/composeApp/src/main/res/drawable/save_24dp.xml @@ -0,0 +1,9 @@ + + +