diff --git a/.idea/inspectionProfiles/ktlint.xml b/.idea/inspectionProfiles/ktlint.xml
index f82b736f29a6..4aab2f7215ef 100644
--- a/.idea/inspectionProfiles/ktlint.xml
+++ b/.idea/inspectionProfiles/ktlint.xml
@@ -6,15 +6,19 @@
+
+
+
+
@@ -39,6 +43,7 @@
+
@@ -66,6 +71,7 @@
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7c5764b7af6b..1d38331a1588 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -355,6 +355,7 @@ dependencies {
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.foundation)
debugImplementation(libs.compose.ui.tooling)
// endregion
diff --git a/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt b/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt
index 5463af89bf34..6d1c0475a46e 100644
--- a/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt
+++ b/app/src/androidTest/java/com/nextcloud/client/assistant/AssistantRepositoryTests.kt
@@ -11,6 +11,7 @@ import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositor
import com.owncloud.android.AbstractOnServerIT
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
import com.owncloud.android.lib.resources.status.NextcloudVersion
+import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -33,8 +34,10 @@ class AssistantRepositoryTests : AbstractOnServerIT() {
return
}
- val result = sut?.getTaskTypes()
- assertTrue(result?.isNotEmpty() == true)
+ runBlocking {
+ val result = sut?.getTaskTypes()
+ assertTrue(result?.isNotEmpty() == true)
+ }
}
@Test
@@ -45,8 +48,10 @@ class AssistantRepositoryTests : AbstractOnServerIT() {
return
}
- val result = sut?.getTaskList("assistant")
- assertTrue(result?.isEmpty() == true || (result?.size ?: 0) > 0)
+ runBlocking {
+ val result = sut?.getTaskList("assistant")
+ assertTrue(result?.isEmpty() == true || (result?.size ?: 0) > 0)
+ }
}
@Test
@@ -65,8 +70,11 @@ class AssistantRepositoryTests : AbstractOnServerIT() {
emptyMap(),
emptyMap()
)
- val result = sut?.createTask(input, taskType)
- assertTrue(result?.isSuccess == true)
+
+ runBlocking {
+ val result = sut?.createTask(input, taskType)
+ assertTrue(result?.isSuccess == true)
+ }
}
@Test
@@ -81,14 +89,16 @@ class AssistantRepositoryTests : AbstractOnServerIT() {
sleep(120)
- val taskList = sut?.getTaskList("assistant")
- assertTrue(taskList != null)
+ runBlocking {
+ val taskList = sut?.getTaskList("assistant")
+ assertTrue(taskList != null)
- sleep(120)
+ sleep(120)
- assert((taskList?.size ?: 0) > 0)
+ assert((taskList?.size ?: 0) > 0)
- val result = sut?.deleteTask(taskList!!.first().id)
- assertTrue(result?.isSuccess == true)
+ val result = sut?.deleteTask(taskList!!.first().id)
+ assertTrue(result?.isSuccess == true)
+ }
}
}
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 a1931bf5a7a0..b8b5d43532cb 100644
--- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt
+++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -20,17 +21,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.FabPosition
-import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
@@ -39,8 +41,10 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -51,10 +55,13 @@ 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.component.AddTaskAlertDialog
+import com.nextcloud.client.assistant.chat.ChatContent
+import com.nextcloud.client.assistant.conversation.ConversationScreen
+import com.nextcloud.client.assistant.conversation.ConversationViewModel
+import com.nextcloud.client.assistant.conversation.repository.MockConversationRemoteRepository
import com.nextcloud.client.assistant.extensions.getInputTitle
+import com.nextcloud.client.assistant.model.AssistantScreenState
import com.nextcloud.client.assistant.model.ScreenOverlayState
-import com.nextcloud.client.assistant.model.ScreenState
import com.nextcloud.client.assistant.repository.local.MockAssistantLocalRepository
import com.nextcloud.client.assistant.repository.remote.MockAssistantRemoteRepository
import com.nextcloud.client.assistant.task.TaskView
@@ -62,6 +69,7 @@ import com.nextcloud.client.assistant.taskTypes.TaskTypesRow
import com.nextcloud.ui.composeActivity.ComposeActivity
import com.nextcloud.ui.composeComponents.alertDialog.SimpleAlertDialog
import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet
+import com.nextcloud.utils.extensions.getChat
import com.owncloud.android.R
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
@@ -69,15 +77,21 @@ import com.owncloud.android.lib.resources.status.OCCapability
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+private const val CHAT_INPUT_DELAY = 100L
private const val PULL_TO_REFRESH_DELAY = 1500L
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun AssistantScreen(viewModel: AssistantViewModel, capability: OCCapability, activity: Activity) {
+fun AssistantScreen(
+ viewModel: AssistantViewModel,
+ conversationViewModel: ConversationViewModel,
+ capability: OCCapability,
+ activity: Activity
+) {
+ val sessionId by viewModel.sessionId.collectAsState()
val messageId by viewModel.snackbarMessageId.collectAsState()
val screenOverlayState by viewModel.screenOverlayState.collectAsState()
-
val selectedTaskType by viewModel.selectedTaskType.collectAsState()
val filteredTaskList by viewModel.filteredTaskList.collectAsState()
val screenState by viewModel.screenState.collectAsState()
@@ -85,6 +99,7 @@ fun AssistantScreen(viewModel: AssistantViewModel, capability: OCCapability, act
val scope = rememberCoroutineScope()
val pullRefreshState = rememberPullToRefreshState()
val snackbarHostState = remember { SnackbarHostState() }
+ val pagerState = rememberPagerState(initialPage = 1, pageCount = { 2 })
LaunchedEffect(messageId) {
messageId?.let {
@@ -93,93 +108,201 @@ fun AssistantScreen(viewModel: AssistantViewModel, capability: OCCapability, act
}
}
- LaunchedEffect(Unit) {
- viewModel.startTaskListPolling()
+ LaunchedEffect(sessionId) {
+ viewModel.startPolling(sessionId)
+
+ sessionId?.let {
+ viewModel.fetchChatMessages(it)
+ }
}
DisposableEffect(Unit) {
onDispose {
- viewModel.stopTaskListPolling()
+ viewModel.stopPolling()
}
}
- Scaffold(
- modifier = Modifier.pullToRefresh(
- false,
- pullRefreshState,
- onRefresh = {
- scope.launch {
- delay(PULL_TO_REFRESH_DELAY)
- viewModel.fetchTaskList()
- }
+ HorizontalPager(
+ state = pagerState,
+ userScrollEnabled = taskTypes.getChat() != null
+ ) { page ->
+ when (page) {
+ 0 -> {
+ ConversationScreen(viewModel = conversationViewModel, close = {
+ scope.launch {
+ pagerState.scrollToPage(1)
+ }
+ }, openChat = { newSessionId ->
+ viewModel.initSessionId(newSessionId)
+ taskTypes.getChat()?.let { chatTaskType ->
+ viewModel.selectTaskType(chatTaskType)
+ }
+ scope.launch {
+ pagerState.scrollToPage(1)
+ }
+ })
}
- ),
- topBar = {
- taskTypes?.let {
- TaskTypesRow(selectedTaskType, data = it) { task ->
- viewModel.selectTaskType(task)
+ 1 -> {
+ Scaffold(
+ modifier = Modifier.pullToRefresh(
+ false,
+ pullRefreshState,
+ onRefresh = {
+ scope.launch {
+ delay(PULL_TO_REFRESH_DELAY)
+
+ val newSessionId = sessionId
+ if (newSessionId != null) {
+ viewModel.fetchChatMessages(newSessionId)
+ } else {
+ viewModel.fetchTaskList()
+ }
+ }
+ }
+ ),
+ topBar = {
+ taskTypes?.let {
+ TaskTypesRow(selectedTaskType, data = it, selectTaskType = { task ->
+ viewModel.selectTaskType(task)
+ }, navigateToConversationList = {
+ scope.launch {
+ pagerState.scrollToPage(0)
+ }
+ })
+ }
+ },
+ bottomBar = {
+ if (!taskTypes.isNullOrEmpty()) {
+ ChatInputBar(
+ sessionId,
+ selectedTaskType,
+ viewModel
+ )
+ }
+ },
+ snackbarHost = {
+ SnackbarHost(snackbarHostState)
+ }
+ ) { paddingValues ->
+ when (screenState) {
+ is AssistantScreenState.EmptyContent -> {
+ val state = (screenState as AssistantScreenState.EmptyContent)
+ EmptyContent(
+ paddingValues,
+ iconId = state.iconId,
+ descriptionId = state.descriptionId,
+ titleId = state.titleId
+ )
+ }
+
+ AssistantScreenState.TaskContent -> {
+ TaskContent(
+ paddingValues,
+ filteredTaskList ?: listOf(),
+ viewModel,
+ capability
+ )
+ }
+
+ AssistantScreenState.ChatContent -> {
+ ChatContent(
+ viewModel = viewModel,
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+
+ else -> EmptyContent(
+ paddingValues,
+ iconId = R.drawable.spinner_inner,
+ titleId = null,
+ descriptionId = R.string.common_loading
+ )
+ }
+
+ LinearProgressIndicator(
+ progress = { pullRefreshState.distanceFraction },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ OverlayState(screenOverlayState, activity, viewModel)
}
}
- },
- floatingActionButton = {
- if (!taskTypes.isNullOrEmpty()) {
- AddTaskButton(
- selectedTaskType,
- viewModel
- )
- }
- },
- floatingActionButtonPosition = FabPosition.EndOverlay,
- snackbarHost = {
- SnackbarHost(snackbarHostState)
}
- ) { paddingValues ->
- when (screenState) {
- is ScreenState.EmptyContent -> {
- val state = (screenState as ScreenState.EmptyContent)
- EmptyContent(
- paddingValues,
- state.iconId,
- state.descriptionId
- )
- }
+ }
+}
- ScreenState.Content -> {
- AssistantContent(
- paddingValues,
- filteredTaskList ?: listOf(),
- viewModel,
- capability
- )
- }
+@Suppress("LongMethod")
+@Composable
+private fun ChatInputBar(sessionId: Long?, selectedTaskType: TaskTypeData?, viewModel: AssistantViewModel) {
+ val scope = rememberCoroutineScope()
+ var text by remember { mutableStateOf("") }
- else -> EmptyContent(
- paddingValues,
- R.drawable.spinner_inner,
- R.string.assistant_screen_loading
+ Surface(
+ tonalElevation = 3.dp,
+ shadowElevation = 4.dp,
+ color = MaterialTheme.colorScheme.surface
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(R.string.assistant_output_generation_warning_text),
+ fontSize = 11.sp,
+ textAlign = TextAlign.Center,
+ color = colorResource(R.color.text_color)
)
- }
- LinearProgressIndicator(
- progress = { pullRefreshState.distanceFraction },
- modifier = Modifier.fillMaxWidth()
- )
+ Spacer(modifier = Modifier.height(8.dp))
- OverlayState(screenOverlayState, activity, viewModel)
- }
-}
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = text,
+ onValueChange = { text = it },
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 8.dp),
+ placeholder = { Text(selectedTaskType?.description ?: "") },
+ singleLine = true
+ )
-@Composable
-private fun AddTaskButton(selectedTaskType: TaskTypeData?, viewModel: AssistantViewModel) {
- FloatingActionButton(
- onClick = {
- selectedTaskType?.let {
- val newState = ScreenOverlayState.AddTask(it, "")
- viewModel.updateTaskListScreenState(newState)
+ IconButton(
+ onClick = {
+ if (text.isBlank()) {
+ return@IconButton
+ }
+
+ val taskType = selectedTaskType ?: return@IconButton
+ if (taskType.isChat()) {
+ if (sessionId != null) {
+ viewModel.sendChatMessage(content = text, sessionId)
+ } else {
+ viewModel.createConversation(text)
+ }
+ } else {
+ viewModel.createTask(input = text, taskType = taskType)
+ }
+
+ scope.launch {
+ delay(CHAT_INPUT_DELAY)
+ text = ""
+ }
+ }
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_send),
+ contentDescription = "Send message",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
}
}
- ) {
- Icon(Icons.Filled.Add, "Add Task Icon")
}
}
@@ -187,42 +310,24 @@ private fun AddTaskButton(selectedTaskType: TaskTypeData?, viewModel: AssistantV
@Composable
private fun OverlayState(state: ScreenOverlayState?, activity: Activity, viewModel: AssistantViewModel) {
when (state) {
- is ScreenOverlayState.AddTask -> {
- AddTaskAlertDialog(
- title = state.taskType.name,
- description = state.taskType.description,
- defaultInput = state.input,
- addTask = { input ->
- state.taskType.let { taskType ->
- viewModel.createTask(input = input, taskType = taskType)
- }
- },
- dismiss = {
- viewModel.updateTaskListScreenState(null)
- }
- )
- }
-
is ScreenOverlayState.DeleteTask -> {
SimpleAlertDialog(
title = stringResource(id = R.string.assistant_screen_delete_task_alert_dialog_title),
description = stringResource(id = R.string.assistant_screen_delete_task_alert_dialog_description),
- dismiss = { viewModel.updateTaskListScreenState(null) },
+ dismiss = { viewModel.updateScreenOverlayState(null) },
onComplete = { viewModel.deleteTask(state.id) }
)
}
is ScreenOverlayState.TaskActions -> {
- val actions = state.getActions(activity, onEditCompleted = { addTask ->
- viewModel.updateTaskListScreenState(addTask)
- }, onDeleteCompleted = { deleteTask ->
- viewModel.updateTaskListScreenState(deleteTask)
+ val actions = state.getActions(activity, onDeleteCompleted = { deleteTask ->
+ viewModel.updateScreenOverlayState(deleteTask)
})
MoreActionsBottomSheet(
title = state.task.getInputTitle(),
actions = actions,
- dismiss = { viewModel.updateTaskListScreenState(null) }
+ dismiss = { viewModel.updateScreenOverlayState(null) }
)
}
@@ -231,7 +336,7 @@ private fun OverlayState(state: ScreenOverlayState?, activity: Activity, viewMod
}
@Composable
-private fun AssistantContent(
+private fun TaskContent(
paddingValues: PaddingValues,
taskList: List,
viewModel: AssistantViewModel,
@@ -251,7 +356,7 @@ private fun AssistantContent(
capability,
showTaskActions = {
val newState = ScreenOverlayState.TaskActions(task)
- viewModel.updateTaskListScreenState(newState)
+ viewModel.updateScreenOverlayState(newState)
}
)
Spacer(modifier = Modifier.height(8.dp))
@@ -260,7 +365,7 @@ private fun AssistantContent(
}
@Composable
-private fun EmptyContent(paddingValues: PaddingValues, iconId: Int?, descriptionId: Int) {
+private fun EmptyContent(paddingValues: PaddingValues, iconId: Int?, descriptionId: Int?, titleId: Int?) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -280,12 +385,24 @@ private fun EmptyContent(paddingValues: PaddingValues, iconId: Int?, description
Spacer(modifier = Modifier.height(8.dp))
}
- Text(
- text = stringResource(descriptionId),
- fontSize = 18.sp,
- textAlign = TextAlign.Center,
- color = colorResource(R.color.text_color)
- )
+ titleId?.let {
+ Text(
+ text = stringResource(titleId),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ color = colorResource(R.color.text_color)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ descriptionId?.let {
+ Text(
+ text = stringResource(descriptionId),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = colorResource(R.color.text_color)
+ )
+ }
}
}
@@ -296,7 +413,8 @@ private fun AssistantScreenPreview() {
MaterialTheme(
content = {
AssistantScreen(
- viewModel = getMockViewModel(false),
+ conversationViewModel = getMockConversationViewModel(),
+ viewModel = getMockAssistantViewModel(false),
activity = ComposeActivity(),
capability = OCCapability().apply {
versionMayor = 30
@@ -313,7 +431,8 @@ private fun AssistantEmptyScreenPreview() {
MaterialTheme(
content = {
AssistantScreen(
- viewModel = getMockViewModel(true),
+ conversationViewModel = getMockConversationViewModel(),
+ viewModel = getMockAssistantViewModel(true),
activity = ComposeActivity(),
capability = OCCapability().apply {
versionMayor = 30
@@ -323,12 +442,20 @@ private fun AssistantEmptyScreenPreview() {
)
}
-private fun getMockViewModel(giveEmptyTasks: Boolean): AssistantViewModel {
+private fun getMockConversationViewModel(): ConversationViewModel {
+ val mockRemoteRepository = MockConversationRemoteRepository()
+ return ConversationViewModel(
+ remoteRepository = mockRemoteRepository
+ )
+}
+
+private fun getMockAssistantViewModel(giveEmptyTasks: Boolean): AssistantViewModel {
val mockLocalRepository = MockAssistantLocalRepository()
val mockRemoteRepository = MockAssistantRemoteRepository(giveEmptyTasks)
return AssistantViewModel(
accountName = "test:localhost",
remoteRepository = mockRemoteRepository,
- localRepository = mockLocalRepository
+ localRepository = mockLocalRepository,
+ sessionIdArg = null
)
}
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 6f64bb69b4f4..6e49c5adfa79 100644
--- a/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt
+++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantViewModel.kt
@@ -9,12 +9,16 @@ package com.nextcloud.client.assistant
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.nextcloud.client.assistant.model.AssistantScreenState
import com.nextcloud.client.assistant.model.ScreenOverlayState
-import com.nextcloud.client.assistant.model.ScreenState
import com.nextcloud.client.assistant.repository.local.AssistantLocalRepository
import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepository
+import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND
+import com.nextcloud.utils.extensions.isHuman
import com.owncloud.android.R
import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
import kotlinx.coroutines.Dispatchers
@@ -22,27 +26,33 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+@Suppress("TooManyFunctions")
class AssistantViewModel(
private val accountName: String,
private val remoteRepository: AssistantRemoteRepository,
- private val localRepository: AssistantLocalRepository
+ private val localRepository: AssistantLocalRepository,
+ sessionIdArg: Long?
) : ViewModel() {
companion object {
private const val TAG = "AssistantViewModel"
- private const val TASK_LIST_POLLING_INTERVAL_MS = 15_000L
+ private const val POLLING_INTERVAL_MS = 15_000L
}
- private val _screenState = MutableStateFlow(null)
- val screenState: StateFlow = _screenState
+ private val _screenState = MutableStateFlow(null)
+ val screenState: StateFlow = _screenState
private val _screenOverlayState = MutableStateFlow(null)
val screenOverlayState: StateFlow = _screenOverlayState
+ private val _sessionId = MutableStateFlow(sessionIdArg)
+ val sessionId: StateFlow = _sessionId
+
private val _snackbarMessageId = MutableStateFlow(null)
val snackbarMessageId: StateFlow = _snackbarMessageId
@@ -57,36 +67,59 @@ class AssistantViewModel(
private val _filteredTaskList = MutableStateFlow?>(null)
val filteredTaskList: StateFlow?> = _filteredTaskList
- private var taskPollingJob: Job? = null
+ private val _chatMessages = MutableStateFlow>(listOf())
+ val chatMessages: StateFlow> = _chatMessages
+
+ private val _isAssistantAnswering = MutableStateFlow(false)
+ val isAssistantAnswering: StateFlow = _isAssistantAnswering
+
+ private var pollingJob: Job? = null
+ private var currentChatTaskId: String? = null
init {
+ observeScreenState()
fetchTaskTypes()
}
// region task polling
- fun startTaskListPolling() {
- stopTaskListPolling()
+ fun startPolling(sessionId: Long?) {
+ stopPolling()
- taskPollingJob = viewModelScope.launch(Dispatchers.IO) {
+ pollingJob = viewModelScope.launch(Dispatchers.IO) {
try {
while (isActive) {
- Log_OC.d(TAG, "Polling task list...")
- fetchTaskListSuspending()
- delay(TASK_LIST_POLLING_INTERVAL_MS)
+ delay(POLLING_INTERVAL_MS)
+
+ val taskType = _selectedTaskType.value ?: continue
+
+ if (taskType.isChat() && sessionId != null) {
+ Log_OC.d(TAG, "Polling chat messages, sessionId: $sessionId")
+
+ if (currentChatTaskId == null) {
+ remoteRepository.generateSession(sessionId.toString())?.let {
+ currentChatTaskId = it.taskId.toString()
+ }
+ }
+
+ fetchNewChatMessage(sessionId)
+ } else if (!taskType.isChat()) {
+ Log_OC.d(TAG, "Polling task list")
+ pollTaskList()
+ }
}
} finally {
- Log_OC.d(TAG, "Polling coroutine cancelled")
+ Log_OC.d(TAG, "Polling cancelled, sessionId: $sessionId")
}
}
}
- fun stopTaskListPolling() {
- taskPollingJob?.cancel()
- taskPollingJob = null
+ fun stopPolling() {
+ pollingJob?.cancel()
+ pollingJob = null
}
// endregion
- private suspend fun fetchTaskListSuspending() {
+ private suspend fun pollTaskList() {
val cachedTasks = localRepository.getCachedTasks(accountName)
if (cachedTasks.isNotEmpty()) {
_filteredTaskList.value = cachedTasks.sortedByDescending { it.id }
@@ -101,107 +134,171 @@ class AssistantViewModel(
}
}
- @Suppress("MagicNumber")
- fun createTask(input: String, taskType: TaskTypeData) {
- viewModelScope.launch(Dispatchers.IO) {
- val result = remoteRepository.createTask(input, taskType)
+ fun fetchNewChatMessage(sessionId: Long) = viewModelScope.launch(Dispatchers.IO) {
+ val taskId = currentChatTaskId ?: return@launch
+ val newMessage = remoteRepository.checkGeneration(taskId, sessionId.toString()) ?: return@launch
- val messageId = if (result.isSuccess) {
- R.string.assistant_screen_task_create_success_message
- } else {
- R.string.assistant_screen_task_create_fail_message
+ _chatMessages.update { current ->
+ val messageExists = current.any {
+ it.id == newMessage.id ||
+ (it.timestamp == newMessage.timestamp && it.content == newMessage.content)
}
- updateSnackbarMessage(messageId)
-
- delay(2000L)
- fetchTaskList()
+ if (messageExists) {
+ current
+ } else {
+ if (!newMessage.isHuman()) {
+ _isAssistantAnswering.update {
+ false
+ }
+ }
+ current + newMessage
+ }
}
}
- fun selectTaskType(task: TaskTypeData) {
- _selectedTaskType.update {
- task
+ private fun observeScreenState() {
+ viewModelScope.launch {
+ combine(
+ _selectedTaskType,
+ _chatMessages,
+ _filteredTaskList
+ ) { selectedTask, chats, tasks ->
+ val isChat = selectedTask?.isChat() == true
+
+ when {
+ selectedTask == null -> AssistantScreenState.Loading
+ isChat && chats.isEmpty() -> AssistantScreenState.emptyChatList()
+ isChat -> AssistantScreenState.ChatContent
+ !isChat && (tasks == null || tasks.isEmpty()) -> AssistantScreenState.emptyTaskList()
+ else -> AssistantScreenState.TaskContent
+ }
+ }.collect { newState ->
+ _screenState.value = newState
+ }
}
-
- fetchTaskList()
}
- private fun fetchTaskTypes() {
- viewModelScope.launch(Dispatchers.IO) {
- val taskTypesResult = remoteRepository.getTaskTypes()
- if (taskTypesResult == null || taskTypesResult.isEmpty()) {
- _screenState.update {
- ScreenState.emptyTaskTypes()
- }
- return@launch
+ // region chat
+ fun sendChatMessage(content: String, sessionId: Long) = viewModelScope.launch(Dispatchers.IO) {
+ val request = ChatMessageRequest(
+ sessionId = sessionId.toString(),
+ role = "human",
+ content = content,
+ timestamp = System.currentTimeMillis() / MILLIS_PER_SECOND,
+ firstHumanMessage = _chatMessages.value.isEmpty()
+ )
+
+ remoteRepository.sendChatMessage(request)?.let { newMessage ->
+ _chatMessages.update { messages ->
+ messages + newMessage
}
+ _isAssistantAnswering.update {
+ true
+ }
+ } ?: updateSnackbarMessage(R.string.assistant_screen_chat_create_error)
+ }
- _taskTypes.update {
- taskTypesResult
+ fun fetchChatMessages(sessionId: Long) = viewModelScope.launch(Dispatchers.IO) {
+ remoteRepository.fetchChatMessages(sessionId)?.let { messageList ->
+ _chatMessages.update {
+ messageList
}
+ } ?: updateSnackbarMessage(R.string.assistant_screen_chat_fetch_error)
+ }
- selectTaskType(taskTypesResult.first())
+ fun createConversation(title: String) = viewModelScope.launch(Dispatchers.IO) {
+ remoteRepository.createConversation(title)?.let { result ->
+ initSessionId(result.session.id)
+ sendChatMessage(title, result.session.id)
}
}
- fun fetchTaskList() {
- viewModelScope.launch(Dispatchers.IO) {
- // Try cached data first
- val cachedTasks = localRepository.getCachedTasks(accountName)
- if (cachedTasks.isNotEmpty()) {
- _filteredTaskList.update {
- cachedTasks.sortedByDescending { it.id }
- }
- updateTaskListScreenState()
- }
+ fun initSessionId(value: Long) {
+ Log_OC.d(TAG, "session id updated: $value")
+ currentChatTaskId = null
+ _sessionId.update { value }
+ }
+ // endregion
- val taskType = _selectedTaskType.value?.id ?: return@launch
- val result = remoteRepository.getTaskList(taskType)
- if (result != null) {
- taskList = result
- _filteredTaskList.update {
- taskList?.sortedByDescending { task ->
- task.id
- }
- }
+ // region task
+ fun createTask(input: String, taskType: TaskTypeData) = viewModelScope.launch(Dispatchers.IO) {
+ val result = remoteRepository.createTask(input, taskType)
+ val message = if (result.isSuccess) {
+ R.string.assistant_screen_task_create_success_message
+ } else {
+ R.string.assistant_screen_task_create_fail_message
+ }
- localRepository.cacheTasks(result, accountName)
- updateSnackbarMessage(null)
+ updateSnackbarMessage(message)
+ delay(MILLIS_PER_SECOND * 2L)
+ fetchTaskList()
+ }
+
+ fun selectTaskType(task: TaskTypeData) {
+ Log_OC.d(TAG, "Task type changed: ${task.name}, session id: ${_sessionId.value}")
+ updateTaskType(task)
+
+ val sessionId = _sessionId.value ?: return
+ if (task.isChat()) {
+ if (_chatMessages.value.isEmpty()) {
+ fetchChatMessages(sessionId)
} else {
- updateSnackbarMessage(R.string.assistant_screen_task_list_error_state_message)
+ fetchNewChatMessage(sessionId)
}
-
- updateTaskListScreenState()
+ } else {
+ fetchTaskList()
}
}
- private fun updateTaskListScreenState() {
- _screenState.update {
- if (_filteredTaskList.value?.isEmpty() == true) {
- ScreenState.emptyTaskList()
- } else {
- ScreenState.Content
- }
+ private fun fetchTaskTypes() = viewModelScope.launch(Dispatchers.IO) {
+ val result = remoteRepository.getTaskTypes()
+ if (result.isNullOrEmpty()) {
+ _screenState.value = AssistantScreenState.emptyTaskTypes()
+ return@launch
+ }
+
+ _taskTypes.update {
+ result
}
+ selectTaskType(result.first())
}
- fun deleteTask(id: Long) {
- viewModelScope.launch(Dispatchers.IO) {
- val result = remoteRepository.deleteTask(id)
+ fun fetchTaskList() = viewModelScope.launch(Dispatchers.IO) {
+ val cached = localRepository.getCachedTasks(accountName)
+ if (cached.isNotEmpty()) {
+ _filteredTaskList.value = cached.sortedByDescending { it.id }
+ }
- val messageId = if (result.isSuccess) {
- R.string.assistant_screen_task_delete_success_message
- } else {
- R.string.assistant_screen_task_delete_fail_message
- }
+ _selectedTaskType.value?.id?.let { typeId ->
+ remoteRepository.getTaskList(typeId)?.let { result ->
+ taskList = result
+ _filteredTaskList.value = result.sortedByDescending { it.id }
+ localRepository.cacheTasks(result, accountName)
+ updateSnackbarMessage(null)
+ } ?: updateSnackbarMessage(R.string.assistant_screen_task_list_error_state_message)
+ }
+ }
- updateSnackbarMessage(messageId)
+ fun deleteTask(id: Long) = viewModelScope.launch(Dispatchers.IO) {
+ val result = remoteRepository.deleteTask(id)
+ val message = if (result.isSuccess) {
+ R.string.assistant_screen_task_delete_success_message
+ } else {
+ R.string.assistant_screen_task_delete_fail_message
+ }
- if (result.isSuccess) {
- removeTaskFromList(id)
- localRepository.deleteTask(id, accountName)
- }
+ updateSnackbarMessage(message)
+ if (result.isSuccess) {
+ removeTaskFromList(id)
+ localRepository.deleteTask(id, accountName)
+ }
+ }
+ // endregion
+
+ private fun updateTaskType(value: TaskTypeData) {
+ _selectedTaskType.update {
+ value
}
}
@@ -211,7 +308,7 @@ class AssistantViewModel(
}
}
- fun updateTaskListScreenState(value: ScreenOverlayState?) {
+ fun updateScreenOverlayState(value: ScreenOverlayState?) {
_screenOverlayState.update {
value
}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt
new file mode 100644
index 000000000000..930334ae8d4f
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/assistant/chat/ChatContent.kt
@@ -0,0 +1,336 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+@file:Suppress("TopLevelPropertyNaming", "MagicNumber")
+
+package com.nextcloud.client.assistant.chat
+
+import androidx.compose.animation.core.LinearEasing
+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.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+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.utils.TimeConstants
+import com.nextcloud.utils.extensions.isHuman
+import com.nextcloud.utils.extensions.time
+import com.owncloud.android.R
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage
+import java.time.Instant
+
+private val MIN_CHAT_HEIGHT = 60.dp
+private val CHAT_BUBBLE_CORNER_RADIUS = 8.dp
+private val ASSISTANT_ICON_SIZE = 40.dp
+
+@Composable
+fun ChatContent(viewModel: AssistantViewModel, modifier: Modifier = Modifier) {
+ val chatMessages by viewModel.chatMessages.collectAsState()
+ val isAssistantAnswering by viewModel.isAssistantAnswering.collectAsState()
+ val listState = rememberLazyListState()
+
+ LaunchedEffect(chatMessages) {
+ listState.animateScrollToItem(listState.layoutInfo.totalItemsCount)
+ }
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.Bottom,
+ reverseLayout = false,
+ state = listState
+ ) {
+ items(chatMessages, key = { it.id }) { message ->
+ if (message.isHuman()) {
+ UserMessageItem(message)
+ } else {
+ AssistantMessageItem(message)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ if (isAssistantAnswering) {
+ item {
+ AssistantTypingIndicator()
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun AssistantTypingIndicator() {
+ Box(
+ modifier = Modifier
+ .padding(vertical = 12.dp)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Row(
+ verticalAlignment = Alignment.Bottom
+ ) {
+ AnimatedAssistantIcon()
+
+ Box(
+ modifier = Modifier
+ .padding(start = 8.dp, end = 16.dp)
+ .defaultMinSize(minHeight = MIN_CHAT_HEIGHT)
+ .clip(
+ RoundedCornerShape(
+ topEnd = CHAT_BUBBLE_CORNER_RADIUS,
+ topStart = CHAT_BUBBLE_CORNER_RADIUS,
+ bottomEnd = CHAT_BUBBLE_CORNER_RADIUS
+ )
+ )
+ .background(color = colorResource(R.color.white))
+ ) {
+ TypingAnimation()
+ }
+ }
+ }
+}
+
+@Composable
+private fun AnimatedAssistantIcon() {
+ val infiniteTransition = rememberInfiniteTransition(label = "assistant_icon_animation")
+ val scale by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 1.1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(TimeConstants.MILLIS_PER_SECOND, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "scale_animation"
+ )
+
+ Box(
+ modifier = Modifier
+ .size(ASSISTANT_ICON_SIZE)
+ .clip(CircleShape)
+ .background(Color.White),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_assistant),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ alignment = Alignment.Center,
+ modifier = Modifier
+ .scale(scale)
+ )
+ }
+}
+
+@Composable
+private fun TypingAnimation() {
+ val infiniteTransition = rememberInfiniteTransition(label = "typing_animation")
+
+ val alpha by infiniteTransition.animateFloat(
+ initialValue = 0.3f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(600, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "typing_alpha"
+ )
+
+ Column(
+ modifier = Modifier.padding(8.dp),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Text(
+ text = stringResource(R.string.assistant_thinking),
+ style = TextStyle(
+ color = colorResource(R.color.text_color).copy(alpha = alpha),
+ fontSize = 16.sp
+ )
+ )
+ }
+}
+
+@Composable
+private fun AssistantMessageItem(message: ChatMessage) {
+ Box(
+ modifier = Modifier
+ .padding(vertical = 12.dp)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Row(
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Box(
+ modifier = Modifier
+ .size(ASSISTANT_ICON_SIZE)
+ .clip(CircleShape)
+ .background(Color.White),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_assistant),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ alignment = Alignment.Center
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .padding(start = 8.dp, end = 16.dp)
+ .defaultMinSize(minHeight = MIN_CHAT_HEIGHT)
+ .clip(
+ RoundedCornerShape(
+ topEnd = CHAT_BUBBLE_CORNER_RADIUS,
+ topStart = CHAT_BUBBLE_CORNER_RADIUS,
+ bottomEnd = CHAT_BUBBLE_CORNER_RADIUS
+ )
+ )
+ .background(
+ color = colorResource(R.color.white)
+ )
+ ) {
+ MessageTextItem(message)
+ }
+ }
+ }
+}
+
+@Composable
+private fun UserMessageItem(message: ChatMessage) {
+ Box(
+ modifier = Modifier
+ .padding(vertical = 12.dp)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ Row(
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(start = 16.dp, end = 8.dp)
+ .defaultMinSize(minHeight = MIN_CHAT_HEIGHT)
+ .clip(
+ RoundedCornerShape(
+ topEnd = CHAT_BUBBLE_CORNER_RADIUS,
+ topStart = CHAT_BUBBLE_CORNER_RADIUS,
+ bottomStart = CHAT_BUBBLE_CORNER_RADIUS
+ )
+ )
+ .background(color = colorResource(R.color.white))
+ ) {
+ MessageTextItem(message)
+ }
+ }
+ }
+}
+
+@Composable
+private fun MessageTextItem(message: ChatMessage) {
+ Column(
+ modifier = Modifier.padding(8.dp),
+ horizontalAlignment = Alignment.Start
+ ) {
+ Text(
+ text = message.content,
+ style = TextStyle(
+ color = colorResource(R.color.text_color),
+ fontSize = 16.sp
+ )
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = message.time(),
+ style = TextStyle(
+ color = colorResource(R.color.text_color),
+ fontSize = 12.sp
+ )
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MessageTextItemPreview() {
+ val mockMessages = listOf(
+ ChatMessage(
+ id = 1,
+ sessionId = 101,
+ role = "human",
+ content = "Hey, how are you?",
+ timestamp = Instant.now().epochSecond,
+ ocpTaskId = null,
+ sources = "",
+ attachments = emptyList()
+ ),
+ ChatMessage(
+ id = 2,
+ sessionId = 101,
+ role = "assistant",
+ content = "I'm good! Here’s a message from yesterday.",
+ timestamp = Instant.now().minusSeconds(86_400).epochSecond, // 1 day ago
+ ocpTaskId = null,
+ sources = "",
+ attachments = emptyList()
+ ),
+ ChatMessage(
+ id = 3,
+ sessionId = 101,
+ role = "human",
+ content = "And an older one from last week.",
+ timestamp = Instant.now().minusSeconds(7 * 86_400).epochSecond, // 7 days ago
+ ocpTaskId = null,
+ sources = "",
+ attachments = emptyList()
+ )
+ )
+
+ Column(modifier = Modifier.padding(16.dp)) {
+ mockMessages.forEach { message ->
+ MessageTextItem(message = message)
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/component/AddTaskAlertDialog.kt b/app/src/main/java/com/nextcloud/client/assistant/component/AddTaskAlertDialog.kt
deleted file mode 100644
index e9ccbb4b3c6d..000000000000
--- a/app/src/main/java/com/nextcloud/client/assistant/component/AddTaskAlertDialog.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2024 Alper Ozturk
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH
- * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
- */
-package com.nextcloud.client.assistant.component
-
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.tooling.preview.Preview
-import com.nextcloud.ui.composeComponents.alertDialog.SimpleAlertDialog
-import com.owncloud.android.R
-
-@Composable
-fun AddTaskAlertDialog(
- title: String,
- description: String?,
- defaultInput: String = "",
- addTask: (String) -> Unit,
- dismiss: () -> Unit
-) {
- var input by remember {
- mutableStateOf(defaultInput)
- }
-
- SimpleAlertDialog(
- title = title,
- description = description ?: "",
- dismiss = { dismiss() },
- onComplete = {
- addTask(input)
- },
- content = {
- TextField(
- placeholder = {
- Text(
- text = stringResource(
- id = R.string.assistant_screen_create_task_alert_dialog_input_field_placeholder
- )
- )
- },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
- value = input,
- onValueChange = {
- input = it
- }
- )
- }
- )
-}
-
-@Composable
-@Preview
-private fun AddTaskAlertDialogPreview() {
- AddTaskAlertDialog(title = "Title", description = "Description", addTask = { }, dismiss = {})
-}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt
new file mode 100644
index 000000000000..0a9a3550b548
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt
@@ -0,0 +1,251 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+@file:Suppress("TopLevelPropertyNaming", "MagicNumber")
+
+package com.nextcloud.client.assistant.conversation
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FabPosition
+import androidx.compose.material3.FloatingActionButton
+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.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.nextcloud.client.assistant.conversation.model.ConversationScreenState
+import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet
+import com.owncloud.android.R
+import com.owncloud.android.lib.resources.assistant.chat.model.Conversation
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Suppress("LongMethod")
+@Composable
+fun ConversationScreen(viewModel: ConversationViewModel, close: () -> Unit, openChat: (Long) -> Unit) {
+ val context = LocalContext.current
+ val screenState by viewModel.screenState.collectAsState()
+ val errorMessageId by viewModel.errorMessageId.collectAsState()
+ val conversations by viewModel.conversations.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(Unit) {
+ viewModel.fetchConversations()
+ }
+
+ LaunchedEffect(errorMessageId) {
+ errorMessageId?.let {
+ snackbarHostState.showSnackbar(context.getString(it))
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ IconButton(
+ onClick = {
+ close()
+ }
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = "go back to assistant page"
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.conversation_screen_title),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ },
+ snackbarHost = {
+ SnackbarHost(snackbarHostState)
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = {
+ viewModel.createConversation(null, onResult = {
+ openChat(it)
+ })
+ }) {
+ Icon(Icons.Filled.Add, "Floating action button.")
+ }
+ },
+ floatingActionButtonPosition = FabPosition.EndOverlay
+ ) { innerPadding ->
+ when (screenState) {
+ is ConversationScreenState.Loading -> {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ is ConversationScreenState.EmptyContent -> {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ stringResource(R.string.conversation_screen_empty_content_title),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ }
+ }
+
+ else -> {
+ ConversationList(
+ viewModel = viewModel,
+ conversations = conversations,
+ modifier = Modifier.padding(innerPadding),
+ openChat = openChat
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConversationList(
+ viewModel: ConversationViewModel,
+ conversations: List,
+ modifier: Modifier = Modifier,
+ openChat: (Long) -> Unit
+) {
+ var selectedConversationId by remember { mutableLongStateOf(-1L) }
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.Bottom
+ ) {
+ items(conversations) { conversation ->
+ ConversationListItem(
+ conversation = conversation,
+ onClick = {
+ openChat(conversation.id)
+ },
+ onLongPressed = {
+ selectedConversationId = conversation.id
+ }
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ }
+
+ if (selectedConversationId != -1L) {
+ val currentId = selectedConversationId
+
+ val bottomSheetAction = listOf(
+ Triple(
+ R.drawable.ic_delete,
+ R.string.conversation_screen_delete_button_title
+ ) {
+ val sessionId: String = currentId.toString()
+ viewModel.deleteConversation(sessionId)
+ selectedConversationId = -1L
+ }
+ )
+
+ MoreActionsBottomSheet(
+ actions = bottomSheetAction,
+ dismiss = { selectedConversationId = -1L }
+ )
+ }
+}
+
+@Composable
+private fun ConversationListItem(conversation: Conversation, onClick: () -> Unit, onLongPressed: () -> Unit) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongPressed
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = conversation.titleRepresentation(),
+ style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = colorResource(R.color.text_color),
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ConversationListPreview() {
+ Column {
+ ConversationListItem(Conversation(1L, "User1", "Who is Al Pacino?", 1762847286L, "", null), {
+ }, {
+ })
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ ConversationListItem(Conversation(2L, "User1", "What is JetpackCompose?", 1761847286L, "", null), {
+ }, {
+ })
+
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt
new file mode 100644
index 000000000000..68e4ff6358eb
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationViewModel.kt
@@ -0,0 +1,99 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.assistant.conversation
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.nextcloud.client.assistant.conversation.model.ConversationScreenState
+import com.nextcloud.client.assistant.conversation.repository.ConversationRemoteRepository
+import com.nextcloud.utils.TimeConstants.MILLIS_PER_SECOND
+import com.owncloud.android.R
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.assistant.chat.model.Conversation
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class ConversationViewModel(private val remoteRepository: ConversationRemoteRepository) : ViewModel() {
+ private val _errorMessageId = MutableStateFlow(null)
+ val errorMessageId: StateFlow = _errorMessageId
+
+ private val _screenState = MutableStateFlow(null)
+ val screenState: StateFlow = _screenState
+
+ private val _conversations = MutableStateFlow>(listOf())
+ val conversations: StateFlow> = _conversations.asStateFlow()
+
+ fun fetchConversations() {
+ _screenState.update {
+ ConversationScreenState.Loading
+ }
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val conversations = remoteRepository.fetchConversationList()
+ if (conversations != null) {
+ if (conversations.isEmpty()) {
+ _screenState.update {
+ ConversationScreenState.emptyConversationList()
+ }
+ } else {
+ _screenState.update {
+ null
+ }
+ _conversations.update {
+ conversations
+ }
+ }
+ } else {
+ _screenState.update {
+ null
+ }
+ _errorMessageId.update {
+ R.string.conversation_screen_fetch_error_title
+ }
+ }
+ }
+ }
+
+ fun createConversation(title: String?, onResult: (Long) -> Unit) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val timestamp = System.currentTimeMillis().div(MILLIS_PER_SECOND)
+ val newConversation = remoteRepository.createConversation(title, timestamp)
+ if (newConversation != null) {
+ _conversations.update {
+ listOf(newConversation.session) + it
+ }
+ onResult(newConversation.session.id)
+ } else {
+ _errorMessageId.update {
+ R.string.conversation_screen_create_error_title
+ }
+ }
+ }
+ }
+
+ fun deleteConversation(sessionId: String) {
+ Log_OC.d("", "BBBB: $sessionId")
+ viewModelScope.launch(Dispatchers.IO) {
+ val success = remoteRepository.deleteConversation(sessionId)
+ if (success) {
+ val updatedList = _conversations.value.filterNot { it.id == sessionId.toLong() }
+ _conversations.update {
+ updatedList
+ }
+ } else {
+ _errorMessageId.update {
+ R.string.conversation_screen_delete_error_title
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/model/ConversationScreenState.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/model/ConversationScreenState.kt
new file mode 100644
index 000000000000..259debcf938e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/model/ConversationScreenState.kt
@@ -0,0 +1,22 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.assistant.conversation.model
+
+import com.owncloud.android.R
+
+sealed class ConversationScreenState {
+ data object Loading : ConversationScreenState()
+
+ data class EmptyContent(val descriptionId: Int) : ConversationScreenState()
+
+ companion object {
+ fun emptyConversationList(): ConversationScreenState = EmptyContent(
+ descriptionId = R.string.conversation_screen_empty_conversation_list_title
+ )
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepository.kt
new file mode 100644
index 000000000000..a0d034d575c3
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepository.kt
@@ -0,0 +1,17 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.assistant.conversation.repository
+
+import com.owncloud.android.lib.resources.assistant.chat.model.Conversation
+import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation
+
+interface ConversationRemoteRepository {
+ suspend fun fetchConversationList(): List?
+ suspend fun createConversation(title: String?, timestamp: Long): CreateConversation?
+ suspend fun deleteConversation(sessionId: String): Boolean
+}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepositoryImpl.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepositoryImpl.kt
new file mode 100644
index 000000000000..8fa89a2bd05e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/ConversationRemoteRepositoryImpl.kt
@@ -0,0 +1,40 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.assistant.conversation.repository
+
+import com.nextcloud.common.NextcloudClient
+import com.owncloud.android.lib.resources.assistant.chat.CreateConversationRemoteOperation
+import com.owncloud.android.lib.resources.assistant.chat.DeleteConversationRemoteOperation
+import com.owncloud.android.lib.resources.assistant.chat.GetConversationListRemoteOperation
+import com.owncloud.android.lib.resources.assistant.chat.model.Conversation
+import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation
+
+class ConversationRemoteRepositoryImpl(private val client: NextcloudClient) : ConversationRemoteRepository {
+ override suspend fun fetchConversationList(): List? {
+ val result = GetConversationListRemoteOperation().execute(client)
+ return if (result.isSuccess) {
+ result.resultData
+ } else {
+ null
+ }
+ }
+
+ override suspend fun createConversation(title: String?, timestamp: Long): CreateConversation? {
+ val result = CreateConversationRemoteOperation(title, timestamp).execute(client)
+ return if (result.isSuccess) {
+ result.resultData
+ } else {
+ null
+ }
+ }
+
+ override suspend fun deleteConversation(sessionId: String): Boolean {
+ val result = DeleteConversationRemoteOperation(sessionId).execute(client)
+ return result.isSuccess
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/MockConversationRemoteRepository.kt b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/MockConversationRemoteRepository.kt
new file mode 100644
index 000000000000..42e13346ac8a
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/repository/MockConversationRemoteRepository.kt
@@ -0,0 +1,19 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.assistant.conversation.repository
+
+import com.owncloud.android.lib.resources.assistant.chat.model.Conversation
+import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation
+
+class MockConversationRemoteRepository : ConversationRemoteRepository {
+ override suspend fun fetchConversationList(): List? = null
+
+ override suspend fun createConversation(title: String?, timestamp: Long): CreateConversation? = null
+
+ override suspend fun deleteConversation(sessionId: String): Boolean = true
+}
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
new file mode 100644
index 000000000000..80d2f1a29fb1
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/assistant/model/AssistantScreenState.kt
@@ -0,0 +1,40 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.client.assistant.model
+
+import com.owncloud.android.R
+
+sealed class AssistantScreenState {
+ data object Loading : AssistantScreenState()
+
+ data object TaskContent : AssistantScreenState()
+
+ data object ChatContent : AssistantScreenState()
+
+ data class EmptyContent(val iconId: Int?, val titleId: Int?, val descriptionId: Int?) : AssistantScreenState()
+
+ companion object {
+ fun emptyTaskTypes(): AssistantScreenState = EmptyContent(
+ titleId = null,
+ descriptionId = R.string.assistant_screen_task_list_empty_warning,
+ iconId = null
+ )
+
+ fun emptyChatList(): AssistantScreenState = EmptyContent(
+ iconId = R.drawable.ic_assistant,
+ titleId = R.string.assistant_screen_empty_content_title,
+ descriptionId = R.string.assistant_screen_empty_content_description
+ )
+
+ fun emptyTaskList(): AssistantScreenState = EmptyContent(
+ iconId = R.drawable.ic_assistant,
+ titleId = R.string.assistant_screen_empty_content_title,
+ descriptionId = null
+ )
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/model/ScreenOverlayState.kt b/app/src/main/java/com/nextcloud/client/assistant/model/ScreenOverlayState.kt
index 02ceb335b72d..76a100b60d96 100644
--- a/app/src/main/java/com/nextcloud/client/assistant/model/ScreenOverlayState.kt
+++ b/app/src/main/java/com/nextcloud/client/assistant/model/ScreenOverlayState.kt
@@ -8,20 +8,16 @@
package com.nextcloud.client.assistant.model
import android.app.Activity
-import com.nextcloud.client.assistant.extensions.getInput
import com.nextcloud.client.assistant.extensions.getInputAndOutput
import com.nextcloud.utils.extensions.showShareIntent
import com.owncloud.android.R
import com.owncloud.android.lib.resources.assistant.v2.model.Task
-import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
import com.owncloud.android.utils.ClipboardUtil
sealed class ScreenOverlayState {
data class DeleteTask(val id: Long) : ScreenOverlayState()
- data class AddTask(val taskType: TaskTypeData, val input: String) : ScreenOverlayState()
data class TaskActions(val task: Task) : ScreenOverlayState() {
private fun getInputAndOutput(): String = task.getInputAndOutput()
- private fun getInput(): String? = task.getInput()
private fun getCopyToClipboardAction(activity: Activity): Triple Unit> = Triple(
R.drawable.ic_content_copy,
@@ -37,22 +33,6 @@ sealed class ScreenOverlayState {
activity.showShareIntent(getInputAndOutput())
}
- private fun getEditAction(activity: Activity, onComplete: (AddTask) -> Unit): Triple Unit> =
- Triple(
- R.drawable.ic_edit,
- R.string.action_edit
- ) {
- val taskType = TaskTypeData(
- task.type,
- activity.getString(R.string.assistant_screen_add_task_alert_dialog_title),
- null,
- emptyMap(),
- emptyMap()
- )
- val newState = AddTask(taskType, getInput() ?: "")
- onComplete(newState)
- }
-
private fun getDeleteAction(onComplete: (DeleteTask) -> Unit): Triple Unit> = Triple(
R.drawable.ic_delete,
R.string.assistant_screen_task_more_actions_bottom_sheet_delete_action
@@ -63,14 +43,10 @@ sealed class ScreenOverlayState {
fun getActions(
activity: Activity,
- onEditCompleted: (AddTask) -> Unit,
onDeleteCompleted: (DeleteTask) -> Unit
): List Unit>> = listOf(
getShareAction(activity),
getCopyToClipboardAction(activity),
- getEditAction(activity, onComplete = {
- onEditCompleted(it)
- }),
getDeleteAction(onComplete = {
onDeleteCompleted(it)
})
diff --git a/app/src/main/java/com/nextcloud/client/assistant/model/ScreenState.kt b/app/src/main/java/com/nextcloud/client/assistant/model/ScreenState.kt
deleted file mode 100644
index 3c2ba4d83e82..000000000000
--- a/app/src/main/java/com/nextcloud/client/assistant/model/ScreenState.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Nextcloud - Android Client
- *
- * SPDX-FileCopyrightText: 2024 Alper Ozturk
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-package com.nextcloud.client.assistant.model
-
-import com.owncloud.android.R
-
-sealed class ScreenState {
- data object Loading : ScreenState()
-
- data object Content : ScreenState()
-
- data class EmptyContent(val iconId: Int?, val descriptionId: Int) : ScreenState()
-
- companion object {
- fun emptyTaskTypes(): ScreenState = EmptyContent(
- descriptionId = R.string.assistant_screen_task_list_empty_warning,
- iconId = null
- )
-
- fun emptyTaskList(): ScreenState = EmptyContent(
- descriptionId = R.string.assistant_screen_create_a_new_task_from_bottom_right_text,
- iconId = null
- )
- }
-}
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 c46245bfd450..3eb48968bb78 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
@@ -8,15 +8,32 @@
package com.nextcloud.client.assistant.repository.remote
import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest
+import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation
+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
interface AssistantRemoteRepository {
- fun getTaskTypes(): List?
+ suspend fun getTaskTypes(): List?
- fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult
+ suspend fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult
- fun getTaskList(taskType: String): List?
+ suspend fun getTaskList(taskType: String): List?
- fun deleteTask(id: Long): RemoteOperationResult
+ suspend fun deleteTask(id: Long): RemoteOperationResult
+
+ suspend fun fetchChatMessages(id: Long): List?
+
+ suspend fun sendChatMessage(request: ChatMessageRequest): ChatMessage?
+
+ suspend fun createConversation(title: String): CreateConversation?
+
+ suspend fun checkSession(sessionId: String): Session?
+
+ suspend fun generateSession(sessionId: String): SessionTask?
+
+ suspend fun checkGeneration(taskId: String, sessionId: String): ChatMessage?
}
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 07545a3591ec..3030ecbe779d 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
@@ -7,8 +7,20 @@
package com.nextcloud.client.assistant.repository.remote
import com.nextcloud.common.NextcloudClient
+import com.nextcloud.utils.TimeConstants
import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
+import com.owncloud.android.lib.resources.assistant.chat.CheckGenerationRemoteOperation
+import com.owncloud.android.lib.resources.assistant.chat.CheckSessionRemoteOperation
+import com.owncloud.android.lib.resources.assistant.chat.CreateConversationRemoteOperation
+import com.owncloud.android.lib.resources.assistant.chat.CreateMessageRemoteOperation
+import com.owncloud.android.lib.resources.assistant.chat.GenerateSessionRemoteOperation
+import com.owncloud.android.lib.resources.assistant.chat.GetMessagesRemoteOperation
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest
+import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation
+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.v1.CreateTaskRemoteOperationV1
import com.owncloud.android.lib.resources.assistant.v1.DeleteTaskRemoteOperationV1
import com.owncloud.android.lib.resources.assistant.v1.GetTaskListRemoteOperationV1
@@ -22,59 +34,94 @@ 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.status.NextcloudVersion
import com.owncloud.android.lib.resources.status.OCCapability
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
class AssistantRemoteRepositoryImpl(private val client: NextcloudClient, capability: OCCapability) :
AssistantRemoteRepository {
private val supportsV2 = capability.version.isNewerOrEqual(NextcloudVersion.nextcloud_30)
- @Suppress("ReturnCount")
- override fun getTaskTypes(): List? {
+ override suspend fun getTaskTypes(): List? = withContext(Dispatchers.IO) {
if (supportsV2) {
val result = GetTaskTypesRemoteOperationV2().execute(client)
if (result.isSuccess) {
- return result.resultData
+ return@withContext result.resultData
}
} else {
val result = GetTaskTypesRemoteOperationV1().execute(client)
if (result.isSuccess) {
- return result.resultData.toV2()
+ return@withContext result.resultData.toV2()
}
}
-
- return null
+ return@withContext null
}
- override fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult = if (supportsV2) {
- CreateTaskRemoteOperationV2(input, taskType).execute(client)
- } else {
- if (taskType.id.isNullOrEmpty()) {
- RemoteOperationResult(ResultCode.CANCELLED)
- } else {
- CreateTaskRemoteOperationV1(input, taskType.id!!).execute(client)
+ override suspend fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult =
+ withContext(Dispatchers.IO) {
+ if (supportsV2) {
+ CreateTaskRemoteOperationV2(input, taskType).execute(client)
+ } else {
+ if (taskType.id.isNullOrEmpty()) {
+ RemoteOperationResult(ResultCode.CANCELLED)
+ } else {
+ CreateTaskRemoteOperationV1(input, taskType.id!!).execute(client)
+ }
+ }
}
- }
- @Suppress("ReturnCount")
- override fun getTaskList(taskType: String): List? {
+ override suspend fun getTaskList(taskType: String): List? = withContext(Dispatchers.IO) {
if (supportsV2) {
val result = GetTaskListRemoteOperationV2(taskType).execute(client)
if (result.isSuccess) {
- return result.resultData.tasks.filter { it.appId == "assistant" }
+ return@withContext result.resultData.tasks.filter { it.appId == "assistant" }
}
} else {
val result = GetTaskListRemoteOperationV1("assistant").execute(client)
if (result.isSuccess) {
- return result.resultData.toV2().tasks.filter { it.type == taskType }
+ return@withContext result.resultData.toV2().tasks.filter { it.type == taskType }
}
}
+ return@withContext null
+ }
+
+ override suspend fun deleteTask(id: Long): RemoteOperationResult = withContext(Dispatchers.IO) {
+ if (supportsV2) {
+ DeleteTaskRemoteOperationV2(id).execute(client)
+ } else {
+ DeleteTaskRemoteOperationV1(id).execute(client)
+ }
+ }
+
+ override suspend fun fetchChatMessages(id: Long): List? = withContext(Dispatchers.IO) {
+ val result = GetMessagesRemoteOperation(id.toString()).execute(client)
+ if (result.isSuccess) result.resultData else null
+ }
+
+ override suspend fun sendChatMessage(request: ChatMessageRequest): ChatMessage? = withContext(Dispatchers.IO) {
+ val result = CreateMessageRemoteOperation(request).execute(client)
+ if (result.isSuccess) result.resultData else null
+ }
- return null
+ override suspend fun createConversation(title: String): CreateConversation? = withContext(Dispatchers.IO) {
+ val timestamp = System.currentTimeMillis().div(TimeConstants.MILLIS_PER_SECOND)
+ val result = CreateConversationRemoteOperation(title, timestamp).execute(client)
+ if (result.isSuccess) result.resultData else null
}
- override fun deleteTask(id: Long): RemoteOperationResult = if (supportsV2) {
- DeleteTaskRemoteOperationV2(id).execute(client)
- } else {
- DeleteTaskRemoteOperationV1(id).execute(client)
+ override suspend fun checkSession(sessionId: String): Session? = withContext(Dispatchers.IO) {
+ val result = CheckSessionRemoteOperation(sessionId).execute(client)
+ if (result.isSuccess) result.resultData else null
}
+
+ override suspend fun generateSession(sessionId: String): SessionTask? = withContext(Dispatchers.IO) {
+ val result = GenerateSessionRemoteOperation(sessionId).execute(client)
+ if (result.isSuccess) result.resultData else null
+ }
+
+ override suspend fun checkGeneration(taskId: String, sessionId: String): ChatMessage? =
+ withContext(Dispatchers.IO) {
+ val result = CheckGenerationRemoteOperation(taskId, sessionId).execute(client)
+ if (result.isSuccess) result.resultData else null
+ }
}
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 b7acd88b41fd..930489812e85 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
@@ -8,6 +8,11 @@ package com.nextcloud.client.assistant.repository.remote
import com.nextcloud.utils.extensions.getRandomString
import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessageRequest
+import com.owncloud.android.lib.resources.assistant.chat.model.CreateConversation
+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.Shape
import com.owncloud.android.lib.resources.assistant.v2.model.Task
import com.owncloud.android.lib.resources.assistant.v2.model.TaskInput
@@ -16,7 +21,7 @@ import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
@Suppress("MagicNumber")
class MockAssistantRemoteRepository(private val giveEmptyTasks: Boolean = false) : AssistantRemoteRepository {
- override fun getTaskTypes(): List = listOf(
+ override suspend fun getTaskTypes(): List = listOf(
TaskTypeData(
id = "core:text2text",
name = "Free text to text prompt",
@@ -38,10 +43,10 @@ class MockAssistantRemoteRepository(private val giveEmptyTasks: Boolean = false)
)
)
- override fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult =
+ override suspend fun createTask(input: String, taskType: TaskTypeData): RemoteOperationResult =
RemoteOperationResult(RemoteOperationResult.ResultCode.OK)
- override fun getTaskList(taskType: String): List = if (giveEmptyTasks) {
+ override suspend fun getTaskList(taskType: String): List = if (giveEmptyTasks) {
listOf()
} else {
listOf(
@@ -62,6 +67,13 @@ class MockAssistantRemoteRepository(private val giveEmptyTasks: Boolean = false)
)
}
- override fun deleteTask(id: Long): RemoteOperationResult =
+ override suspend fun deleteTask(id: Long): RemoteOperationResult =
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
+ override suspend fun checkSession(sessionId: String): Session? = null
+ override suspend fun generateSession(sessionId: String): SessionTask? = null
+
+ override suspend fun checkGeneration(taskId: String, sessionId: String): ChatMessage? = null
}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/taskDetail/TaskDetailBottomSheet.kt b/app/src/main/java/com/nextcloud/client/assistant/taskDetail/TaskDetailBottomSheet.kt
index a52b1141e3f5..7b9ae673f6f4 100644
--- a/app/src/main/java/com/nextcloud/client/assistant/taskDetail/TaskDetailBottomSheet.kt
+++ b/app/src/main/java/com/nextcloud/client/assistant/taskDetail/TaskDetailBottomSheet.kt
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -39,6 +40,7 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+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
@@ -104,9 +106,11 @@ fun TaskDetailBottomSheet(task: Task, showTaskActions: () -> Unit, dismiss: () -
Spacer(modifier = Modifier.width(4.dp))
Text(
- text = stringResource(R.string.assistant_generation_warning),
+ modifier = Modifier.widthIn(max = 300.dp),
+ text = stringResource(R.string.assistant_output_generation_warning_text),
color = colorResource(R.color.text_color),
- fontSize = 12.sp
+ fontSize = 11.sp,
+ textAlign = TextAlign.Center
)
}
}
diff --git a/app/src/main/java/com/nextcloud/client/assistant/taskTypes/TaskTypesRow.kt b/app/src/main/java/com/nextcloud/client/assistant/taskTypes/TaskTypesRow.kt
index 3e0e9ad05d5f..f1af94510c3e 100644
--- a/app/src/main/java/com/nextcloud/client/assistant/taskTypes/TaskTypesRow.kt
+++ b/app/src/main/java/com/nextcloud/client/assistant/taskTypes/TaskTypesRow.kt
@@ -8,6 +8,13 @@
package com.nextcloud.client.assistant.taskTypes
import android.annotation.SuppressLint
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
@@ -15,6 +22,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.owncloud.android.R
@@ -22,22 +30,48 @@ import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
@SuppressLint("ResourceType")
@Composable
-fun TaskTypesRow(selectedTaskType: TaskTypeData?, data: List, selectTaskType: (TaskTypeData) -> Unit) {
+fun TaskTypesRow(
+ selectedTaskType: TaskTypeData?,
+ data: List,
+ selectTaskType: (TaskTypeData) -> Unit,
+ navigateToConversationList: () -> Unit
+) {
+ if (data.isEmpty()) {
+ return
+ }
+
val selectedTabIndex = data.indexOfFirst { it.id == selectedTaskType?.id }.takeIf { it >= 0 } ?: 0
- PrimaryScrollableTabRow(
- selectedTabIndex = selectedTabIndex,
- edgePadding = 0.dp,
- containerColor = colorResource(R.color.actionbar_color),
- indicator = {
- TabRowDefaults.SecondaryIndicator(
- Modifier.tabIndicatorOffset(selectedTabIndex),
- color = colorResource(R.color.primary)
- )
- }
+ Row(
+ modifier = Modifier.background(color = colorResource(R.color.actionbar_color)),
+ horizontalArrangement = Arrangement.Center
) {
- data.forEach { taskType ->
- if (taskType.name.isNotEmpty()) {
+ if (data.any { it.isChat() }) {
+ Spacer(modifier = Modifier.width(11.dp))
+
+ IconButton(
+ onClick = { navigateToConversationList() }
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_history_back_arrow),
+ contentDescription = "open conversation list button",
+ tint = colorResource(R.color.text_color)
+ )
+ }
+ }
+
+ PrimaryScrollableTabRow(
+ selectedTabIndex = selectedTabIndex,
+ edgePadding = 0.dp,
+ containerColor = colorResource(R.color.actionbar_color),
+ indicator = {
+ TabRowDefaults.SecondaryIndicator(
+ Modifier.tabIndicatorOffset(selectedTabIndex),
+ color = colorResource(R.color.primary)
+ )
+ }
+ ) {
+ data.forEach { taskType ->
Tab(
selected = selectedTaskType?.id == taskType.id,
onClick = { selectTaskType(taskType) },
@@ -61,5 +95,7 @@ private fun TaskTypesRowPreview() {
TaskTypeData("4", "Summarize", "", emptyMap(), emptyMap())
)
- TaskTypesRow(selectedTaskType, taskTypes) { }
+ TaskTypesRow(selectedTaskType, taskTypes, {
+ }, {
+ })
}
diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt
index 61cd04ec7e17..27b295bac9c3 100644
--- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt
+++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt
@@ -12,17 +12,19 @@ import android.view.MenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.nextcloud.client.assistant.AssistantScreen
import com.nextcloud.client.assistant.AssistantViewModel
+import com.nextcloud.client.assistant.conversation.ConversationViewModel
+import com.nextcloud.client.assistant.conversation.repository.ConversationRemoteRepositoryImpl
import com.nextcloud.client.assistant.repository.local.AssistantLocalRepositoryImpl
import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositoryImpl
import com.nextcloud.client.database.NextcloudDatabase
import com.nextcloud.common.NextcloudClient
-import com.nextcloud.utils.extensions.getSerializableArgument
import com.owncloud.android.R
import com.owncloud.android.databinding.ActivityComposeBinding
import com.owncloud.android.ui.activity.DrawerActivity
@@ -41,7 +43,7 @@ class ComposeActivity : DrawerActivity() {
binding = ActivityComposeBinding.inflate(layoutInflater)
setContentView(binding.root)
- val destination = intent.getSerializableArgument(DESTINATION, ComposeDestination::class.java)
+ val destinationId = intent.getIntExtra(DESTINATION, -1)
val titleId = intent.getIntExtra(TITLE, R.string.empty)
setupDrawer()
@@ -54,7 +56,7 @@ class ComposeActivity : DrawerActivity() {
MaterialTheme(
colorScheme = viewThemeUtils.getColorScheme(this),
content = {
- Content(destination)
+ Content(ComposeDestination.fromId(destinationId))
}
)
}
@@ -69,31 +71,40 @@ class ComposeActivity : DrawerActivity() {
}
@Composable
- private fun Content(destination: ComposeDestination?) {
+ private fun Content(destination: ComposeDestination) {
+ val currentScreen by ComposeNavigation.currentScreen.collectAsState()
var nextcloudClient by remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
+ ComposeNavigation.navigate(destination)
nextcloudClient = clientRepository.getNextcloudClient()
}
- if (destination == ComposeDestination.AssistantScreen) {
- binding.bottomNavigation.menu.findItem(R.id.nav_assistant).run {
- isChecked = true
- }
+ binding.bottomNavigation.menu.findItem(R.id.nav_assistant).run {
+ isChecked = true
+ }
- val dao = NextcloudDatabase.instance().assistantDao()
+ when (currentScreen) {
+ is ComposeDestination.AssistantScreen -> {
+ val dao = NextcloudDatabase.instance().assistantDao()
+ val sessionId = (currentScreen as? ComposeDestination.AssistantScreen)?.sessionId
+ val client = nextcloudClient ?: return
- nextcloudClient?.let { client ->
AssistantScreen(
viewModel = AssistantViewModel(
accountName = userAccountManager.user.accountName,
remoteRepository = AssistantRemoteRepositoryImpl(client, capabilities),
- localRepository = AssistantLocalRepositoryImpl(dao)
+ localRepository = AssistantLocalRepositoryImpl(dao),
+ sessionIdArg = sessionId
+ ),
+ conversationViewModel = ConversationViewModel(
+ remoteRepository = ConversationRemoteRepositoryImpl(client)
),
activity = this,
capability = capabilities
)
}
+ else -> Unit
}
}
}
diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt
index 10e80ad4ca7f..050c8e5a2848 100644
--- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt
+++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt
@@ -7,8 +7,13 @@
*/
package com.nextcloud.ui.composeActivity
-import java.io.Serializable
+sealed class ComposeDestination(val id: Int) {
+ data class AssistantScreen(val sessionId: Long?) : ComposeDestination(0)
-enum class ComposeDestination : Serializable {
- AssistantScreen
+ companion object {
+ fun fromId(id: Int): ComposeDestination = when (id) {
+ 0 -> AssistantScreen(null)
+ else -> throw IllegalArgumentException("Unknown destination: $id")
+ }
+ }
}
diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt
new file mode 100644
index 000000000000..453bb439b8f2
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt
@@ -0,0 +1,18 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.ui.composeActivity
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+
+object ComposeNavigation {
+ private var root: MutableStateFlow = MutableStateFlow(null)
+ val currentScreen: StateFlow = root
+ fun navigate(value: ComposeDestination) = root.update { value }
+}
diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ChatMessageExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ChatMessageExtensions.kt
new file mode 100644
index 000000000000..fae3a9d6f7a0
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/utils/extensions/ChatMessageExtensions.kt
@@ -0,0 +1,31 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.utils.extensions
+
+import com.owncloud.android.lib.resources.assistant.chat.model.ChatMessage
+import java.time.Instant
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import kotlin.time.ExperimentalTime
+
+fun ChatMessage.isHuman(): Boolean = (role == "human")
+
+@OptIn(ExperimentalTime::class)
+fun ChatMessage.time(): String {
+ val messageDate = Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()).toLocalDate()
+ val today = LocalDate.now(ZoneId.systemDefault())
+
+ val pattern = if (messageDate == today) "HH:mm" else "dd.MM.yyyy - HH:mm"
+
+ val formatter = DateTimeFormatter.ofPattern(pattern, Locale.getDefault())
+ val messageTime = Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()).toLocalDateTime()
+
+ return formatter.format(messageTime)
+}
diff --git a/app/src/main/java/com/nextcloud/utils/extensions/TaskTypeExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/TaskTypeExtensions.kt
new file mode 100644
index 000000000000..5fca6d0ab98e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/utils/extensions/TaskTypeExtensions.kt
@@ -0,0 +1,12 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.utils.extensions
+
+import com.owncloud.android.lib.resources.assistant.v2.model.TaskTypeData
+
+fun List?.getChat(): TaskTypeData? = this?.find { it.isChat() }
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
index c2e16c0630b7..5b72371a8922 100644
--- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
@@ -282,7 +282,7 @@ private void handleBottomNavigationViewClicks() {
setupToolbar();
handleSearchEvents(new SearchEvent("", SearchRemoteOperation.SearchType.FAVORITE_SEARCH), menuItemId);
} else if (menuItemId == R.id.nav_assistant && !(this instanceof ComposeActivity)) {
- startComposeActivity(ComposeDestination.AssistantScreen, R.string.assistant_screen_top_bar_title);
+ startComposeActivity(new ComposeDestination.AssistantScreen(null), R.string.assistant_screen_top_bar_title);
} else if (menuItemId == R.id.nav_gallery) {
setupToolbar();
startPhotoSearch(menuItem.getItemId());
@@ -470,7 +470,7 @@ private void showTopBanner(ConstraintLayout banner) {
moreView.setOnClickListener(v -> LinkHelper.INSTANCE.openAppStore("Nextcloud", true, this));
assistantView.setOnClickListener(v -> {
DrawerActivity.menuItemId = Menu.NONE;
- startComposeActivity(ComposeDestination.AssistantScreen, R.string.assistant_screen_top_bar_title);
+ startComposeActivity(new ComposeDestination.AssistantScreen(null), R.string.assistant_screen_top_bar_title);
});
if (getCapabilities() != null && getCapabilities().getAssistant().isTrue()) {
assistantView.setVisibility(View.VISIBLE);
@@ -632,7 +632,7 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
startRecentlyModifiedSearch(menuItem);
} else if (itemId == R.id.nav_assistant) {
resetOnlyPersonalAndOnDevice();
- startComposeActivity(ComposeDestination.AssistantScreen, R.string.assistant_screen_top_bar_title);
+ startComposeActivity(new ComposeDestination.AssistantScreen(null), R.string.assistant_screen_top_bar_title);
} else if (itemId == R.id.nav_groupfolders) {
resetOnlyPersonalAndOnDevice();
Intent intent = new Intent(getApplicationContext(), FileDisplayActivity.class);
@@ -652,7 +652,7 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
private void startComposeActivity(ComposeDestination destination, int titleId) {
Intent composeActivity = new Intent(getApplicationContext(), ComposeActivity.class);
- composeActivity.putExtra(ComposeActivity.DESTINATION, destination);
+ composeActivity.putExtra(ComposeActivity.DESTINATION, destination.getId());
composeActivity.putExtra(ComposeActivity.TITLE, titleId);
startActivity(composeActivity);
}
diff --git a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt
index b69e94d5a1ff..a6e35ee36019 100644
--- a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt
+++ b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt
@@ -161,6 +161,7 @@ object PermissionUtil {
}
// region Storage permission checks
+
/**
* Checks if the application has storage/media access permissions.
*
diff --git a/app/src/main/res/drawable/ic_history_back_arrow.xml b/app/src/main/res/drawable/ic_history_back_arrow.xml
new file mode 100644
index 000000000000..5299d0ef2a4e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_history_back_arrow.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_list_item_header_shimmer.xml b/app/src/main/res/layout/activity_list_item_header_shimmer.xml
deleted file mode 100644
index fdadb983ad0b..000000000000
--- a/app/src/main/res/layout/activity_list_item_header_shimmer.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
diff --git a/app/src/main/res/layout/file_details_share_group.xml b/app/src/main/res/layout/file_details_share_group.xml
deleted file mode 100644
index 075e5ba65cab..000000000000
--- a/app/src/main/res/layout/file_details_share_group.xml
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_compose_view.xml b/app/src/main/res/layout/fragment_compose_view.xml
deleted file mode 100644
index 0e8d25ea1819..000000000000
--- a/app/src/main/res/layout/fragment_compose_view.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/note_dialog.xml b/app/src/main/res/layout/note_dialog.xml
deleted file mode 100644
index 6fac84886bf6..000000000000
--- a/app/src/main/res/layout/note_dialog.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/search_users_groups_layout.xml b/app/src/main/res/layout/search_users_groups_layout.xml
deleted file mode 100644
index f0b30be55872..000000000000
--- a/app/src/main/res/layout/search_users_groups_layout.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml
index 1394d2559fda..b4f8d33b8ebb 100644
--- a/app/src/main/res/values/dims.xml
+++ b/app/src/main/res/values/dims.xml
@@ -91,8 +91,6 @@
40dp
240dp
16sp
- 200dp
- 20dp
12sp
20dp
60dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 86d539bf531c..9281e6f74d74 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -54,31 +54,39 @@
%1$d / %2$d - %3$s
AnonymousAccountType
- Unable to fetch task types, please check your internet connection.
- Unable to fetch task list, please check your internet connection.
- Task list is empty. Check assistant app configuration.
-
+
Assistant
- Loading task list…
The task output is not ready yet.
- Create a new task from bottom right
+ Failed to send a message
+ Failed to fetch chat messages
+ Unable to fetch task list, please check your internet connection.
+ Task list is empty. Check assistant app configuration.
Delete task
Are you sure you want to delete this task?
- Add new task
-
Delete Task
-
Task successfully created
An error occurred while creating the task
-
Task successfully deleted
An error occurred while deleting the task
-
- Type some text
-
Input
Output
- This content was generated by AI and can make mistakes.
+
+
+ Conversations
+ No conversations yet
+ Failed to fetch conversation list
+ No conversations found
+ Failed to create conversation
+ Failed to delete conversation
+ Delete conversation
+
+
+
+ Output shown here is generated by AI. Make sure to always double-check.
+ Thinking...
+ Hello there! What can I help you with today?
+ Try sending a message to spark a conversation.
+
Recommended files
@@ -484,7 +492,6 @@
Firstname Lastname
Filetype
Download
- Internal share link only works for users with access to this folder
Internal share link only works for users with access to this folder
All 12 words together make a very strong password, letting only you view and make use of your encrypted files. Please write it down and keep it somewhere safe.
No internet connection
@@ -708,7 +715,6 @@
Share link
Send link
Set password
- Share with…
Unset
Name, Federated Cloud ID or email address…
@@ -727,7 +733,6 @@
Settings, database and server certificates from %1$s\'s data will be deleted permanently. \n\nDownloaded files will be kept untouched.\n\nThis process can take a while.
Clear data
- Additional permissions required to upload and download files.
File not found in local file system
Do you really want to delete the selected items?
Do you really want to delete the selected items and their contents?
@@ -1157,7 +1162,6 @@
Failed to start editor
Add folder description
Adds folder description
- Retry to upload failed local files
We couldnt locate the file on server. Another user may have deleted the file
File not found. Are you sure that this file exists or has a previous conflict not been resolved?
File upload conflict
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dee97d166595..4f9f40608808 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,7 +5,7 @@
androidCommonLibraryVersion = "0.29.0"
androidGifDrawableVersion = "1.2.29"
androidImageCropperVersion = "4.6.0"
-androidLibraryVersion = "8c77f600ac942f6eb4e3c4447143fc949d34acc6"
+androidLibraryVersion = "b8f77935157e44c1d7a71f81271b412b0dbe8c76"
androidPluginVersion = '8.13.1'
androidsvgVersion = "1.4"
androidxMediaVersion = "1.5.1"
@@ -84,6 +84,7 @@ spotless = "8.1.0"
stateless4jVersion = "2.6.0"
webkitVersion = "1.14.0"
workRuntime = "2.10.4"
+foundationVersion = "1.9.4"
[libraries]
# Crypto
@@ -236,6 +237,7 @@ qrcodescanner = { module = "com.github.nextcloud-deps:qrcodescanner", version.re
# Worker
work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" }
work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" }
+foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" }
[bundles]
media3 = ["media3-ui", "media3-session", "media3-exoplayer", "media3-datasource"]
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index ea2799bc44af..30ef7812b678 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -2,7 +2,7 @@
@@ -17,6 +17,10 @@
+
+
+
+
@@ -408,6 +412,7 @@
+
@@ -18114,6 +18119,14 @@
+
+
+
+
+
+
+
+
@@ -18202,6 +18215,14 @@
+
+
+
+
+
+
+
+
@@ -18210,6 +18231,14 @@
+
+
+
+
+
+
+
+
@@ -21835,6 +21864,11 @@
+
+
+
+
+
@@ -21935,6 +21969,14 @@
+
+
+
+
+
+
+
+
@@ -22469,6 +22511,14 @@
+
+
+
+
+
+
+
+