diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef2466fd2d2b..8e3717619d62 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -347,6 +347,7 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.material3) + implementation(libs.compose.activity) implementation(libs.compose.ui.tooling.preview) implementation(libs.foundation) debugImplementation(libs.compose.ui.tooling) diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt index b51dd1d9f927..14a3a4b70c71 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -65,6 +66,8 @@ import com.nextcloud.client.assistant.repository.local.MockAssistantLocalReposit import com.nextcloud.client.assistant.repository.remote.MockAssistantRemoteRepository import com.nextcloud.client.assistant.task.TaskView import com.nextcloud.client.assistant.taskTypes.TaskTypesRow +import com.nextcloud.client.assistant.translate.TranslationScreen +import com.nextcloud.client.assistant.translate.TranslationViewModel import com.nextcloud.ui.composeActivity.ComposeActivity import com.nextcloud.ui.composeActivity.ComposeViewModel import com.nextcloud.ui.composeComponents.alertDialog.SimpleAlertDialog @@ -95,6 +98,7 @@ fun AssistantScreen( val messageId by viewModel.snackbarMessageId.collectAsState() val screenOverlayState by viewModel.screenOverlayState.collectAsState() val selectedTaskType by viewModel.selectedTaskType.collectAsState() + val isTranslationTask by viewModel.isTranslationTask.collectAsState() val filteredTaskList by viewModel.filteredTaskList.collectAsState() val screenState by viewModel.screenState.collectAsState() val taskTypes by viewModel.taskTypes.collectAsState() @@ -160,6 +164,7 @@ fun AssistantScreen( } }) } + AssistantPage.Content.id -> { Scaffold( modifier = Modifier.pullToRefresh( @@ -190,7 +195,7 @@ fun AssistantScreen( } }, bottomBar = { - if (!taskTypes.isNullOrEmpty()) { + if (!taskTypes.isNullOrEmpty() && selectedTaskType?.isTranslate() != true) { InputBar( sessionId, selectedTaskType, @@ -200,6 +205,24 @@ fun AssistantScreen( }, snackbarHost = { SnackbarHost(snackbarHostState) + }, + floatingActionButton = { + if (selectedTaskType?.isTranslate() == true && !isTranslationTask) { + FloatingActionButton(onClick = { + viewModel.updateTranslationTaskState(true) + viewModel.updateScreenState(AssistantScreenState.Translation(null)) + }, content = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_plus), + contentDescription = "translate button" + ) + } + }) + } } ) { paddingValues -> when (screenState) { @@ -229,6 +252,23 @@ fun AssistantScreen( ) } + is AssistantScreenState.Translation -> { + selectedTaskType?.let { + val task = (screenState as AssistantScreenState.Translation).task + val textToTranslate = task?.input?.input ?: selectedText ?: "" + + val translationViewModel = + TranslationViewModel(remoteRepository = viewModel.getRemoteRepository()) + + translationViewModel.init(it, task, textToTranslate) + + TranslationScreen( + viewModel = translationViewModel, + assistantViewModel = viewModel + ) + } + } + else -> EmptyContent( paddingValues, iconId = R.drawable.spinner_inner, @@ -371,6 +411,7 @@ private fun TaskContent( items(taskList, key = { it.id }) { task -> TaskView( task, + viewModel, capability, showTaskActions = { val newState = ScreenOverlayState.TaskActions(task) @@ -468,7 +509,7 @@ private fun getMockConversationViewModel(): ConversationViewModel { ) } -private fun getMockAssistantViewModel(giveEmptyTasks: Boolean): AssistantViewModel { +fun getMockAssistantViewModel(giveEmptyTasks: Boolean): AssistantViewModel { val mockLocalRepository = MockAssistantLocalRepository() val mockRemoteRepository = MockAssistantRemoteRepository(giveEmptyTasks) return AssistantViewModel( diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt index 820bbafb63e7..def6bf60882b 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt @@ -44,7 +44,7 @@ class AssistantViewModel( private const val POLLING_INTERVAL_MS = 15_000L } - private val _inputBarText = MutableStateFlow("") + private val _inputBarText = MutableStateFlow("") val inputBarText: StateFlow = _inputBarText private val _screenState = MutableStateFlow(null) @@ -59,6 +59,11 @@ class AssistantViewModel( private val _snackbarMessageId = MutableStateFlow(null) val snackbarMessageId: StateFlow = _snackbarMessageId + private val _isTranslationTask = MutableStateFlow(false) + val isTranslationTask: StateFlow = _isTranslationTask + + private val selectedTask = MutableStateFlow(null) + private val _selectedTaskType = MutableStateFlow(null) val selectedTaskType: StateFlow = _selectedTaskType @@ -123,12 +128,13 @@ class AssistantViewModel( // endregion private suspend fun pollTaskList() { - val cachedTasks = localRepository.getCachedTasks(accountName) + val taskType = _selectedTaskType.value?.id ?: return + + val cachedTasks = localRepository.getCachedTasks(accountName, taskType) if (cachedTasks.isNotEmpty()) { _filteredTaskList.value = cachedTasks.sortedByDescending { it.id } } - val taskType = _selectedTaskType.value?.id ?: return val result = remoteRepository.getTaskList(taskType) if (result != null) { taskList = result @@ -163,18 +169,28 @@ class AssistantViewModel( private fun observeScreenState() { viewModelScope.launch { combine( + selectedTask, _selectedTaskType, _chatMessages, _filteredTaskList - ) { selectedTask, chats, tasks -> - val isChat = selectedTask?.isChat() == true + ) { selectedTask, selectedTaskType, chats, tasks -> + val isChat = selectedTaskType?.isChat() == true + val isTranslation = + selectedTaskType?.isTranslate() == true && selectedTask?.isTranslate() == true when { - selectedTask == null -> AssistantScreenState.Loading + selectedTaskType == null -> AssistantScreenState.Loading + isTranslation -> AssistantScreenState.Translation(selectedTask) isChat && chats.isEmpty() -> AssistantScreenState.emptyChatList() isChat -> AssistantScreenState.ChatContent !isChat && (tasks == null || tasks.isEmpty()) -> AssistantScreenState.emptyTaskList() - else -> AssistantScreenState.TaskContent + else -> { + if (!_isTranslationTask.value) { + AssistantScreenState.TaskContent + } else { + _screenState.value + } + } } }.collect { newState -> _screenState.value = newState @@ -240,17 +256,27 @@ class AssistantViewModel( fun selectTaskType(task: TaskTypeData) { Log_OC.d(TAG, "Task type changed: ${task.name}, session id: ${_sessionId.value}") + + // clear task list immediately when task type change + if (_selectedTaskType.value != task) { + _filteredTaskList.update { + listOf() + } + } + updateTaskType(task) + if (!task.isChat()) { + fetchTaskList() + return + } + + // only task chat type needs to be handled differently val sessionId = _sessionId.value ?: return - if (task.isChat()) { - if (_chatMessages.value.isEmpty()) { - fetchChatMessages(sessionId) - } else { - fetchNewChatMessage(sessionId) - } + if (_chatMessages.value.isEmpty()) { + fetchChatMessages(sessionId) } else { - fetchTaskList() + fetchNewChatMessage(sessionId) } } @@ -268,12 +294,16 @@ class AssistantViewModel( } fun fetchTaskList() = viewModelScope.launch(Dispatchers.IO) { - val cached = localRepository.getCachedTasks(accountName) + val taskType = _selectedTaskType.value ?: return@launch + + val cached = localRepository.getCachedTasks(accountName, taskType.name) if (cached.isNotEmpty()) { - _filteredTaskList.value = cached.sortedByDescending { it.id } + _filteredTaskList.update { + cached.sortedByDescending { it.id } + } } - _selectedTaskType.value?.id?.let { typeId -> + taskType.id?.let { typeId -> remoteRepository.getTaskList(typeId)?.let { result -> taskList = result _filteredTaskList.value = result.sortedByDescending { it.id } @@ -292,9 +322,11 @@ class AssistantViewModel( } updateSnackbarMessage(message) + + val taskType = _selectedTaskType.value ?: return@launch if (result.isSuccess) { removeTaskFromList(id) - localRepository.deleteTask(id, accountName) + localRepository.deleteTask(id, accountName, taskType.name) } } // endregion @@ -305,6 +337,12 @@ class AssistantViewModel( } } + fun selectTask(task: Task?) { + selectedTask.update { + task + } + } + fun updateSnackbarMessage(value: Int?) { _snackbarMessageId.update { value @@ -323,6 +361,25 @@ class AssistantViewModel( } } + fun updateScreenState(state: AssistantScreenState) { + _screenState.update { + state + } + } + + fun updateTranslationTaskState(value: Boolean) { + _isTranslationTask.update { + value + } + } + + fun onTranslationScreenDismissed() { + updateTranslationTaskState(false) + selectTask(null) + } + + fun getRemoteRepository(): AssistantRemoteRepository = remoteRepository + private fun removeTaskFromList(id: Long) { _filteredTaskList.update { currentList -> currentList?.filter { it.id != id } diff --git a/app/src/main/java/com/nextcloud/client/assistant/model/AssistantScreenState.kt b/app/src/main/java/com/nextcloud/client/assistant/model/AssistantScreenState.kt index 80d2f1a29fb1..8f0ad7bfd011 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/model/AssistantScreenState.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/model/AssistantScreenState.kt @@ -8,6 +8,7 @@ package com.nextcloud.client.assistant.model import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.Task sealed class AssistantScreenState { data object Loading : AssistantScreenState() @@ -16,6 +17,8 @@ sealed class AssistantScreenState { data object ChatContent : AssistantScreenState() + data class Translation(val task: Task?) : AssistantScreenState() + data class EmptyContent(val iconId: Int?, val titleId: Int?, val descriptionId: Int?) : AssistantScreenState() companion object { diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepository.kt index 070c0c74a9c1..76568959583e 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepository.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepository.kt @@ -11,7 +11,7 @@ import com.owncloud.android.lib.resources.assistant.v2.model.Task interface AssistantLocalRepository { suspend fun cacheTasks(tasks: List, accountName: String) - suspend fun getCachedTasks(accountName: String): List + suspend fun getCachedTasks(accountName: String, type: String): List suspend fun insertTask(task: Task, accountName: String) - suspend fun deleteTask(id: Long, accountName: String) + suspend fun deleteTask(id: Long, accountName: String, type: String) } diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepositoryImpl.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepositoryImpl.kt index ef6ba9365606..70fb3398cee9 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/local/AssistantLocalRepositoryImpl.kt @@ -20,8 +20,8 @@ class AssistantLocalRepositoryImpl(private val assistantDao: AssistantDao) : Ass assistantDao.insertAssistantTasks(entities) } - override suspend fun getCachedTasks(accountName: String): List { - val entities = assistantDao.getAssistantTasksByAccount(accountName) + override suspend fun getCachedTasks(accountName: String, type: String): List { + val entities = assistantDao.getAssistantTasksByAccount(accountName, type) return entities.map { it.toTask() } } @@ -29,8 +29,8 @@ class AssistantLocalRepositoryImpl(private val assistantDao: AssistantDao) : Ass assistantDao.insertAssistantTask(task.toEntity(accountName)) } - override suspend fun deleteTask(id: Long, accountName: String) { - val cached = assistantDao.getAssistantTasksByAccount(accountName).firstOrNull { it.id == id } ?: return + override suspend fun deleteTask(id: Long, accountName: String, type: String) { + val cached = assistantDao.getAssistantTasksByAccount(accountName, type).firstOrNull { it.id == id } ?: return assistantDao.deleteAssistantTask(cached) } diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/local/MockAssistantLocalRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/local/MockAssistantLocalRepository.kt index c09065a5b867..231534a674a6 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/repository/local/MockAssistantLocalRepository.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/local/MockAssistantLocalRepository.kt @@ -23,13 +23,14 @@ class MockAssistantLocalRepository : AssistantLocalRepository { } } - override suspend fun getCachedTasks(accountName: String): List = mutex.withLock { tasks.toList() } + override suspend fun getCachedTasks(accountName: String, type: String): List = + mutex.withLock { tasks.toList() } override suspend fun insertTask(task: Task, accountName: String) { mutex.withLock { tasks.add(task) } } - override suspend fun deleteTask(id: Long, accountName: String) { + override suspend fun deleteTask(id: Long, accountName: String, type: String) { mutex.withLock { tasks.removeAll { it.id == id } } } } diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepository.kt index 3eb48968bb78..8b1f2397d421 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepository.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepository.kt @@ -15,6 +15,7 @@ import com.owncloud.android.lib.resources.assistant.chat.model.Session import com.owncloud.android.lib.resources.assistant.chat.model.SessionTask import com.owncloud.android.lib.resources.assistant.v2.model.Task import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationRequest interface AssistantRemoteRepository { suspend fun getTaskTypes(): List? @@ -36,4 +37,6 @@ interface AssistantRemoteRepository { suspend fun generateSession(sessionId: String): SessionTask? suspend fun checkGeneration(taskId: String, sessionId: String): ChatMessage? + + suspend fun translate(input: TranslationRequest, taskType: TaskTypeData): RemoteOperationResult } diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepositoryImpl.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepositoryImpl.kt index 3030ecbe779d..923f689bd0e9 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/AssistantRemoteRepositoryImpl.kt @@ -27,11 +27,13 @@ import com.owncloud.android.lib.resources.assistant.v1.GetTaskListRemoteOperatio import com.owncloud.android.lib.resources.assistant.v1.GetTaskTypesRemoteOperationV1 import com.owncloud.android.lib.resources.assistant.v1.model.toV2 import com.owncloud.android.lib.resources.assistant.v2.CreateTaskRemoteOperationV2 +import com.owncloud.android.lib.resources.assistant.v2.CreateTranslationTaskRemoteOperation import com.owncloud.android.lib.resources.assistant.v2.DeleteTaskRemoteOperationV2 import com.owncloud.android.lib.resources.assistant.v2.GetTaskListRemoteOperationV2 import com.owncloud.android.lib.resources.assistant.v2.GetTaskTypesRemoteOperationV2 import com.owncloud.android.lib.resources.assistant.v2.model.Task import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationRequest import com.owncloud.android.lib.resources.status.NextcloudVersion import com.owncloud.android.lib.resources.status.OCCapability import kotlinx.coroutines.Dispatchers @@ -124,4 +126,9 @@ class AssistantRemoteRepositoryImpl(private val client: NextcloudClient, capabil val result = CheckGenerationRemoteOperation(taskId, sessionId).execute(client) if (result.isSuccess) result.resultData else null } + + override suspend fun translate(input: TranslationRequest, taskType: TaskTypeData): RemoteOperationResult = + withContext(Dispatchers.IO) { + CreateTranslationTaskRemoteOperation(input, taskType).execute(client) + } } diff --git a/app/src/main/java/com/nextcloud/client/assistant/repository/remote/MockAssistantRemoteRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/MockAssistantRemoteRepository.kt index 930489812e85..e11ec5d1a873 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/repository/remote/MockAssistantRemoteRepository.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/repository/remote/MockAssistantRemoteRepository.kt @@ -18,6 +18,7 @@ import com.owncloud.android.lib.resources.assistant.v2.model.Task import com.owncloud.android.lib.resources.assistant.v2.model.TaskInput import com.owncloud.android.lib.resources.assistant.v2.model.TaskOutput import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationRequest @Suppress("MagicNumber") class MockAssistantRemoteRepository(private val giveEmptyTasks: Boolean = false) : AssistantRemoteRepository { @@ -68,7 +69,7 @@ class MockAssistantRemoteRepository(private val giveEmptyTasks: Boolean = false) } override suspend fun deleteTask(id: Long): RemoteOperationResult = - RemoteOperationResult(RemoteOperationResult.ResultCode.OK) + RemoteOperationResult(RemoteOperationResult.ResultCode.OK) override suspend fun fetchChatMessages(id: Long): List = emptyList() override suspend fun sendChatMessage(request: ChatMessageRequest): ChatMessage? = null override suspend fun createConversation(title: String): CreateConversation? = null @@ -76,4 +77,6 @@ class MockAssistantRemoteRepository(private val giveEmptyTasks: Boolean = false) override suspend fun generateSession(sessionId: String): SessionTask? = null override suspend fun checkGeneration(taskId: String, sessionId: String): ChatMessage? = null + override suspend fun translate(input: TranslationRequest, taskType: TaskTypeData): RemoteOperationResult = + RemoteOperationResult(RemoteOperationResult.ResultCode.OK) } diff --git a/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt b/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt index 7cefa122fb9c..508839ef10c0 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/task/TaskView.kt @@ -39,6 +39,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.nextcloud.client.assistant.AssistantViewModel +import com.nextcloud.client.assistant.getMockAssistantViewModel +import com.nextcloud.client.assistant.model.AssistantScreenState import com.nextcloud.client.assistant.taskDetail.TaskDetailBottomSheet import com.nextcloud.utils.extensions.truncateWithEllipsis import com.owncloud.android.R @@ -49,7 +52,7 @@ import com.owncloud.android.lib.resources.status.OCCapability @Suppress("LongMethod", "MagicNumber") @Composable -fun TaskView(task: Task, capability: OCCapability, showTaskActions: () -> Unit) { +fun TaskView(task: Task, viewModel: AssistantViewModel, capability: OCCapability, showTaskActions: () -> Unit) { var showTaskDetailBottomSheet by remember { mutableStateOf(false) } Box { @@ -59,7 +62,14 @@ fun TaskView(task: Task, capability: OCCapability, showTaskActions: () -> Unit) .clip(RoundedCornerShape(8.dp)) .background(color = colorResource(R.color.task_container)) .clickable { - showTaskDetailBottomSheet = true + viewModel.selectTask(task) + + if (task.isTranslate()) { + viewModel.updateTranslationTaskState(true) + viewModel.updateScreenState(AssistantScreenState.Translation(task)) + } else { + showTaskDetailBottomSheet = true + } } .padding(16.dp) ) { @@ -102,6 +112,8 @@ fun TaskView(task: Task, capability: OCCapability, showTaskActions: () -> Unit) showTaskDetailBottomSheet = false showTaskActions() }) { + // task is unselected + viewModel.selectTask(null) showTaskDetailBottomSheet = false } } @@ -142,6 +154,7 @@ private fun TaskViewPreview() { 1707692337, 1707692337 ), + viewModel = getMockAssistantViewModel(true), OCCapability().apply { versionMayor = 30 }, diff --git a/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreen.kt new file mode 100644 index 000000000000..9da714a95eec --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreen.kt @@ -0,0 +1,264 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.translate + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.nextcloud.client.assistant.AssistantViewModel +import com.nextcloud.client.assistant.model.AssistantScreenState +import com.nextcloud.utils.extensions.getActivity +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationLanguage +import com.owncloud.android.lib.resources.assistant.v2.model.toTranslationLanguages +import com.owncloud.android.utils.ClipboardUtil + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TranslationScreen(viewModel: TranslationViewModel, assistantViewModel: AssistantViewModel) { + val context = LocalContext.current + val state by viewModel.screenState.collectAsState() + val messageId by viewModel.snackbarMessageId.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + BackHandler { + assistantViewModel.onTranslationScreenDismissed() + assistantViewModel.updateScreenState(AssistantScreenState.TaskContent) + } + + LaunchedEffect(messageId) { + messageId?.let { + snackbarHostState.showSnackbar(context.getString(it)) + viewModel.updateSnackbarMessage(null) + } + } + + // task is unselected + DisposableEffect(Unit) { + onDispose { + assistantViewModel.onTranslationScreenDismissed() + } + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .padding(top = 32.dp), + floatingActionButton = { + if (state.fabVisibility) { + FloatingActionButton(onClick = { + viewModel.translate() + }, content = { + Icon(painter = painterResource(R.drawable.ic_translate), contentDescription = "translate button") + }) + } + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + } + ) { paddingValues -> + LazyColumn(modifier = Modifier.padding(paddingValues)) { + item { + TranslationSection( + labelId = R.string.translation_screen_label_from, + hintId = R.string.translation_screen_hint_source, + state = state.source, + availableLanguages = state.taskTypeData.toTranslationLanguages().originLanguages, + maxDp = 120.dp, + shimmer = false, + onStateChange = { + viewModel.updateSourceState(it) + } + ) + } + + item { + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + item { + TranslationSection( + labelId = R.string.translation_screen_label_to, + hintId = state.targetHintMessageId, + state = state.target, + availableLanguages = state.taskTypeData.toTranslationLanguages().targetLanguages, + maxDp = Dp.Unspecified, + shimmer = state.shimmer, + onStateChange = { + viewModel.updateTargetState(it) + } + ) + } + } + } +} + +@Suppress("LongMethod", "LongParameterList") +@Composable +private fun TranslationSection( + labelId: Int, + hintId: Int?, + state: TranslationSideState, + availableLanguages: List, + maxDp: Dp, + shimmer: Boolean, + onStateChange: (TranslationSideState) -> Unit +) { + val activity = LocalContext.current.getActivity() + + Row( + modifier = Modifier + .padding(16.dp) + .clickable { onStateChange(state.copy(isExpanded = !state.isExpanded)) }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(labelId), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = state.language?.name ?: "", + style = MaterialTheme.typography.labelLarge + ) + Icon( + painter = painterResource(R.drawable.ic_baseline_arrow_drop_down_24), + contentDescription = "dropdown icon", + modifier = Modifier + .padding(start = 4.dp) + .size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + DropdownMenu( + expanded = state.isExpanded, + onDismissRequest = { onStateChange(state.copy(isExpanded = false)) } + ) { + availableLanguages.forEach { language -> + DropdownMenuItem( + text = { Text(language.name) }, + onClick = { + onStateChange(state.copy(language = language, isExpanded = false)) + } + ) + } + } + + if (state.isTarget && state.text.isNotBlank()) { + Spacer(modifier = Modifier.weight(1f)) + + IconButton(onClick = { + activity?.let { ClipboardUtil.copyToClipboard(it, state.text, true) } + }) { + Icon(painter = painterResource(R.drawable.ic_content_copy), contentDescription = "copy button") + } + } + } + + if (state.isTarget && shimmer) { + TranslatingShimmer( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp) + .padding(horizontal = 16.dp) + ) + } else { + TextField( + value = state.text, + onValueChange = { onStateChange(state.copy(text = it)) }, + readOnly = state.isTarget, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = maxDp), + placeholder = { + hintId?.let { Text(text = stringResource(it), style = MaterialTheme.typography.headlineSmall) } + }, + textStyle = MaterialTheme.typography.headlineSmall, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun TranslatingShimmer(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "shimmer") + val alpha by transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(900), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + Column(modifier = modifier) { + Text( + text = stringResource(R.string.translation_screen_translating), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + modifier = Modifier.padding(vertical = 4.dp) + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreenState.kt b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreenState.kt new file mode 100644 index 000000000000..fe4e8976610a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationScreenState.kt @@ -0,0 +1,194 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.translate + +import com.owncloud.android.R +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.toTranslationLanguages + +@Suppress("LongParameterList") +sealed class TranslationScreenState( + open val taskTypeData: TaskTypeData, + open val source: TranslationSideState, + open val target: TranslationSideState, + open val fabVisibility: Boolean, + open val shimmer: Boolean, + open val targetHintMessageId: Int? +) + +data object Uninitialized : TranslationScreenState( + taskTypeData = TaskTypeData(null, "", null, mapOf(), mapOf()), + source = TranslationSideState( + text = "", + language = null, + isTarget = false + ), + target = TranslationSideState( + text = "", + language = null, + isTarget = true + ), + fabVisibility = false, + shimmer = false, + targetHintMessageId = null +) + +data class NewTranslation( + override val taskTypeData: TaskTypeData, + override val source: TranslationSideState, + override val target: TranslationSideState, + override val shimmer: Boolean = false +) : TranslationScreenState( + taskTypeData = taskTypeData, + source = source, + target = target, + fabVisibility = true, + shimmer = shimmer, + targetHintMessageId = R.string.translation_screen_start_to_translate_task +) { + companion object { + fun create(taskTypeData: TaskTypeData, textToTranslate: String): NewTranslation = NewTranslation( + taskTypeData = taskTypeData, + source = TranslationSideState( + text = textToTranslate, + language = taskTypeData.toTranslationLanguages().originLanguages.firstOrNull(), + isTarget = false + ), + target = TranslationSideState( + text = "", + language = taskTypeData.toTranslationLanguages().targetLanguages.firstOrNull(), + isTarget = true + ) + ) + } +} + +data class ExistingTranslation( + override val taskTypeData: TaskTypeData, + override val source: TranslationSideState, + override val target: TranslationSideState, + override val shimmer: Boolean = false +) : TranslationScreenState( + taskTypeData = taskTypeData, + source = source, + target = target, + fabVisibility = false, + shimmer = shimmer, + targetHintMessageId = null +) { + companion object { + fun create(taskTypeData: TaskTypeData, textToTranslate: String, translatedText: String): ExistingTranslation = + ExistingTranslation( + taskTypeData = taskTypeData, + source = TranslationSideState( + text = textToTranslate, + language = taskTypeData.toTranslationLanguages().originLanguages.firstOrNull(), + isTarget = false + ), + target = TranslationSideState( + text = translatedText, + language = taskTypeData.toTranslationLanguages().targetLanguages.firstOrNull(), + isTarget = true + ) + ) + } +} + +data class EditedTranslation( + override val taskTypeData: TaskTypeData, + override val source: TranslationSideState, + override val target: TranslationSideState, + override val shimmer: Boolean = false +) : TranslationScreenState( + taskTypeData = taskTypeData, + source = source, + target = target, + fabVisibility = true, + shimmer = shimmer, + targetHintMessageId = null +) { + companion object { + fun create(taskTypeData: TaskTypeData, textToTranslate: String, translatedText: String): EditedTranslation = + EditedTranslation( + taskTypeData = taskTypeData, + source = TranslationSideState( + text = textToTranslate, + language = taskTypeData.toTranslationLanguages().originLanguages.firstOrNull(), + isTarget = false + ), + target = TranslationSideState( + text = translatedText, + language = taskTypeData.toTranslationLanguages().targetLanguages.firstOrNull(), + isTarget = true + ) + ) + } +} + +fun TranslationScreenState.withShimmer(shimmer: Boolean): TranslationScreenState = when (this) { + is NewTranslation -> copy(shimmer = shimmer) + is ExistingTranslation -> copy(shimmer = shimmer) + is EditedTranslation -> copy(shimmer = shimmer) + Uninitialized -> { + Uninitialized + } +} + +fun TranslationScreenState.withTargetText(text: String): TranslationScreenState = when (this) { + is NewTranslation -> EditedTranslation( + taskTypeData = taskTypeData, + source = source, + target = target.copy(text = text), + shimmer = shimmer + ) + + is ExistingTranslation -> copy( + target = target.copy(text = text) + ) + + is EditedTranslation -> copy( + target = target.copy(text = text) + ) + + Uninitialized -> { + Uninitialized + } +} + +fun TranslationScreenState.withSource(newSource: TranslationSideState): TranslationScreenState = when (this) { + is NewTranslation -> copy(source = newSource) + is ExistingTranslation -> EditedTranslation( + taskTypeData = taskTypeData, + source = newSource, + target = target, + shimmer = shimmer + ) + + is EditedTranslation -> copy(source = newSource) + Uninitialized -> { + Uninitialized + } +} + +fun TranslationScreenState.withTarget(newTarget: TranslationSideState): TranslationScreenState = when (this) { + is NewTranslation -> { + copy(target = newTarget) + } + + is ExistingTranslation -> EditedTranslation( + taskTypeData = taskTypeData, + source = source, + target = newTarget, + shimmer = shimmer + ) + + is EditedTranslation -> copy(target = newTarget) + Uninitialized -> { + Uninitialized + } +} diff --git a/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationSideState.kt b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationSideState.kt new file mode 100644 index 000000000000..b39e8dab7378 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationSideState.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.translate + +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationLanguage + +data class TranslationSideState( + val text: String = "", + val language: TranslationLanguage? = null, + val isExpanded: Boolean = false, + val isTarget: Boolean +) diff --git a/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationViewModel.kt new file mode 100644 index 000000000000..df78ef73a35f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/translate/TranslationViewModel.kt @@ -0,0 +1,133 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.assistant.translate + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.assistant.v2.model.Task +import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData +import com.owncloud.android.lib.resources.assistant.v2.model.TranslationRequest +import com.owncloud.android.lib.resources.assistant.v2.model.toTranslationModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class TranslationViewModel(private val remoteRepository: AssistantRemoteRepository) : ViewModel() { + + companion object { + private const val TAG = "TranslationViewModel" + private const val POLLING_INTERVAL_MS = 15_000L + private const val MAX_RETRY = 3 + } + + private var _screenState = + MutableStateFlow(Uninitialized) + val screenState: StateFlow + get() = _screenState + + private val _snackbarMessageId = MutableStateFlow(null) + val snackbarMessageId: StateFlow = _snackbarMessageId + + private lateinit var taskTypeData: TaskTypeData + private var task: Task? = null + private var textToTranslate = "" + private var translatedText = "" + + fun init(taskTypeData: TaskTypeData, task: Task?, textToTranslate: String) { + this.task = task + this.textToTranslate = textToTranslate + this.taskTypeData = taskTypeData + + _screenState = if (task == null) { + MutableStateFlow(NewTranslation.create(taskTypeData, textToTranslate)) + } else { + val translatedText = task.output?.output ?: "" + this.translatedText = translatedText + MutableStateFlow(ExistingTranslation.create(taskTypeData, textToTranslate, translatedText)) + } + } + + fun translate() { + viewModelScope.launch(Dispatchers.IO) { + val stateValue = _screenState.value + val textToTranslate = stateValue.source.text + val originLanguage = stateValue.source.language ?: return@launch + val targetLanguage = stateValue.target.language ?: return@launch + + val model = taskTypeData.toTranslationModel() + + val input = TranslationRequest( + input = textToTranslate, + originLanguage = originLanguage.code, + targetLanguage = targetLanguage.code, + maxTokens = model?.maxTokens ?: 0.0, + model = model?.model ?: "" + ) + + val result = remoteRepository.translate(input, taskTypeData) + if (result.isSuccess) { + _screenState.update { it.withShimmer(true) } + pollTranslationResult() + _screenState.update { it.withShimmer(false) } + } + } + } + + @Suppress("ReturnCount") + private suspend fun pollTranslationResult() { + val screenStateValue = _screenState.value + if (screenStateValue is Uninitialized) { + return + } + + val taskTypeId = taskTypeData.id ?: return + val input = screenStateValue.source.text + + repeat(MAX_RETRY) { attempt -> + val translationTasks = remoteRepository.getTaskList(taskTypeId) + val translationResult = translationTasks + ?.find { it.input?.input == input } + ?.output + ?.output + + if (!translationResult.isNullOrBlank()) { + _screenState.update { it.withTargetText(translationResult) } + return + } + + Log_OC.d(TAG, "Translation not ready yet (attempt ${attempt + 1}/$MAX_RETRY)") + + if (attempt < MAX_RETRY - 1) { + delay(POLLING_INTERVAL_MS) + } + } + + Log_OC.w(TAG, "Translation polling finished but result is still empty") + updateSnackbarMessage(R.string.translation_screen_task_processing) + } + + fun updateSnackbarMessage(value: Int?) { + _snackbarMessageId.update { + value + } + } + + fun updateSourceState(newSourceState: TranslationSideState) { + _screenState.update { it.withSource(newSourceState) } + } + + fun updateTargetState(newTargetState: TranslationSideState) { + _screenState.update { it.withTarget(newTargetState) } + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/AssistantDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/AssistantDao.kt index 9ba9012ea2a6..f674ae4f3c6c 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/AssistantDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/AssistantDao.kt @@ -33,9 +33,9 @@ interface AssistantDao { @Query( """ SELECT * FROM ${ProviderMeta.ProviderTableMeta.ASSISTANT_TABLE_NAME} - WHERE accountName = :accountName + WHERE accountName = :accountName AND type = :taskType ORDER BY lastUpdated DESC """ ) - suspend fun getAssistantTasksByAccount(accountName: String): List + suspend fun getAssistantTasksByAccount(accountName: String, taskType: String): List } diff --git a/app/src/main/res/drawable/ic_translate.xml b/app/src/main/res/drawable/ic_translate.xml new file mode 100644 index 000000000000..fa98b6cc47b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_translate.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 717f4c5c06a6..a99fd1d05754 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,15 @@ successful failed + + Please select task + Translate from: + Translate to: + Translating… + Press the button to translate + Enter text to translate… + Translation is taking longer than expected. + Conversations No conversations yet diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b18ff7db236..04f75794f1d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidCommonLibraryVersion = "4fc0f29981" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" -androidLibraryVersion ="839366c261c52437425cde740c9e5a4ab8c8bace" +androidLibraryVersion ="0752f97f74" androidPluginVersion = "9.0.0" androidsvgVersion = "1.4" androidxMediaVersion = "1.5.1" @@ -182,6 +182,7 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-material3 = { module = "androidx.compose.material3:material3" } +compose-activity = { module = "androidx.activity:activity-compose" } # Media3 media3-datasource = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 57f378ae5932..f3b5dbd80c36 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -19524,6 +19524,14 @@ + + + + + + + + @@ -19636,6 +19644,14 @@ + + + + + + + + @@ -19708,6 +19724,14 @@ + + + + + + + + @@ -20012,6 +20036,14 @@ + + + + + + + + @@ -20449,6 +20481,14 @@ + + + + + + + +