From 1ed9ff96dfae07c5deb8dd19e77c0f78d40a6db7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 12 Nov 2026 12:37:51 +0100 Subject: [PATCH 01/17] add teams navigation Signed-off-by: alperozturk96 --- .../drawer/header/EcosystemAppsBanner.kt | 3 +- app/src/main/res/layout/drawer_header.xml | 41 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/drawer/header/EcosystemAppsBanner.kt b/app/src/main/java/com/owncloud/android/ui/activity/drawer/header/EcosystemAppsBanner.kt index 2e36c0ea8e99..da4c334131fd 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/drawer/header/EcosystemAppsBanner.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/drawer/header/EcosystemAppsBanner.kt @@ -43,6 +43,7 @@ class EcosystemAppsBanner( val talkView = banner.findViewById(R.id.drawer_ecosystem_talk) val moreView = banner.findViewById(R.id.drawer_ecosystem_more) val assistantView = banner.findViewById(R.id.drawer_ecosystem_assistant) + val teamsView = banner.findViewById(R.id.drawer_ecosystem_teams) activity.user.getOrNull()?.accountName?.let { accountName -> notesView.setOnClickListener { activity.ecosystemManager.openApp(EcosystemApp.NOTES, accountName) } @@ -53,7 +54,7 @@ class EcosystemAppsBanner( assistantView.isVisible = activity.isAssistantAvailable val color = iconColor - listOf(notesView, talkView, moreView, assistantView).forEach { tintAppView(it, color) } + listOf(notesView, talkView, moreView, assistantView, teamsView).forEach { tintAppView(it, color) } banner.visibility = View.VISIBLE } diff --git a/app/src/main/res/layout/drawer_header.xml b/app/src/main/res/layout/drawer_header.xml index 88f7ade88c4a..2d3c1b5af00a 100644 --- a/app/src/main/res/layout/drawer_header.xml +++ b/app/src/main/res/layout/drawer_header.xml @@ -141,7 +141,7 @@ android:focusable="true" android:gravity="center_horizontal" android:orientation="vertical" - app:layout_constraintEnd_toStartOf="@+id/drawer_ecosystem_more" + app:layout_constraintEnd_toStartOf="@+id/drawer_ecosystem_teams" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toEndOf="@+id/drawer_ecosystem_talk" app:layout_constraintTop_toTopOf="parent" @@ -169,6 +169,43 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3d6f60bf3e1..b697072384ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1481,6 +1481,7 @@ Notes Talk More + Teams Details %s mm ƒ/%s From 3eb9428a6cce60b6aa775471c2e5609e8fc8c6b5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 12 Nov 2026 13:01:41 +0100 Subject: [PATCH 02/17] add team overview screen Signed-off-by: alperozturk96 --- app/build.gradle.kts | 1 + .../java/com/nextcloud/client/di/AppModule.kt | 5 + .../client/di/SavedStateViewModelCreator.kt | 14 ++ .../nextcloud/client/di/ViewModelFactory.kt | 12 +- .../nextcloud/client/di/ViewModelModule.kt | 9 + .../ui/composeActivity/ComposeActivity.kt | 74 +++++--- .../ui/composeActivity/ComposeDestination.kt | 5 + .../ui/composeActivity/ComposeNavigation.kt | 18 -- .../com/nextcloud/ui/teams/TeamsScreen.kt | 170 ++++++++++++++++++ .../ui/teams/component/CreateTeamDialog.kt | 41 +++++ .../nextcloud/ui/teams/model/TeamsUiState.kt | 17 ++ .../teams/repository/TeamsRemoteRepository.kt | 31 ++++ .../ui/teams/repository/TeamsRepository.kt | 15 ++ .../ui/teams/viewmodel/TeamsViewModel.kt | 101 +++++++++++ .../drawer/header/EcosystemAppsBanner.kt | 1 + .../drawer/navigation/DrawerNavigator.kt | 6 + app/src/main/res/values/strings.xml | 11 ++ gradle.properties | 5 +- gradle/libs.versions.toml | 1 + settings.gradle.kts | 6 +- 20 files changed, 491 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/di/SavedStateViewModelCreator.kt delete mode 100644 app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRepository.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8ad94b13f8e8..d95ccd45ec18 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -405,6 +405,7 @@ dependencies { implementation(libs.lifecycle.process) implementation(libs.lifecycle.service) implementation(libs.lifecycle.runtime.ktx) + implementation(libs.lifecycle.runtime.compose) // endregion // region JUnit diff --git a/app/src/main/java/com/nextcloud/client/di/AppModule.kt b/app/src/main/java/com/nextcloud/client/di/AppModule.kt index 8154074c608d..615410fc72c3 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppModule.kt @@ -44,6 +44,8 @@ import com.nextcloud.client.notifications.AppNotificationManagerImpl import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.repository.ClientRepository import com.nextcloud.repository.RemoteClientRepository +import com.nextcloud.ui.teams.repository.TeamsRemoteRepository +import com.nextcloud.ui.teams.repository.TeamsRepository import com.nextcloud.utils.e2ee.E2EEActionResolver import com.nextcloud.utils.e2ee.E2EEKeyInspector import com.nextcloud.utils.thumbnail.FolderThumbnailGenerator @@ -119,6 +121,9 @@ internal class AppModule { @Provides fun clientRepository(repository: RemoteClientRepository): ClientRepository = repository + @Provides + fun teamsRepository(repository: TeamsRemoteRepository): TeamsRepository = repository + @Provides fun fileUploadHelper(): FileUploadHelper = FileUploadHelper.instance() diff --git a/app/src/main/java/com/nextcloud/client/di/SavedStateViewModelCreator.kt b/app/src/main/java/com/nextcloud/client/di/SavedStateViewModelCreator.kt new file mode 100644 index 000000000000..d4170bcc42e5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/di/SavedStateViewModelCreator.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.di + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel + +fun interface SavedStateViewModelCreator { + fun create(savedStateHandle: SavedStateHandle): ViewModel +} diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelFactory.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelFactory.kt index 08041ae60feb..ad40629affd6 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelFactory.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelFactory.kt @@ -8,6 +8,8 @@ package com.nextcloud.client.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras import javax.inject.Inject import javax.inject.Provider @@ -21,9 +23,17 @@ import javax.inject.Provider * @see ViewModelKey */ class ViewModelFactory @Inject constructor( - private val viewModelProviders: Map, @JvmSuppressWildcards Provider> + private val viewModelProviders: Map, @JvmSuppressWildcards Provider>, + private val savedStateViewModelCreators: Map, @JvmSuppressWildcards SavedStateViewModelCreator> ) : ViewModelProvider.Factory { + override fun create(modelClass: Class, extras: CreationExtras): T { + val creator = savedStateViewModelCreators[modelClass] ?: return create(modelClass) + + @Suppress("UNCHECKED_CAST") + return creator.create(extras.createSavedStateHandle()) as T + } + override fun create(modelClass: Class): T { var vmProvider: Provider? = viewModelProviders[modelClass] diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index 0dc9da9cb15f..5936abb04201 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -16,6 +16,7 @@ import com.nextcloud.client.logger.ui.LogsViewModel import com.nextcloud.client.player.ui.audio.AudioPlayerViewModel import com.nextcloud.ui.fileactions.FileActionsViewModel import com.nextcloud.ui.tags.TagManagementViewModel +import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel import com.owncloud.android.ui.activity.filedisplayactivity.FileDisplayActivityViewModel import com.owncloud.android.ui.fragment.filedetail.FileDetailFragmentViewModel @@ -23,6 +24,7 @@ import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel import dagger.Binds import dagger.Module +import dagger.Provides import dagger.multibindings.IntoMap @Module @@ -84,4 +86,11 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(FileDetailFragmentViewModel::class) abstract fun fileDetailFragmentViewModel(vm: FileDetailFragmentViewModel): ViewModel + + companion object { + @Provides + @IntoMap + @ViewModelKey(TeamsViewModel::class) + fun teamsViewModel(factory: TeamsViewModel.Factory) = SavedStateViewModelCreator(factory::create) + } } 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 3251a30cb908..14b73d39cd70 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt @@ -9,17 +9,18 @@ package com.nextcloud.ui.composeActivity import android.content.Intent import android.os.Bundle +import android.view.Menu import android.view.MenuItem import android.view.View import androidx.activity.viewModels 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 androidx.lifecycle.ViewModelProvider import com.nextcloud.client.assistant.AssistantScreen import com.nextcloud.client.assistant.AssistantViewModel import com.nextcloud.client.assistant.chat.ChatViewModel @@ -28,18 +29,30 @@ import com.nextcloud.client.assistant.conversation.repository.ConversationRemote 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.client.di.ViewModelFactory import com.nextcloud.common.NextcloudClient import com.nextcloud.ui.ClientIntegrationScreen +import com.nextcloud.ui.teams.TeamsScreen +import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.nextcloud.utils.extensions.getParcelableArgument import com.owncloud.android.R import com.owncloud.android.databinding.ActivityComposeBinding import com.owncloud.android.ui.activity.drawer.DrawerActivity +import javax.inject.Inject class ComposeActivity : DrawerActivity() { + @Inject + lateinit var vmFactory: ViewModelFactory + lateinit var binding: ActivityComposeBinding private val composeViewModel: ComposeViewModel by viewModels() + private val destination: ComposeDestination by lazy { + intent.getParcelableArgument(DESTINATION, ComposeDestination::class.java) + ?: ComposeDestination.getAssistantScreen(this) + } + companion object { const val DESTINATION = "DESTINATION" } @@ -49,17 +62,13 @@ class ComposeActivity : DrawerActivity() { binding = ActivityComposeBinding.inflate(layoutInflater) setContentView(binding.root) - val destination = - intent.getParcelableArgument(DESTINATION, ComposeDestination::class.java) - ?: ComposeDestination.getAssistantScreen(this) - setupActivityUIFor(destination) binding.composeView.setContent { MaterialTheme( colorScheme = viewThemeUtils.getColorScheme(this), content = { - Content(destination) + Content() } ) } @@ -82,7 +91,7 @@ class ComposeActivity : DrawerActivity() { } override val menuItemId: Int - get() = R.id.nav_assistant + get() = if (destination is ComposeDestination.TeamsScreen) Menu.NONE else R.id.nav_assistant override fun onResume() { super.onResume() @@ -90,19 +99,25 @@ class ComposeActivity : DrawerActivity() { } private fun setupActivityUIFor(destination: ComposeDestination) { - if (destination is ComposeDestination.AssistantScreen) { - setupDrawer(menuItemId) - showMenuAndTitleActionBar(destination.title) { - openDrawer() - } - } else { - setSupportActionBar(null) - findViewById(R.id.appbar)?.let { - it.visibility = View.GONE + when (destination) { + is ComposeDestination.AssistantScreen -> setupDrawerScreen(destination.title) + is ComposeDestination.TeamsScreen -> setupDrawerScreen(destination.title) + is ComposeDestination.ClientIntegrationScreen -> { + setSupportActionBar(null) + findViewById(R.id.appbar)?.let { + it.visibility = View.GONE + } } } } + private fun setupDrawerScreen(title: String) { + setupDrawer(menuItemId) + showMenuAndTitleActionBar(title) { + openDrawer() + } + } + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { toggleDrawer() @@ -113,23 +128,20 @@ class ComposeActivity : DrawerActivity() { } @Composable - private fun Content(destination: ComposeDestination) { - val currentScreen by ComposeNavigation.currentScreen.collectAsState() + private fun Content() { var nextcloudClient by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - ComposeNavigation.navigate(destination) nextcloudClient = clientRepository.getNextcloudClient() } - binding.bottomNavigation.menu.findItem(R.id.nav_assistant).run { - isChecked = true - } - - when (currentScreen) { + when (val screen = destination) { is ComposeDestination.AssistantScreen -> { + binding.bottomNavigation.menu.findItem(R.id.nav_assistant).run { + isChecked = true + } + val dao = NextcloudDatabase.instance().assistantDao() - val sessionId = (currentScreen as? ComposeDestination.AssistantScreen)?.sessionId val client = nextcloudClient ?: return val optionalCapability = capabilities if (optionalCapability.isEmpty) { @@ -144,7 +156,7 @@ class ComposeActivity : DrawerActivity() { accountName = userAccountManager.user.accountName, remoteRepository = remoteRepository, localRepository = AssistantLocalRepositoryImpl(dao), - sessionIdArg = sessionId + sessionIdArg = screen.sessionId ), chatViewModel = ChatViewModel(remoteRepository), conversationViewModel = ConversationViewModel( @@ -157,11 +169,15 @@ class ComposeActivity : DrawerActivity() { is ComposeDestination.ClientIntegrationScreen -> { binding.bottomNavigation.visibility = View.GONE - val integrationScreen = (currentScreen as ComposeDestination.ClientIntegrationScreen) - ClientIntegrationScreen(integrationScreen.data, nextcloudClient?.baseUri.toString()) + ClientIntegrationScreen(screen.data, nextcloudClient?.baseUri.toString()) } - else -> Unit + is ComposeDestination.TeamsScreen -> { + val viewModel = remember { + ViewModelProvider(this, vmFactory)[TeamsViewModel::class.java] + } + TeamsScreen(viewModel) + } } } } 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 6564d61cd515..9db19c286495 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt @@ -21,7 +21,12 @@ sealed class ComposeDestination(val id: Int) : Parcelable { @Parcelize data class ClientIntegrationScreen(val title: String, val data: ClientIntegrationUI) : ComposeDestination(1) + @Parcelize + data class TeamsScreen(val title: String) : ComposeDestination(2) + companion object { + fun getTeamsScreen(context: Context): TeamsScreen = TeamsScreen(context.getString(R.string.navigation_teams)) + /** * Creates a assistant screen without selected chat */ diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt deleted file mode 100644 index 453bb439b8f2..000000000000 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeNavigation.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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/ui/teams/TeamsScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt new file mode 100644 index 000000000000..1ddacf52dcbf --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt @@ -0,0 +1,170 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nextcloud.android.lib.resources.teams.model.Team +import com.nextcloud.ui.teams.component.CreateTeamDialog +import com.nextcloud.ui.teams.model.TeamsUiState +import com.nextcloud.ui.teams.viewmodel.TeamsViewModel +import com.owncloud.android.R + +@Composable +fun TeamsScreen(viewModel: TeamsViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val messageId by viewModel.messageId.collectAsStateWithLifecycle() + val createTeamName by viewModel.createTeamName.collectAsStateWithLifecycle() + val resources = LocalResources.current + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(messageId) { + val id = messageId ?: return@LaunchedEffect + snackbarHostState.showSnackbar(resources.getString(id)) + viewModel.onMessageShown() + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.teams_create)) }, + icon = { Icon(painterResource(R.drawable.ic_plus), contentDescription = null) }, + onClick = viewModel::openCreateTeamDialog + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + when (val state = uiState) { + TeamsUiState.Loading -> CircularProgressIndicator() + TeamsUiState.Error -> TeamsError(onRetry = viewModel::retry) + is TeamsUiState.Content -> TeamsContent(state) + } + } + } + + createTeamName?.let { name -> + CreateTeamDialog( + name = name, + onNameChange = viewModel::updateCreateTeamName, + onDismiss = viewModel::dismissCreateTeamDialog, + onCreate = viewModel::createTeam + ) + } +} + +@Composable +private fun TeamsContent(state: TeamsUiState.Content) { + if (state.myTeams.isEmpty() && state.otherTeams.isEmpty()) { + Text( + text = stringResource(R.string.teams_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + return + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + teamSection(R.string.teams_my_teams, state.myTeams) + teamSection(R.string.teams_other_teams, state.otherTeams) + } +} + +private fun LazyListScope.teamSection(@StringRes titleId: Int, teams: List) { + if (teams.isEmpty()) { + return + } + + item(key = titleId) { SectionHeader(titleId) } + items(teams, key = { it.id }) { TeamListItem(it) } +} + +@Composable +private fun SectionHeader(@StringRes titleId: Int) { + Text( + text = stringResource(titleId), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding( + start = dimensionResource(R.dimen.standard_padding), + end = dimensionResource(R.dimen.standard_padding), + top = dimensionResource(R.dimen.standard_padding), + bottom = dimensionResource(R.dimen.standard_half_padding) + ) + ) +} + +@Composable +private fun TeamListItem(team: Team) { + ListItem( + headlineContent = { + Text(text = team.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Text(pluralStringResource(R.plurals.teams_member_count, team.population, team.population)) + }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_circles), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) +} + +@Composable +private fun TeamsError(onRetry: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) + ) { + Text( + text = stringResource(R.string.teams_load_failed), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt new file mode 100644 index 000000000000..f67a55b37729 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.owncloud.android.R + +@Composable +fun CreateTeamDialog(name: String, onNameChange: (String) -> Unit, onDismiss: () -> Unit, onCreate: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.teams_create)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text(stringResource(R.string.teams_name_hint)) }, + singleLine = true + ) + }, + confirmButton = { + TextButton(onClick = onCreate, enabled = name.isNotBlank()) { + Text(stringResource(R.string.create)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + ) +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt b/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt new file mode 100644 index 000000000000..0882320556f3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.model + +import com.nextcloud.android.lib.resources.teams.model.Team + +sealed interface TeamsUiState { + data object Loading : TeamsUiState + + data object Error : TeamsUiState + + data class Content(val myTeams: List, val otherTeams: List) : TeamsUiState +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt new file mode 100644 index 000000000000..5a979ee85a4a --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.repository + +import com.nextcloud.android.lib.resources.teams.get.team.GetTeamsRemoteOperation +import com.nextcloud.android.lib.resources.teams.model.Team +import com.nextcloud.android.lib.resources.teams.post.team.CreateTeamRemoteOperation +import com.nextcloud.repository.ClientRepository +import com.owncloud.android.lib.common.operations.RemoteOperation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class TeamsRemoteRepository @Inject constructor(private val clientRepository: ClientRepository) : TeamsRepository { + + override suspend fun getTeams(): List? = execute(GetTeamsRemoteOperation()) + + override suspend fun createTeam(name: String): Team? = execute(CreateTeamRemoteOperation(name)) + + private suspend fun execute(operation: RemoteOperation): T? { + val client = clientRepository.getNextcloudClient() ?: return null + return withContext(Dispatchers.IO) { + val result = operation.execute(client) + if (result.isSuccess) result.resultData else null + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRepository.kt new file mode 100644 index 000000000000..dc1decc30bea --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRepository.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.repository + +import com.nextcloud.android.lib.resources.teams.model.Team + +interface TeamsRepository { + suspend fun getTeams(): List? + + suspend fun createTeam(name: String): Team? +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt new file mode 100644 index 000000000000..a545c2250782 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt @@ -0,0 +1,101 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.viewmodel + +import androidx.annotation.StringRes +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.ui.teams.model.TeamsUiState +import com.nextcloud.ui.teams.repository.TeamsRepository +import com.owncloud.android.R +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class TeamsViewModel @AssistedInject constructor( + private val repository: TeamsRepository, + @Assisted private val savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val _uiState = MutableStateFlow(TeamsUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _messageId = MutableStateFlow(null) + val messageId: StateFlow = _messageId.asStateFlow() + + val createTeamName: StateFlow = savedStateHandle.getStateFlow(KEY_CREATE_TEAM_NAME, null) + + init { + viewModelScope.launch { loadTeams() } + } + + fun retry() { + _uiState.value = TeamsUiState.Loading + viewModelScope.launch { loadTeams() } + } + + fun openCreateTeamDialog() { + savedStateHandle[KEY_CREATE_TEAM_NAME] = "" + } + + fun updateCreateTeamName(name: String) { + savedStateHandle[KEY_CREATE_TEAM_NAME] = name + } + + fun dismissCreateTeamDialog() { + savedStateHandle[KEY_CREATE_TEAM_NAME] = null + } + + fun createTeam() { + val name = createTeamName.value?.trim() + if (name.isNullOrEmpty()) { + return + } + + dismissCreateTeamDialog() + viewModelScope.launch { + if (repository.createTeam(name) == null) { + showMessage(R.string.teams_create_failed) + return@launch + } + loadTeams() + } + } + + fun onMessageShown() { + _messageId.value = null + } + + private suspend fun loadTeams() { + val teams = repository.getTeams() + if (teams == null) { + _uiState.value = TeamsUiState.Error + return + } + + val (myTeams, otherTeams) = teams.partition { it.isMember } + _uiState.value = TeamsUiState.Content(myTeams, otherTeams) + } + + private fun showMessage(@StringRes id: Int) { + _messageId.value = id + } + + @AssistedFactory + interface Factory { + fun create(savedStateHandle: SavedStateHandle): TeamsViewModel + } + + companion object { + private const val KEY_CREATE_TEAM_NAME = "create_team_name" + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/drawer/header/EcosystemAppsBanner.kt b/app/src/main/java/com/owncloud/android/ui/activity/drawer/header/EcosystemAppsBanner.kt index da4c334131fd..ebfceb896810 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/drawer/header/EcosystemAppsBanner.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/drawer/header/EcosystemAppsBanner.kt @@ -52,6 +52,7 @@ class EcosystemAppsBanner( moreView.setOnClickListener { LinkHelper.openAppStore(APP_STORE_QUERY, true, activity) } assistantView.setOnClickListener { navigator.startAssistantScreen() } assistantView.isVisible = activity.isAssistantAvailable + teamsView.setOnClickListener { navigator.startTeamsScreen() } val color = iconColor listOf(notesView, talkView, moreView, assistantView, teamsView).forEach { tintAppView(it, color) } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerNavigator.kt b/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerNavigator.kt index 3e4eda7df728..739f6cf8a146 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerNavigator.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerNavigator.kt @@ -142,6 +142,12 @@ class DrawerNavigator(private val activity: DrawerActivity) { activity.startActivity(intent) } + fun startTeamsScreen() { + val intent = Intent(activity.applicationContext, ComposeActivity::class.java) + .putExtra(ComposeActivity.DESTINATION, ComposeDestination.getTeamsScreen(activity)) + activity.startActivity(intent) + } + fun resetOnlyPersonalAndOnDevice() { MainApp.showOnlyFilesOnDevice(false) MainApp.showOnlyPersonalFiles(false) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b697072384ba..eac134d3fb84 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1482,6 +1482,17 @@ Talk More Teams + My teams + Other teams + Create team + Team name + No teams yet + Could not load teams + Could not create team + + %d member + %d members + Details %s mm ƒ/%s diff --git a/gradle.properties b/gradle.properties index 91f3dedc1c10..26e1baf5b5cf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,8 +23,9 @@ org.gradle.configuration-cache=true org.gradle.workers.max=4 # Needed for local libs -# org.gradle.dependency.verification=off -# systemProp.org.gradle.internal.publish.checksums.insecure=true + +org.gradle.dependency.verification=off +systemProp.org.gradle.internal.publish.checksums.insecure=true # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32ddcc810ef1..31b10131d7eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -164,6 +164,7 @@ glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } # Lifecycle lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleViewmodelKtxVersion" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewmodelKtxVersion" } +lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtxVersion" } lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycleViewmodelKtxVersion" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtxVersion" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 26e4b55708b4..6bc259761eec 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,14 +43,16 @@ dependencyResolutionManagement { /* Needed for local android library + +*/ + + includeBuild("../android-library") { dependencySubstitution { substitute(module("com.github.nextcloud:android-library")) .using(project(":library")) } } -*/ - /* Needed for local android common library From e0c5c3fcd8e2503208e0b0f9fa4ddae6090d0907 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 12 Nov 2026 13:18:22 +0100 Subject: [PATCH 03/17] handle errors Signed-off-by: alperozturk96 --- .../com/nextcloud/ui/teams/TeamsScreen.kt | 23 +---- .../ui/teams/component/CreateTeamDialog.kt | 93 +++++++++++++++++-- .../ui/teams/model/CreateTeamStatus.kt | 17 ++++ .../ui/teams/model/TeamsErrorMessages.kt | 30 ++++++ .../nextcloud/ui/teams/model/TeamsUiState.kt | 3 +- .../teams/repository/TeamsRemoteRepository.kt | 16 ++-- .../ui/teams/repository/TeamsRepository.kt | 5 +- .../ui/teams/viewmodel/TeamsViewModel.kt | 46 ++++----- .../RemoteOperationResultExtensions.kt | 10 ++ app/src/main/res/values/strings.xml | 5 + 10 files changed, 188 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/ui/teams/model/CreateTeamStatus.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/model/TeamsErrorMessages.kt diff --git a/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt index 1ddacf52dcbf..61f452fbdde0 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt @@ -21,17 +21,12 @@ import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -47,19 +42,10 @@ import com.owncloud.android.R @Composable fun TeamsScreen(viewModel: TeamsViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val messageId by viewModel.messageId.collectAsStateWithLifecycle() val createTeamName by viewModel.createTeamName.collectAsStateWithLifecycle() - val resources = LocalResources.current - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(messageId) { - val id = messageId ?: return@LaunchedEffect - snackbarHostState.showSnackbar(resources.getString(id)) - viewModel.onMessageShown() - } + val createTeamStatus by viewModel.createTeamStatus.collectAsStateWithLifecycle() Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, floatingActionButton = { ExtendedFloatingActionButton( text = { Text(stringResource(R.string.teams_create)) }, @@ -76,7 +62,7 @@ fun TeamsScreen(viewModel: TeamsViewModel) { ) { when (val state = uiState) { TeamsUiState.Loading -> CircularProgressIndicator() - TeamsUiState.Error -> TeamsError(onRetry = viewModel::retry) + is TeamsUiState.Error -> TeamsError(state.messageId, onRetry = viewModel::retry) is TeamsUiState.Content -> TeamsContent(state) } } @@ -85,6 +71,7 @@ fun TeamsScreen(viewModel: TeamsViewModel) { createTeamName?.let { name -> CreateTeamDialog( name = name, + status = createTeamStatus, onNameChange = viewModel::updateCreateTeamName, onDismiss = viewModel::dismissCreateTeamDialog, onCreate = viewModel::createTeam @@ -153,13 +140,13 @@ private fun TeamListItem(team: Team) { } @Composable -private fun TeamsError(onRetry: () -> Unit) { +private fun TeamsError(@StringRes messageId: Int, onRetry: () -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) ) { Text( - text = stringResource(R.string.teams_load_failed), + text = stringResource(messageId), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt index f67a55b37729..9365f16021ce 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt @@ -6,36 +6,109 @@ */ package com.nextcloud.ui.teams.component +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.ui.teams.model.CreateTeamStatus +import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.owncloud.android.R @Composable -fun CreateTeamDialog(name: String, onNameChange: (String) -> Unit, onDismiss: () -> Unit, onCreate: () -> Unit) { +fun CreateTeamDialog( + name: String, + status: CreateTeamStatus, + onNameChange: (String) -> Unit, + onDismiss: () -> Unit, + onCreate: () -> Unit +) { + val isCreating = status == CreateTeamStatus.Creating + val isNameValid = TeamsViewModel.isValidTeamName(name) + AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = { if (!isCreating) onDismiss() }, title = { Text(stringResource(R.string.teams_create)) }, text = { - OutlinedTextField( - value = name, - onValueChange = onNameChange, - label = { Text(stringResource(R.string.teams_name_hint)) }, - singleLine = true - ) + Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding))) { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text(stringResource(R.string.teams_name_hint)) }, + supportingText = { CreateTeamSupportingText(name, status, isNameValid) }, + isError = status is CreateTeamStatus.Failed, + enabled = !isCreating, + singleLine = true + ) + if (isCreating) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } }, confirmButton = { - TextButton(onClick = onCreate, enabled = name.isNotBlank()) { + TextButton(onClick = onCreate, enabled = isNameValid && !isCreating) { Text(stringResource(R.string.create)) } }, dismissButton = { - TextButton(onClick = onDismiss) { + TextButton(onClick = onDismiss, enabled = !isCreating) { Text(stringResource(R.string.common_cancel)) } } ) } + +@Composable +private fun CreateTeamSupportingText(name: String, status: CreateTeamStatus, isNameValid: Boolean) { + if (status is CreateTeamStatus.Failed) { + Text(stringResource(status.messageId)) + return + } + + if (name.isNotBlank() && !isNameValid) { + Text( + stringResource( + R.string.teams_name_length, + TeamsViewModel.MIN_TEAM_NAME_BYTES, + TeamsViewModel.MAX_TEAM_NAME_BYTES + ) + ) + } +} + +@Composable +private fun CreateTeamDialogPreview(name: String, status: CreateTeamStatus) { + MaterialTheme { + CreateTeamDialog(name = name, status = status, onNameChange = {}, onDismiss = {}, onCreate = {}) + } +} + +@Preview +@Composable +private fun CreateTeamDialogEditingPreview() { + CreateTeamDialogPreview(name = "Design team", status = CreateTeamStatus.Editing) +} + +@Preview +@Composable +private fun CreateTeamDialogCreatingPreview() { + CreateTeamDialogPreview(name = "Design team", status = CreateTeamStatus.Creating) +} + +@Preview +@Composable +private fun CreateTeamDialogFailedPreview() { + CreateTeamDialogPreview( + name = "Design team", + status = CreateTeamStatus.Failed(R.string.teams_error_create_not_allowed) + ) +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/model/CreateTeamStatus.kt b/app/src/main/java/com/nextcloud/ui/teams/model/CreateTeamStatus.kt new file mode 100644 index 000000000000..0d2dcc3ccacf --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/model/CreateTeamStatus.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.model + +import androidx.annotation.StringRes + +sealed interface CreateTeamStatus { + data object Editing : CreateTeamStatus + + data object Creating : CreateTeamStatus + + data class Failed(@StringRes val messageId: Int) : CreateTeamStatus +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/model/TeamsErrorMessages.kt b/app/src/main/java/com/nextcloud/ui/teams/model/TeamsErrorMessages.kt new file mode 100644 index 000000000000..644b651f7680 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/model/TeamsErrorMessages.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.model + +import androidx.annotation.StringRes +import com.nextcloud.utils.extensions.connectionErrorMessageId +import com.owncloud.android.R +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import java.net.HttpURLConnection + +private const val HTTP_TOO_MANY_REQUESTS = 429 + +@StringRes +fun RemoteOperationResult<*>.loadTeamsErrorMessageId(): Int = connectionErrorMessageId() ?: when (httpCode) { + HttpURLConnection.HTTP_NOT_FOUND -> R.string.teams_error_not_available + else -> R.string.teams_load_failed +} + +@StringRes +fun RemoteOperationResult<*>.createTeamErrorMessageId(): Int = connectionErrorMessageId() ?: when (httpCode) { + HttpURLConnection.HTTP_BAD_REQUEST -> R.string.teams_error_invalid_name + HttpURLConnection.HTTP_FORBIDDEN -> R.string.teams_error_create_not_allowed + HttpURLConnection.HTTP_NOT_FOUND -> R.string.teams_error_not_available + HTTP_TOO_MANY_REQUESTS -> R.string.teams_error_too_many_requests + else -> R.string.teams_create_failed +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt b/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt index 0882320556f3..6c42e1b4e361 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt @@ -6,12 +6,13 @@ */ package com.nextcloud.ui.teams.model +import androidx.annotation.StringRes import com.nextcloud.android.lib.resources.teams.model.Team sealed interface TeamsUiState { data object Loading : TeamsUiState - data object Error : TeamsUiState + data class Error(@StringRes val messageId: Int) : TeamsUiState data class Content(val myTeams: List, val otherTeams: List) : TeamsUiState } diff --git a/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt index 5a979ee85a4a..eddda917d3dd 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt @@ -11,21 +11,21 @@ import com.nextcloud.android.lib.resources.teams.model.Team import com.nextcloud.android.lib.resources.teams.post.team.CreateTeamRemoteOperation import com.nextcloud.repository.ClientRepository import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject class TeamsRemoteRepository @Inject constructor(private val clientRepository: ClientRepository) : TeamsRepository { - override suspend fun getTeams(): List? = execute(GetTeamsRemoteOperation()) + override suspend fun getTeams(): RemoteOperationResult> = execute(GetTeamsRemoteOperation()) - override suspend fun createTeam(name: String): Team? = execute(CreateTeamRemoteOperation(name)) + override suspend fun createTeam(name: String): RemoteOperationResult = + execute(CreateTeamRemoteOperation(name)) - private suspend fun execute(operation: RemoteOperation): T? { - val client = clientRepository.getNextcloudClient() ?: return null - return withContext(Dispatchers.IO) { - val result = operation.execute(client) - if (result.isSuccess) result.resultData else null - } + private suspend fun execute(operation: RemoteOperation): RemoteOperationResult { + val client = clientRepository.getNextcloudClient() ?: return RemoteOperationResult(ResultCode.ACCOUNT_NOT_FOUND) + return withContext(Dispatchers.IO) { operation.execute(client) } } } diff --git a/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRepository.kt index dc1decc30bea..6f21d5a4ce11 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRepository.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRepository.kt @@ -7,9 +7,10 @@ package com.nextcloud.ui.teams.repository import com.nextcloud.android.lib.resources.teams.model.Team +import com.owncloud.android.lib.common.operations.RemoteOperationResult interface TeamsRepository { - suspend fun getTeams(): List? + suspend fun getTeams(): RemoteOperationResult> - suspend fun createTeam(name: String): Team? + suspend fun createTeam(name: String): RemoteOperationResult } diff --git a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt index a545c2250782..d4f994f42680 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt @@ -6,13 +6,14 @@ */ package com.nextcloud.ui.teams.viewmodel -import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nextcloud.ui.teams.model.CreateTeamStatus import com.nextcloud.ui.teams.model.TeamsUiState +import com.nextcloud.ui.teams.model.createTeamErrorMessageId +import com.nextcloud.ui.teams.model.loadTeamsErrorMessageId import com.nextcloud.ui.teams.repository.TeamsRepository -import com.owncloud.android.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,11 +30,11 @@ class TeamsViewModel @AssistedInject constructor( private val _uiState = MutableStateFlow(TeamsUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() - private val _messageId = MutableStateFlow(null) - val messageId: StateFlow = _messageId.asStateFlow() - val createTeamName: StateFlow = savedStateHandle.getStateFlow(KEY_CREATE_TEAM_NAME, null) + private val _createTeamStatus = MutableStateFlow(CreateTeamStatus.Editing) + val createTeamStatus: StateFlow = _createTeamStatus.asStateFlow() + init { viewModelScope.launch { loadTeams() } } @@ -44,10 +45,12 @@ class TeamsViewModel @AssistedInject constructor( } fun openCreateTeamDialog() { + _createTeamStatus.value = CreateTeamStatus.Editing savedStateHandle[KEY_CREATE_TEAM_NAME] = "" } fun updateCreateTeamName(name: String) { + _createTeamStatus.value = CreateTeamStatus.Editing savedStateHandle[KEY_CREATE_TEAM_NAME] = name } @@ -57,39 +60,35 @@ class TeamsViewModel @AssistedInject constructor( fun createTeam() { val name = createTeamName.value?.trim() - if (name.isNullOrEmpty()) { + if (name == null || !isValidTeamName(name)) { return } - dismissCreateTeamDialog() + _createTeamStatus.value = CreateTeamStatus.Creating viewModelScope.launch { - if (repository.createTeam(name) == null) { - showMessage(R.string.teams_create_failed) + val result = repository.createTeam(name) + if (!result.isSuccess) { + _createTeamStatus.value = CreateTeamStatus.Failed(result.createTeamErrorMessageId()) return@launch } + loadTeams() + dismissCreateTeamDialog() + _createTeamStatus.value = CreateTeamStatus.Editing } } - fun onMessageShown() { - _messageId.value = null - } - private suspend fun loadTeams() { - val teams = repository.getTeams() - if (teams == null) { - _uiState.value = TeamsUiState.Error + val result = repository.getTeams() + if (!result.isSuccess) { + _uiState.value = TeamsUiState.Error(result.loadTeamsErrorMessageId()) return } - val (myTeams, otherTeams) = teams.partition { it.isMember } + val (myTeams, otherTeams) = result.resultData.orEmpty().partition { it.isMember } _uiState.value = TeamsUiState.Content(myTeams, otherTeams) } - private fun showMessage(@StringRes id: Int) { - _messageId.value = id - } - @AssistedFactory interface Factory { fun create(savedStateHandle: SavedStateHandle): TeamsViewModel @@ -97,5 +96,10 @@ class TeamsViewModel @AssistedInject constructor( companion object { private const val KEY_CREATE_TEAM_NAME = "create_team_name" + const val MIN_TEAM_NAME_BYTES = 3 + const val MAX_TEAM_NAME_BYTES = 127 + + fun isValidTeamName(name: String): Boolean = + name.trim().toByteArray().size in MIN_TEAM_NAME_BYTES..MAX_TEAM_NAME_BYTES } } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/RemoteOperationResultExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/RemoteOperationResultExtensions.kt index cb5f3781a6ff..c3be081eb7fe 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/RemoteOperationResultExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/RemoteOperationResultExtensions.kt @@ -7,6 +7,7 @@ package com.nextcloud.utils.extensions +import androidx.annotation.StringRes import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.lib.common.operations.RemoteOperation @@ -20,6 +21,15 @@ fun Pair?, RemoteOperation<*>?>?.getErrorMessage(): Str return ErrorMessageAdapter.getErrorCauseMessage(result, operation, MainApp.getAppContext().resources) } +@StringRes +fun RemoteOperationResult<*>.connectionErrorMessageId(): Int? = when (code) { + ResultCode.NO_NETWORK_CONNECTION -> R.string.auth_no_net_conn_title + ResultCode.HOST_NOT_AVAILABLE, ResultCode.WRONG_CONNECTION -> R.string.network_host_not_available + ResultCode.TIMEOUT -> R.string.auth_timeout_title + ResultCode.MAINTENANCE_MODE -> R.string.maintenance_mode + else -> null +} + fun ResultCode.isFileSpecificError(): Boolean { val generalErrorCodes = listOf( ResultCode.INSTANCE_NOT_CONFIGURED, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eac134d3fb84..87f9dd96ffab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1489,6 +1489,11 @@ No teams yet Could not load teams Could not create team + Use %1$d to %2$d characters + This team name is not allowed + You are not allowed to create teams + Teams are not available on this server + Too many teams created in a short time. Try again in a minute. %d member %d members From 962fb936598b73d46bc839332b8f65eba9e34f1b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 12 Nov 2026 13:26:54 +0100 Subject: [PATCH 04/17] handle errors Signed-off-by: alperozturk96 --- .../ui/teams/component/CreateTeamDialog.kt | 93 ++++++++++++------- .../ui/teams/viewmodel/TeamsViewModel.kt | 9 +- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt index 9365f16021ce..511948571e9d 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt @@ -8,14 +8,20 @@ package com.nextcloud.ui.teams.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource @@ -24,6 +30,7 @@ import com.nextcloud.ui.teams.model.CreateTeamStatus import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.owncloud.android.R +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateTeamDialog( name: String, @@ -31,40 +38,62 @@ fun CreateTeamDialog( onNameChange: (String) -> Unit, onDismiss: () -> Unit, onCreate: () -> Unit +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + CreateTeamDialogCard(name, status, onNameChange, onDismiss, onCreate) + } +} + +@Composable +private fun CreateTeamDialogCard( + name: String, + status: CreateTeamStatus, + onNameChange: (String) -> Unit, + onDismiss: () -> Unit, + onCreate: () -> Unit ) { val isCreating = status == CreateTeamStatus.Creating val isNameValid = TeamsViewModel.isValidTeamName(name) - AlertDialog( - onDismissRequest = { if (!isCreating) onDismiss() }, - title = { Text(stringResource(R.string.teams_create)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding))) { - OutlinedTextField( - value = name, - onValueChange = onNameChange, - label = { Text(stringResource(R.string.teams_name_hint)) }, - supportingText = { CreateTeamSupportingText(name, status, isNameValid) }, - isError = status is CreateTeamStatus.Failed, - enabled = !isCreating, - singleLine = true - ) - if (isCreating) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - } - }, - confirmButton = { - TextButton(onClick = onCreate, enabled = isNameValid && !isCreating) { - Text(stringResource(R.string.create)) + Surface( + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + modifier = Modifier.padding(dimensionResource(R.dimen.dialog_padding)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_padding)) + ) { + Text( + text = stringResource(R.string.teams_create), + style = MaterialTheme.typography.headlineSmall, + color = AlertDialogDefaults.titleContentColor + ) + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text(stringResource(R.string.teams_name_hint)) }, + supportingText = { CreateTeamSupportingText(name, status, isNameValid) }, + isError = status is CreateTeamStatus.Failed, + readOnly = isCreating, + singleLine = true + ) + if (isCreating) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } - }, - dismissButton = { - TextButton(onClick = onDismiss, enabled = !isCreating) { - Text(stringResource(R.string.common_cancel)) + Row( + modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + TextButton(onClick = onCreate, enabled = isNameValid && !isCreating) { + Text(stringResource(R.string.create)) + } } } - ) + } } @Composable @@ -88,27 +117,27 @@ private fun CreateTeamSupportingText(name: String, status: CreateTeamStatus, isN @Composable private fun CreateTeamDialogPreview(name: String, status: CreateTeamStatus) { MaterialTheme { - CreateTeamDialog(name = name, status = status, onNameChange = {}, onDismiss = {}, onCreate = {}) + CreateTeamDialogCard(name = name, status = status, onNameChange = {}, onDismiss = {}, onCreate = {}) } } @Preview @Composable private fun CreateTeamDialogEditingPreview() { - CreateTeamDialogPreview(name = "Design team", status = CreateTeamStatus.Editing) + CreateTeamDialogPreview(name = "Editing", status = CreateTeamStatus.Editing) } @Preview @Composable private fun CreateTeamDialogCreatingPreview() { - CreateTeamDialogPreview(name = "Design team", status = CreateTeamStatus.Creating) + CreateTeamDialogPreview(name = "Creating", status = CreateTeamStatus.Creating) } @Preview @Composable private fun CreateTeamDialogFailedPreview() { CreateTeamDialogPreview( - name = "Design team", + name = "Failed", status = CreateTeamStatus.Failed(R.string.teams_error_create_not_allowed) ) } diff --git a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt index d4f994f42680..ecade588e5f6 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt @@ -55,6 +55,7 @@ class TeamsViewModel @AssistedInject constructor( } fun dismissCreateTeamDialog() { + _createTeamStatus.value = CreateTeamStatus.Editing savedStateHandle[KEY_CREATE_TEAM_NAME] = null } @@ -68,13 +69,15 @@ class TeamsViewModel @AssistedInject constructor( viewModelScope.launch { val result = repository.createTeam(name) if (!result.isSuccess) { - _createTeamStatus.value = CreateTeamStatus.Failed(result.createTeamErrorMessageId()) + val failed = CreateTeamStatus.Failed(result.createTeamErrorMessageId()) + _createTeamStatus.compareAndSet(CreateTeamStatus.Creating, failed) return@launch } loadTeams() - dismissCreateTeamDialog() - _createTeamStatus.value = CreateTeamStatus.Editing + if (_createTeamStatus.compareAndSet(CreateTeamStatus.Creating, CreateTeamStatus.Editing)) { + dismissCreateTeamDialog() + } } } From 1bf21238c8460484bf3b7d58d53b59120ed4bd3d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 12 Nov 2026 14:50:00 +0100 Subject: [PATCH 05/17] add search Signed-off-by: alperozturk96 --- .../com/nextcloud/ui/teams/TeamsScreen.kt | 36 ++++++---- .../ui/teams/component/TeamsSearchBar.kt | 66 +++++++++++++++++++ .../nextcloud/ui/teams/model/TeamsUiState.kt | 14 +++- .../ui/teams/viewmodel/TeamsViewModel.kt | 24 +++++-- app/src/main/res/values/strings.xml | 3 + 5 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/ui/teams/component/TeamsSearchBar.kt diff --git a/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt index 61f452fbdde0..8f4ddb1123d0 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt @@ -10,6 +10,7 @@ import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -35,6 +36,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.android.lib.resources.teams.model.Team import com.nextcloud.ui.teams.component.CreateTeamDialog +import com.nextcloud.ui.teams.component.TeamsSearchBar import com.nextcloud.ui.teams.model.TeamsUiState import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.owncloud.android.R @@ -42,10 +44,12 @@ import com.owncloud.android.R @Composable fun TeamsScreen(viewModel: TeamsViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val createTeamName by viewModel.createTeamName.collectAsStateWithLifecycle() val createTeamStatus by viewModel.createTeamStatus.collectAsStateWithLifecycle() Scaffold( + contentWindowInsets = WindowInsets(0), floatingActionButton = { ExtendedFloatingActionButton( text = { Text(stringResource(R.string.teams_create)) }, @@ -63,7 +67,7 @@ fun TeamsScreen(viewModel: TeamsViewModel) { when (val state = uiState) { TeamsUiState.Loading -> CircularProgressIndicator() is TeamsUiState.Error -> TeamsError(state.messageId, onRetry = viewModel::retry) - is TeamsUiState.Content -> TeamsContent(state) + is TeamsUiState.Content -> TeamsContent(state, searchQuery, viewModel::updateSearchQuery) } } } @@ -80,19 +84,25 @@ fun TeamsScreen(viewModel: TeamsViewModel) { } @Composable -private fun TeamsContent(state: TeamsUiState.Content) { - if (state.myTeams.isEmpty() && state.otherTeams.isEmpty()) { - Text( - text = stringResource(R.string.teams_empty), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - return - } +private fun TeamsContent(state: TeamsUiState.Content, searchQuery: String, onSearchQueryChange: (String) -> Unit) { + Column(modifier = Modifier.fillMaxSize()) { + TeamsSearchBar(searchQuery, onSearchQueryChange) - LazyColumn(modifier = Modifier.fillMaxSize()) { - teamSection(R.string.teams_my_teams, state.myTeams) - teamSection(R.string.teams_other_teams, state.otherTeams) + if (state.isEmpty()) { + val messageId = if (searchQuery.isBlank()) R.string.teams_empty else R.string.teams_search_no_results + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(messageId), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + teamSection(R.string.teams_my_teams, state.myTeams) + teamSection(R.string.teams_other_teams, state.otherTeams) + } + } } } diff --git a/app/src/main/java/com/nextcloud/ui/teams/component/TeamsSearchBar.kt b/app/src/main/java/com/nextcloud/ui/teams/component/TeamsSearchBar.kt new file mode 100644 index 000000000000..5d82e97fe29b --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/component/TeamsSearchBar.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DockedSearchBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.owncloud.android.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TeamsSearchBar(query: String, onQueryChange: (String) -> Unit) { + val focusManager = LocalFocusManager.current + + DockedSearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { focusManager.clearFocus() }, + expanded = false, + onExpandedChange = {}, + placeholder = { Text(stringResource(R.string.teams_search_hint)) }, + leadingIcon = { Icon(painterResource(R.drawable.ic_search), contentDescription = null) }, + trailingIcon = { ClearSearchButton(query, onQueryChange) } + ) + }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.standard_padding), + vertical = dimensionResource(R.dimen.standard_half_padding) + ) + ) {} +} + +@Composable +private fun ClearSearchButton(query: String, onQueryChange: (String) -> Unit) { + if (query.isEmpty()) { + return + } + + IconButton(onClick = { onQueryChange("") }) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.teams_search_clear) + ) + } +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt b/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt index 6c42e1b4e361..ea62c939f173 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.kt @@ -14,5 +14,17 @@ sealed interface TeamsUiState { data class Error(@StringRes val messageId: Int) : TeamsUiState - data class Content(val myTeams: List, val otherTeams: List) : TeamsUiState + data class Content(val myTeams: List, val otherTeams: List) : TeamsUiState { + fun isEmpty(): Boolean = myTeams.isEmpty() && otherTeams.isEmpty() + + fun matching(query: String): Content { + val trimmedQuery = query.trim() + if (trimmedQuery.isEmpty()) { + return this + } + + val matches: (Team) -> Boolean = { it.displayName.contains(trimmedQuery, ignoreCase = true) } + return Content(myTeams.filter(matches), otherTeams.filter(matches)) + } + } } diff --git a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt index ecade588e5f6..4f808eb8be42 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt @@ -18,8 +18,11 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class TeamsViewModel @AssistedInject constructor( @@ -27,8 +30,13 @@ class TeamsViewModel @AssistedInject constructor( @Assisted private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private val _uiState = MutableStateFlow(TeamsUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + private val teamsState = MutableStateFlow(TeamsUiState.Loading) + + val searchQuery: StateFlow = savedStateHandle.getStateFlow(KEY_SEARCH_QUERY, "") + + val uiState: StateFlow = combine(teamsState, searchQuery) { state, query -> + if (state is TeamsUiState.Content) state.matching(query) else state + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), TeamsUiState.Loading) val createTeamName: StateFlow = savedStateHandle.getStateFlow(KEY_CREATE_TEAM_NAME, null) @@ -40,10 +48,14 @@ class TeamsViewModel @AssistedInject constructor( } fun retry() { - _uiState.value = TeamsUiState.Loading + teamsState.value = TeamsUiState.Loading viewModelScope.launch { loadTeams() } } + fun updateSearchQuery(query: String) { + savedStateHandle[KEY_SEARCH_QUERY] = query + } + fun openCreateTeamDialog() { _createTeamStatus.value = CreateTeamStatus.Editing savedStateHandle[KEY_CREATE_TEAM_NAME] = "" @@ -84,12 +96,12 @@ class TeamsViewModel @AssistedInject constructor( private suspend fun loadTeams() { val result = repository.getTeams() if (!result.isSuccess) { - _uiState.value = TeamsUiState.Error(result.loadTeamsErrorMessageId()) + teamsState.value = TeamsUiState.Error(result.loadTeamsErrorMessageId()) return } val (myTeams, otherTeams) = result.resultData.orEmpty().partition { it.isMember } - _uiState.value = TeamsUiState.Content(myTeams, otherTeams) + teamsState.value = TeamsUiState.Content(myTeams, otherTeams) } @AssistedFactory @@ -99,6 +111,8 @@ class TeamsViewModel @AssistedInject constructor( companion object { private const val KEY_CREATE_TEAM_NAME = "create_team_name" + private const val KEY_SEARCH_QUERY = "search_query" + private const val STOP_TIMEOUT_MILLIS = 5_000L const val MIN_TEAM_NAME_BYTES = 3 const val MAX_TEAM_NAME_BYTES = 127 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87f9dd96ffab..4e1ebf4dfa3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1487,6 +1487,9 @@ Create team Team name No teams yet + Search teams + Clear search + No teams found Could not load teams Could not create team Use %1$d to %2$d characters From 37f3485bf0870b891b9a15f2bb7c8ed94643deb0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 12 Nov 2026 15:22:37 +0100 Subject: [PATCH 06/17] add team detail screen Signed-off-by: alperozturk96 --- .../java/com/nextcloud/client/di/AppModule.kt | 5 + .../nextcloud/client/di/ViewModelModule.kt | 6 + .../ui/composeActivity/ComposeActivity.kt | 60 +++++- .../ui/composeActivity/ComposeDestination.kt | 6 + .../webView/WebPageScreen.kt | 136 +++++++++++++ .../com/nextcloud/ui/teams/TeamsScreen.kt | 23 ++- .../ui/teams/detail/TeamDetailScreen.kt | 129 +++++++++++++ .../ui/teams/detail/TeamResourceOpener.kt | 71 +++++++ .../detail/component/AddTeamMemberDialog.kt | 110 +++++++++++ .../component/TeamDetailListStateContent.kt | 72 +++++++ .../teams/detail/component/TeamMemberIcon.kt | 25 +++ .../detail/component/TeamMemberListItem.kt | 40 ++++ .../detail/component/TeamResourceListItem.kt | 48 +++++ .../teams/detail/model/AddTeamMemberStatus.kt | 17 ++ .../detail/model/TeamDetailErrorMessages.kt | 26 +++ .../detail/model/TeamDetailListUiState.kt | 17 ++ .../ui/teams/detail/model/TeamDetailTab.kt | 15 ++ .../detail/model/TeamMemberSuggestion.kt | 11 ++ .../detail/model/TeamResourceProviderId.kt | 13 ++ .../teams/detail/model/TeamResourceTarget.kt | 15 ++ .../repository/TeamDetailRemoteRepository.kt | 74 +++++++ .../detail/repository/TeamDetailRepository.kt | 26 +++ .../detail/viewmodel/TeamDetailViewModel.kt | 180 ++++++++++++++++++ .../ui/teams/viewmodel/TeamsViewModel.kt | 6 +- app/src/main/res/values/dims.xml | 1 + app/src/main/res/values/strings.xml | 16 ++ 26 files changed, 1130 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/ui/composeComponents/webView/WebPageScreen.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberIcon.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberListItem.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/model/AddTeamMemberStatus.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailErrorMessages.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailListUiState.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailTab.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamMemberSuggestion.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRepository.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt diff --git a/app/src/main/java/com/nextcloud/client/di/AppModule.kt b/app/src/main/java/com/nextcloud/client/di/AppModule.kt index 615410fc72c3..d55dbd4ce7f7 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppModule.kt @@ -44,6 +44,8 @@ import com.nextcloud.client.notifications.AppNotificationManagerImpl import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.repository.ClientRepository import com.nextcloud.repository.RemoteClientRepository +import com.nextcloud.ui.teams.detail.repository.TeamDetailRemoteRepository +import com.nextcloud.ui.teams.detail.repository.TeamDetailRepository import com.nextcloud.ui.teams.repository.TeamsRemoteRepository import com.nextcloud.ui.teams.repository.TeamsRepository import com.nextcloud.utils.e2ee.E2EEActionResolver @@ -124,6 +126,9 @@ internal class AppModule { @Provides fun teamsRepository(repository: TeamsRemoteRepository): TeamsRepository = repository + @Provides + fun teamDetailRepository(repository: TeamDetailRemoteRepository): TeamDetailRepository = repository + @Provides fun fileUploadHelper(): FileUploadHelper = FileUploadHelper.instance() diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index 5936abb04201..e6d912dcddfc 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -16,6 +16,7 @@ import com.nextcloud.client.logger.ui.LogsViewModel import com.nextcloud.client.player.ui.audio.AudioPlayerViewModel import com.nextcloud.ui.fileactions.FileActionsViewModel import com.nextcloud.ui.tags.TagManagementViewModel +import com.nextcloud.ui.teams.detail.viewmodel.TeamDetailViewModel import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel import com.owncloud.android.ui.activity.filedisplayactivity.FileDisplayActivityViewModel @@ -92,5 +93,10 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(TeamsViewModel::class) fun teamsViewModel(factory: TeamsViewModel.Factory) = SavedStateViewModelCreator(factory::create) + + @Provides + @IntoMap + @ViewModelKey(TeamDetailViewModel::class) + fun teamDetailViewModel(factory: TeamDetailViewModel.Factory) = SavedStateViewModelCreator(factory::create) } } 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 14b73d39cd70..642e1d482745 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt @@ -31,7 +31,13 @@ import com.nextcloud.client.assistant.repository.remote.AssistantRemoteRepositor import com.nextcloud.client.database.NextcloudDatabase import com.nextcloud.client.di.ViewModelFactory import com.nextcloud.common.NextcloudClient +import com.nextcloud.android.lib.resources.teams.model.Team import com.nextcloud.ui.ClientIntegrationScreen +import com.nextcloud.ui.composeComponents.webView.WebPageScreen +import com.nextcloud.ui.teams.detail.TeamResourceOpener +import com.nextcloud.ui.teams.detail.TeamDetailScreen +import com.nextcloud.ui.teams.detail.model.TeamResourceTarget +import com.nextcloud.ui.teams.detail.viewmodel.TeamDetailViewModel import com.nextcloud.ui.teams.TeamsScreen import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.nextcloud.utils.extensions.getParcelableArgument @@ -91,7 +97,13 @@ class ComposeActivity : DrawerActivity() { } override val menuItemId: Int - get() = if (destination is ComposeDestination.TeamsScreen) Menu.NONE else R.id.nav_assistant + get() = when (destination) { + is ComposeDestination.TeamsScreen, + is ComposeDestination.TeamDetailScreen, + is ComposeDestination.WebPageScreen -> Menu.NONE + + else -> R.id.nav_assistant + } override fun onResume() { super.onResume() @@ -102,15 +114,27 @@ class ComposeActivity : DrawerActivity() { when (destination) { is ComposeDestination.AssistantScreen -> setupDrawerScreen(destination.title) is ComposeDestination.TeamsScreen -> setupDrawerScreen(destination.title) - is ComposeDestination.ClientIntegrationScreen -> { - setSupportActionBar(null) - findViewById(R.id.appbar)?.let { - it.visibility = View.GONE - } - } + is ComposeDestination.ClientIntegrationScreen, + is ComposeDestination.TeamDetailScreen, + is ComposeDestination.WebPageScreen -> hideActionBar() } } + private fun hideActionBar() { + setSupportActionBar(null) + findViewById(R.id.appbar)?.let { + it.visibility = View.GONE + } + } + + private fun openTeam(team: Team) { + val destination = ComposeDestination.TeamDetailScreen(team.id, team.displayName, team.url) + val intent = Intent(this, ComposeActivity::class.java) + .putExtra(DESTINATION, destination) + .putExtra(TeamDetailViewModel.KEY_TEAM_ID, team.id) + startActivity(intent) + } + private fun setupDrawerScreen(title: String) { setupDrawer(menuItemId) showMenuAndTitleActionBar(title) { @@ -176,7 +200,27 @@ class ComposeActivity : DrawerActivity() { val viewModel = remember { ViewModelProvider(this, vmFactory)[TeamsViewModel::class.java] } - TeamsScreen(viewModel) + TeamsScreen(viewModel, onTeamClick = ::openTeam) + } + + is ComposeDestination.TeamDetailScreen -> { + binding.bottomNavigation.visibility = View.GONE + val viewModel = remember { + ViewModelProvider(this, vmFactory)[TeamDetailViewModel::class.java] + } + val opener = remember { TeamResourceOpener(this, userAccountManager.user) } + TeamDetailScreen( + viewModel = viewModel, + title = screen.title, + onBack = ::finish, + onOpenSettings = { opener.open(TeamResourceTarget.WebPage(screen.settingsUrl, screen.title)) }, + onOpenResource = opener::open + ) + } + + is ComposeDestination.WebPageScreen -> { + binding.bottomNavigation.visibility = View.GONE + WebPageScreen(screen.title, screen.url, nextcloudClient, onClose = ::finish) } } } 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 9db19c286495..b38df7b1f16e 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt +++ b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt @@ -24,6 +24,12 @@ sealed class ComposeDestination(val id: Int) : Parcelable { @Parcelize data class TeamsScreen(val title: String) : ComposeDestination(2) + @Parcelize + data class TeamDetailScreen(val teamId: String, val title: String, val settingsUrl: String) : ComposeDestination(3) + + @Parcelize + data class WebPageScreen(val title: String, val url: String) : ComposeDestination(4) + companion object { fun getTeamsScreen(context: Context): TeamsScreen = TeamsScreen(context.getString(R.string.navigation_teams)) diff --git a/app/src/main/java/com/nextcloud/ui/composeComponents/webView/WebPageScreen.kt b/app/src/main/java/com/nextcloud/ui/composeComponents/webView/WebPageScreen.kt new file mode 100644 index 000000000000..72d4b4c4328d --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/composeComponents/webView/WebPageScreen.kt @@ -0,0 +1,136 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.composeComponents.webView + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.net.toUri +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import com.nextcloud.common.NextcloudClient +import com.owncloud.android.R + +private const val MAX_PROGRESS = 100 +private const val AUTHORIZATION_HEADER = "Authorization" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WebPageScreen(title: String, url: String, client: NextcloudClient?, onClose: () -> Unit) { + var webView by remember { mutableStateOf(null) } + var progress by remember { mutableIntStateOf(0) } + var canGoBack by remember { mutableStateOf(false) } + + BackHandler(enabled = canGoBack) { webView?.goBack() } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + TopAppBar( + title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(painterResource(R.drawable.ic_arrow_back), stringResource(R.string.common_back)) + } + }, + windowInsets = WindowInsets(0) + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + if (progress < MAX_PROGRESS) { + LinearProgressIndicator( + progress = { progress.toFloat() / MAX_PROGRESS }, + modifier = Modifier.fillMaxWidth() + ) + } + + client ?: return@Column + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + configure( + onProgressChange = { progress = it }, + onHistoryChange = { canGoBack = it } + ) + loadAuthenticated(url, client) + webView = this + } + }, + onRelease = { it.destroy() } + ) + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +private fun WebView.configure(onProgressChange: (Int) -> Unit, onHistoryChange: (Boolean) -> Unit) { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, true) + } + + webViewClient = object : WebViewClient() { + override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { + onHistoryChange(view.canGoBack()) + } + } + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView, newProgress: Int) { + onProgressChange(newProgress) + } + } +} + +// Cookies are shared by every WebView of the app: drop a session that may belong to another account +// before signing in with this account's credentials. +private fun WebView.loadAuthenticated(url: String, client: NextcloudClient) { + if (!url.toUri().hasSameOrigin(client.baseUri)) { + loadUrl(url) + return + } + + CookieManager.getInstance().removeAllCookies { + loadUrl(url, mapOf(AUTHORIZATION_HEADER to client.credentials)) + } +} + +private fun Uri.hasSameOrigin(other: Uri): Boolean = scheme == other.scheme && host == other.host && port == other.port diff --git a/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt index 8f4ddb1123d0..690f0de53230 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt @@ -7,6 +7,7 @@ package com.nextcloud.ui.teams import androidx.annotation.StringRes +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,7 +43,7 @@ import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.owncloud.android.R @Composable -fun TeamsScreen(viewModel: TeamsViewModel) { +fun TeamsScreen(viewModel: TeamsViewModel, onTeamClick: (Team) -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val createTeamName by viewModel.createTeamName.collectAsStateWithLifecycle() @@ -67,7 +68,7 @@ fun TeamsScreen(viewModel: TeamsViewModel) { when (val state = uiState) { TeamsUiState.Loading -> CircularProgressIndicator() is TeamsUiState.Error -> TeamsError(state.messageId, onRetry = viewModel::retry) - is TeamsUiState.Content -> TeamsContent(state, searchQuery, viewModel::updateSearchQuery) + is TeamsUiState.Content -> TeamsContent(state, searchQuery, viewModel::updateSearchQuery, onTeamClick) } } } @@ -84,7 +85,12 @@ fun TeamsScreen(viewModel: TeamsViewModel) { } @Composable -private fun TeamsContent(state: TeamsUiState.Content, searchQuery: String, onSearchQueryChange: (String) -> Unit) { +private fun TeamsContent( + state: TeamsUiState.Content, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onTeamClick: (Team) -> Unit +) { Column(modifier = Modifier.fillMaxSize()) { TeamsSearchBar(searchQuery, onSearchQueryChange) @@ -99,20 +105,20 @@ private fun TeamsContent(state: TeamsUiState.Content, searchQuery: String, onSea } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { - teamSection(R.string.teams_my_teams, state.myTeams) - teamSection(R.string.teams_other_teams, state.otherTeams) + teamSection(R.string.teams_my_teams, state.myTeams, onTeamClick) + teamSection(R.string.teams_other_teams, state.otherTeams, onTeamClick) } } } } -private fun LazyListScope.teamSection(@StringRes titleId: Int, teams: List) { +private fun LazyListScope.teamSection(@StringRes titleId: Int, teams: List, onTeamClick: (Team) -> Unit) { if (teams.isEmpty()) { return } item(key = titleId) { SectionHeader(titleId) } - items(teams, key = { it.id }) { TeamListItem(it) } + items(teams, key = { it.id }) { team -> TeamListItem(team) { onTeamClick(team) } } } @Composable @@ -131,8 +137,9 @@ private fun SectionHeader(@StringRes titleId: Int) { } @Composable -private fun TeamListItem(team: Team) { +private fun TeamListItem(team: Team, onClick: () -> Unit) { ListItem( + modifier = Modifier.clickable(onClick = onClick), headlineContent = { Text(text = team.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) }, diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt new file mode 100644 index 000000000000..134c388329c9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt @@ -0,0 +1,129 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nextcloud.ui.teams.detail.component.AddTeamMemberDialog +import com.nextcloud.ui.teams.detail.component.TeamDetailListStateContent +import com.nextcloud.ui.teams.detail.component.TeamMemberListItem +import com.nextcloud.ui.teams.detail.component.TeamResourceListItem +import com.nextcloud.ui.teams.detail.model.TeamResourceTarget +import com.nextcloud.ui.teams.detail.model.TeamDetailTab +import com.nextcloud.ui.teams.detail.viewmodel.TeamDetailViewModel +import com.owncloud.android.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TeamDetailScreen( + viewModel: TeamDetailViewModel, + title: String, + onBack: () -> Unit, + onOpenSettings: () -> Unit, + onOpenResource: (TeamResourceTarget) -> Unit +) { + val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() + val resources by viewModel.resources.collectAsStateWithLifecycle() + val members by viewModel.members.collectAsStateWithLifecycle() + val addMemberQuery by viewModel.addMemberQuery.collectAsStateWithLifecycle() + val addMemberStatus by viewModel.addMemberStatus.collectAsStateWithLifecycle() + val memberSuggestions by viewModel.memberSuggestions.collectAsStateWithLifecycle() + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + TopAppBar( + title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(painterResource(R.drawable.ic_arrow_back), stringResource(R.string.common_back)) + } + }, + actions = { + IconButton(onClick = onOpenSettings) { + Icon(painterResource(R.drawable.ic_settings), stringResource(R.string.team_settings)) + } + }, + windowInsets = WindowInsets(0) + ) + }, + floatingActionButton = { + if (selectedTab == TeamDetailTab.MEMBERS) { + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.team_add_member)) }, + icon = { Icon(painterResource(R.drawable.ic_account_plus), contentDescription = null) }, + onClick = viewModel::openAddMemberDialog + ) + } + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + PrimaryTabRow(selectedTabIndex = selectedTab.ordinal) { + TeamDetailTab.entries.forEach { tab -> + Tab( + selected = tab == selectedTab, + onClick = { viewModel.selectTab(tab) }, + text = { Text(stringResource(tab.titleId)) } + ) + } + } + + when (selectedTab) { + TeamDetailTab.RESOURCES -> TeamDetailListStateContent( + state = resources, + emptyMessageId = R.string.team_resources_empty, + onRetry = viewModel::retryResources, + key = { "${it.provider.id}:${it.id}" } + ) { resource -> + TeamResourceListItem(resource) { onOpenResource(viewModel.targetFor(resource)) } + } + + TeamDetailTab.MEMBERS -> TeamDetailListStateContent( + state = members, + emptyMessageId = R.string.team_members_empty, + onRetry = viewModel::retryMembers, + key = { it.singleId } + ) { member -> + TeamMemberListItem(member) + } + } + } + } + + addMemberQuery?.let { query -> + AddTeamMemberDialog( + query = query, + status = addMemberStatus, + suggestions = memberSuggestions, + onQueryChange = viewModel::updateAddMemberQuery, + onSelect = viewModel::addMember, + onDismiss = viewModel::dismissAddMemberDialog + ) + } +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt new file mode 100644 index 000000000000..65ebe38d5c8c --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import com.nextcloud.client.account.User +import com.nextcloud.ui.composeActivity.ComposeActivity +import com.nextcloud.ui.composeActivity.ComposeDestination +import com.nextcloud.ui.teams.detail.model.TeamResourceTarget +import com.owncloud.android.ui.activity.FileDisplayActivity + +class TeamResourceOpener(private val activity: Activity, private val user: User) { + + fun open(target: TeamResourceTarget) { + when (target) { + is TeamResourceTarget.Folder -> openFolder(target.remotePath) + is TeamResourceTarget.TalkConversation -> openTalkConversation(target) + is TeamResourceTarget.WebPage -> openWebPage(target) + } + } + + private fun openFolder(remotePath: String) { + val intent = Intent(activity, FileDisplayActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra(FileDisplayActivity.KEY_ACCOUNT, user.accountName) + putExtra(FileDisplayActivity.KEY_FILE_PATH, remotePath) + } + activity.startActivity(intent) + } + + private fun openTalkConversation(target: TeamResourceTarget.TalkConversation) { + val intent = Intent(Intent.ACTION_VIEW, talkConversationUri(target.token)) + try { + activity.startActivity(intent) + } catch (_: ActivityNotFoundException) { + openWebPage(target.webPage) + } + } + + private fun talkConversationUri(token: String): Uri { + val server = user.server.uri + val userId = Uri.encode(user.accountName.substringBeforeLast(ACCOUNT_SEPARATOR)) + val basePath = server.rawPath.orEmpty().trimEnd(PATH_SEPARATOR) + return Uri.Builder().apply { + scheme(TALK_SCHEME) + encodedAuthority("$userId$ACCOUNT_SEPARATOR${server.rawAuthority}") + encodedPath("$basePath$TALK_CALL_PATH$token") + }.build() + } + + private fun openWebPage(target: TeamResourceTarget.WebPage) { + val intent = Intent(activity, ComposeActivity::class.java).apply { + putExtra(ComposeActivity.DESTINATION, ComposeDestination.WebPageScreen(target.title, target.url)) + } + activity.startActivity(intent) + } + + companion object { + private const val TALK_SCHEME = "nextcloudtalk" + private const val TALK_CALL_PATH = "/call/" + private const val ACCOUNT_SEPARATOR = '@' + private const val PATH_SEPARATOR = '/' + } +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt new file mode 100644 index 000000000000..2ceacc61f52a --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt @@ -0,0 +1,110 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.nextcloud.ui.teams.detail.model.AddTeamMemberStatus +import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion +import com.owncloud.android.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddTeamMemberDialog( + query: String, + status: AddTeamMemberStatus, + suggestions: List, + onQueryChange: (String) -> Unit, + onSelect: (TeamMemberSuggestion) -> Unit, + onDismiss: () -> Unit +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + AddTeamMemberDialogCard(query, status, suggestions, onQueryChange, onSelect, onDismiss) + } +} + +@Composable +private fun AddTeamMemberDialogCard( + query: String, + status: AddTeamMemberStatus, + suggestions: List, + onQueryChange: (String) -> Unit, + onSelect: (TeamMemberSuggestion) -> Unit, + onDismiss: () -> Unit +) { + val isAdding = status == AddTeamMemberStatus.Adding + val failure = status as? AddTeamMemberStatus.Failed + + Surface( + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + modifier = Modifier.padding(dimensionResource(R.dimen.dialog_padding)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) + ) { + Text( + text = stringResource(R.string.team_add_member), + style = MaterialTheme.typography.headlineSmall, + color = AlertDialogDefaults.titleContentColor + ) + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + label = { Text(stringResource(R.string.team_add_member_hint)) }, + supportingText = failure?.let { { Text(stringResource(it.messageId)) } }, + isError = failure != null, + readOnly = isAdding, + singleLine = true + ) + if (isAdding) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + val suggestionsMaxHeight = dimensionResource(R.dimen.team_member_suggestions_max_height) + LazyColumn(modifier = Modifier.heightIn(max = suggestionsMaxHeight)) { + items(suggestions, key = { "${it.member.type}:${it.member.id}" }) { suggestion -> + ListItem( + headlineContent = { + Text(text = suggestion.label, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + leadingContent = { TeamMemberIcon(suggestion.member.type) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(enabled = !isAdding) { onSelect(suggestion) } + ) + } + } + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.common_cancel)) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt new file mode 100644 index 000000000000..5e9e93cf0a4f --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import com.nextcloud.ui.teams.detail.model.TeamDetailListUiState +import com.owncloud.android.R + +@Composable +fun TeamDetailListStateContent( + state: TeamDetailListUiState, + @StringRes emptyMessageId: Int, + onRetry: () -> Unit, + key: (T) -> Any, + itemContent: @Composable (T) -> Unit +) { + if (state is TeamDetailListUiState.Content && state.items.isNotEmpty()) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.items, key = key) { itemContent(it) } + } + return + } + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + when (state) { + TeamDetailListUiState.Loading -> CircularProgressIndicator() + is TeamDetailListUiState.Error -> ErrorMessage(state.messageId, onRetry) + is TeamDetailListUiState.Content -> Message(emptyMessageId) + } + } +} + +@Composable +private fun ErrorMessage(@StringRes messageId: Int, onRetry: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) + ) { + Message(messageId) + TextButton(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } +} + +@Composable +private fun Message(@StringRes messageId: Int) { + Text( + text = stringResource(messageId), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberIcon.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberIcon.kt new file mode 100644 index 000000000000..7854f8112332 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberIcon.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.component + +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import com.nextcloud.android.lib.resources.teams.model.TeamMemberType +import com.owncloud.android.R + +@Composable +fun TeamMemberIcon(type: TeamMemberType?) { + val iconId = when (type) { + TeamMemberType.GROUP -> R.drawable.ic_group + TeamMemberType.MAIL -> R.drawable.ic_email + TeamMemberType.CIRCLE -> R.drawable.ic_circles + TeamMemberType.USER, TeamMemberType.CONTACT, null -> R.drawable.ic_user + } + Icon(painter = painterResource(iconId), contentDescription = null, tint = MaterialTheme.colorScheme.primary) +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberListItem.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberListItem.kt new file mode 100644 index 000000000000..f493aa7243f4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberListItem.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.component + +import androidx.annotation.StringRes +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.nextcloud.android.lib.resources.teams.model.TeamMember +import com.nextcloud.android.lib.resources.teams.model.TeamRole +import com.owncloud.android.R + +@Composable +fun TeamMemberListItem(member: TeamMember) { + ListItem( + headlineContent = { + Text( + text = member.displayName.ifEmpty { member.userId.orEmpty() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { Text(stringResource(roleTitleId(member.role))) }, + leadingContent = { TeamMemberIcon(member.type) } + ) +} + +@StringRes +private fun roleTitleId(role: TeamRole): Int = when (role) { + TeamRole.OWNER -> R.string.team_role_owner + TeamRole.ADMIN -> R.string.team_role_admin + TeamRole.MODERATOR -> R.string.team_role_moderator + TeamRole.MEMBER -> R.string.team_role_member +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt new file mode 100644 index 000000000000..482ce8e1dc6a --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResource +import com.nextcloud.ui.teams.detail.model.TeamResourceProviderId +import com.owncloud.android.R + +@Composable +fun TeamResourceListItem(resource: TeamResource, onClick: () -> Unit) { + ListItem( + headlineContent = { Text(text = resource.label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = { Text(resource.provider.name) }, + leadingContent = { + Icon( + painter = painterResource(iconId(resource.provider.id)), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(dimensionResource(R.dimen.iconized_single_line_item_icon_size)) + ) + }, + modifier = Modifier.clickable(onClick = onClick) + ) +} + +@DrawableRes +private fun iconId(providerId: String): Int = when (providerId) { + TeamResourceProviderId.TEAM_FOLDER -> R.drawable.folder + TeamResourceProviderId.TALK -> R.drawable.ic_talk + TeamResourceProviderId.DECK -> R.drawable.ic_deck + else -> R.drawable.ic_link +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/AddTeamMemberStatus.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/AddTeamMemberStatus.kt new file mode 100644 index 000000000000..22eb12029555 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/AddTeamMemberStatus.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.model + +import androidx.annotation.StringRes + +sealed interface AddTeamMemberStatus { + data object Editing : AddTeamMemberStatus + + data object Adding : AddTeamMemberStatus + + data class Failed(@StringRes val messageId: Int) : AddTeamMemberStatus +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailErrorMessages.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailErrorMessages.kt new file mode 100644 index 000000000000..3e9a74c2acbb --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailErrorMessages.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.model + +import androidx.annotation.StringRes +import com.nextcloud.utils.extensions.connectionErrorMessageId +import com.owncloud.android.R +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import java.net.HttpURLConnection + +@StringRes +fun RemoteOperationResult<*>.loadTeamDetailErrorMessageId(): Int = connectionErrorMessageId() ?: when (httpCode) { + HttpURLConnection.HTTP_NOT_FOUND -> R.string.team_error_not_found + else -> R.string.team_load_failed +} + +@StringRes +fun RemoteOperationResult<*>.addTeamMemberErrorMessageId(): Int = connectionErrorMessageId() ?: when (httpCode) { + HttpURLConnection.HTTP_FORBIDDEN -> R.string.team_error_add_member_not_allowed + HttpURLConnection.HTTP_BAD_REQUEST -> R.string.team_error_add_member_invalid + else -> R.string.team_add_member_failed +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailListUiState.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailListUiState.kt new file mode 100644 index 000000000000..637941eb2436 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailListUiState.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.model + +import androidx.annotation.StringRes + +sealed interface TeamDetailListUiState { + data object Loading : TeamDetailListUiState + + data class Error(@StringRes val messageId: Int) : TeamDetailListUiState + + data class Content(val items: List) : TeamDetailListUiState +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailTab.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailTab.kt new file mode 100644 index 000000000000..9c917bab3aec --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamDetailTab.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.model + +import androidx.annotation.StringRes +import com.owncloud.android.R + +enum class TeamDetailTab(@StringRes val titleId: Int) { + RESOURCES(R.string.team_tab_resources), + MEMBERS(R.string.team_tab_members) +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamMemberSuggestion.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamMemberSuggestion.kt new file mode 100644 index 000000000000..4a6af7ddbd3c --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamMemberSuggestion.kt @@ -0,0 +1,11 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.model + +import com.nextcloud.android.lib.resources.teams.post.members.model.NewTeamMember + +data class TeamMemberSuggestion(val label: String, val member: NewTeamMember) diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt new file mode 100644 index 000000000000..ac75fc26e725 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.model + +object TeamResourceProviderId { + const val TEAM_FOLDER = "groupfolders" + const val TALK = "talk" + const val DECK = "deck" +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.kt new file mode 100644 index 000000000000..a0ae30f5d7e8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.model + +sealed interface TeamResourceTarget { + data class Folder(val remotePath: String) : TeamResourceTarget + + data class TalkConversation(val token: String, val webPage: WebPage) : TeamResourceTarget + + data class WebPage(val url: String, val title: String) : TeamResourceTarget +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt new file mode 100644 index 000000000000..ca4cbf5909fc --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.repository + +import com.nextcloud.android.lib.resources.teams.get.folder.GetTeamFolderRemoteOperation +import com.nextcloud.android.lib.resources.teams.get.folder.model.TeamFolder +import com.nextcloud.android.lib.resources.teams.get.members.GetTeamMembersRemoteOperation +import com.nextcloud.android.lib.resources.teams.get.resources.GetTeamResourcesRemoteOperation +import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResource +import com.nextcloud.android.lib.resources.teams.model.TeamMember +import com.nextcloud.android.lib.resources.teams.model.TeamMemberType +import com.nextcloud.android.lib.resources.teams.post.members.AddTeamMembersRemoteOperation +import com.nextcloud.android.lib.resources.teams.post.members.model.NewTeamMember +import com.nextcloud.repository.ClientRepository +import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation +import com.owncloud.android.lib.resources.shares.ShareType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import javax.inject.Inject + +class TeamDetailRemoteRepository @Inject constructor(private val clientRepository: ClientRepository) : + TeamDetailRepository { + + override suspend fun getResources(teamId: String): RemoteOperationResult> = + execute(GetTeamResourcesRemoteOperation(teamId)) + + override suspend fun getFolder(teamId: String): RemoteOperationResult = + execute(GetTeamFolderRemoteOperation(teamId)) + + override suspend fun getMembers(teamId: String): RemoteOperationResult> = + execute(GetTeamMembersRemoteOperation(teamId)) + + override suspend fun addMember(teamId: String, member: NewTeamMember): RemoteOperationResult = + execute(AddTeamMembersRemoteOperation(teamId, listOf(member))) + + override suspend fun searchMemberSuggestions(query: String): List { + val client = clientRepository.getOwncloudClient() ?: return emptyList() + return withContext(Dispatchers.IO) { + val result = GetShareesRemoteOperation(query, FIRST_PAGE, SUGGESTIONS_PER_PAGE).execute(client) + if (!result.isSuccess) { + return@withContext emptyList() + } + result.resultData.orEmpty().mapNotNull { it.toTeamMemberSuggestion() } + } + } + + private suspend fun execute(operation: RemoteOperation): RemoteOperationResult { + val client = clientRepository.getNextcloudClient() ?: return RemoteOperationResult(ResultCode.ACCOUNT_NOT_FOUND) + return withContext(Dispatchers.IO) { operation.execute(client) } + } + + private fun JSONObject.toTeamMemberSuggestion(): TeamMemberSuggestion? { + val value = optJSONObject(GetShareesRemoteOperation.NODE_VALUE) ?: return null + val shareType = ShareType.fromValue(value.optInt(GetShareesRemoteOperation.PROPERTY_SHARE_TYPE)) + val type = TeamMemberType.fromShareType(shareType) ?: return null + val id = value.optString(GetShareesRemoteOperation.PROPERTY_SHARE_WITH).ifEmpty { return null } + val label = optString(GetShareesRemoteOperation.PROPERTY_LABEL).ifEmpty { id } + return TeamMemberSuggestion(label, NewTeamMember(id, type)) + } + + companion object { + private const val FIRST_PAGE = 1 + private const val SUGGESTIONS_PER_PAGE = 20 + } +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRepository.kt new file mode 100644 index 000000000000..50e67e1f7542 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRepository.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.repository + +import com.nextcloud.android.lib.resources.teams.get.folder.model.TeamFolder +import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResource +import com.nextcloud.android.lib.resources.teams.model.TeamMember +import com.nextcloud.android.lib.resources.teams.post.members.model.NewTeamMember +import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion +import com.owncloud.android.lib.common.operations.RemoteOperationResult + +interface TeamDetailRepository { + suspend fun getResources(teamId: String): RemoteOperationResult> + + suspend fun getFolder(teamId: String): RemoteOperationResult + + suspend fun getMembers(teamId: String): RemoteOperationResult> + + suspend fun addMember(teamId: String, member: NewTeamMember): RemoteOperationResult + + suspend fun searchMemberSuggestions(query: String): List +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt new file mode 100644 index 000000000000..4343c057cf89 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt @@ -0,0 +1,180 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResource +import com.nextcloud.android.lib.resources.teams.model.TeamMember +import com.nextcloud.ui.teams.detail.model.AddTeamMemberStatus +import com.nextcloud.ui.teams.detail.model.TeamDetailListUiState +import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion +import com.nextcloud.ui.teams.detail.model.TeamResourceProviderId +import com.nextcloud.ui.teams.detail.model.TeamResourceTarget +import com.nextcloud.ui.teams.detail.model.TeamDetailTab +import com.nextcloud.ui.teams.detail.model.addTeamMemberErrorMessageId +import com.nextcloud.ui.teams.detail.model.loadTeamDetailErrorMessageId +import com.nextcloud.ui.teams.detail.repository.TeamDetailRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class TeamDetailViewModel @AssistedInject constructor( + private val repository: TeamDetailRepository, + @Assisted private val savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val teamId: String = checkNotNull(savedStateHandle[KEY_TEAM_ID]) + + private var folderPath: String? = null + + val selectedTab: StateFlow = savedStateHandle.getStateFlow(KEY_SELECTED_TAB, TeamDetailTab.RESOURCES) + + private val _resources = MutableStateFlow>(TeamDetailListUiState.Loading) + val resources: StateFlow> = _resources.asStateFlow() + + private val _members = MutableStateFlow>(TeamDetailListUiState.Loading) + val members: StateFlow> = _members.asStateFlow() + + val addMemberQuery: StateFlow = savedStateHandle.getStateFlow(KEY_ADD_MEMBER_QUERY, null) + + private val _addMemberStatus = MutableStateFlow(AddTeamMemberStatus.Editing) + val addMemberStatus: StateFlow = _addMemberStatus.asStateFlow() + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private val searchResults = addMemberQuery + .debounce(SEARCH_DEBOUNCE) + .mapLatest { query -> + if (query.isNullOrBlank()) emptyList() else repository.searchMemberSuggestions(query.trim()) + } + + val memberSuggestions: StateFlow> = + combine(searchResults, _members) { suggestions, members -> + val currentMembers = (members as? TeamDetailListUiState.Content)?.items.orEmpty() + val memberIds = currentMembers.mapNotNull { it.userId }.toSet() + suggestions.filterNot { it.member.id in memberIds } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT), emptyList()) + + init { + viewModelScope.launch { loadResources() } + viewModelScope.launch { loadMembers() } + } + + fun selectTab(tab: TeamDetailTab) { + savedStateHandle[KEY_SELECTED_TAB] = tab + } + + fun retryResources() { + _resources.value = TeamDetailListUiState.Loading + viewModelScope.launch { loadResources() } + } + + fun retryMembers() { + _members.value = TeamDetailListUiState.Loading + viewModelScope.launch { loadMembers() } + } + + fun targetFor(resource: TeamResource): TeamResourceTarget { + val webPage = TeamResourceTarget.WebPage(resource.url, resource.label) + return when (resource.provider.id) { + TeamResourceProviderId.TEAM_FOLDER -> folderPath?.let { TeamResourceTarget.Folder(it) } ?: webPage + TeamResourceProviderId.TALK -> TeamResourceTarget.TalkConversation(resource.id, webPage) + else -> webPage + } + } + + fun openAddMemberDialog() { + _addMemberStatus.value = AddTeamMemberStatus.Editing + savedStateHandle[KEY_ADD_MEMBER_QUERY] = "" + } + + fun updateAddMemberQuery(query: String) { + _addMemberStatus.value = AddTeamMemberStatus.Editing + savedStateHandle[KEY_ADD_MEMBER_QUERY] = query + } + + fun dismissAddMemberDialog() { + _addMemberStatus.value = AddTeamMemberStatus.Editing + savedStateHandle[KEY_ADD_MEMBER_QUERY] = null + } + + fun addMember(suggestion: TeamMemberSuggestion) { + _addMemberStatus.value = AddTeamMemberStatus.Adding + viewModelScope.launch { + val result = repository.addMember(teamId, suggestion.member) + if (!result.isSuccess) { + val failed = AddTeamMemberStatus.Failed(result.addTeamMemberErrorMessageId()) + _addMemberStatus.compareAndSet(AddTeamMemberStatus.Adding, failed) + return@launch + } + + loadMembers() + if (_addMemberStatus.compareAndSet(AddTeamMemberStatus.Adding, AddTeamMemberStatus.Editing)) { + dismissAddMemberDialog() + } + } + } + + private suspend fun loadResources() { + val result = repository.getResources(teamId) + if (!result.isSuccess) { + _resources.value = TeamDetailListUiState.Error(result.loadTeamDetailErrorMessageId()) + return + } + + val resources = result.resultData.orEmpty() + if (resources.any { it.provider.id == TeamResourceProviderId.TEAM_FOLDER }) { + folderPath = loadFolderPath() + } + _resources.value = TeamDetailListUiState.Content(resources) + } + + private suspend fun loadFolderPath(): String? { + val result = repository.getFolder(teamId) + if (!result.isSuccess) { + return null + } + return result.resultData?.mountPoint?.let { "/$it/" } + } + + private suspend fun loadMembers() { + val result = repository.getMembers(teamId) + _members.value = if (result.isSuccess) { + TeamDetailListUiState.Content(result.resultData.orEmpty()) + } else { + TeamDetailListUiState.Error(result.loadTeamDetailErrorMessageId()) + } + } + + @AssistedFactory + interface Factory { + fun create(savedStateHandle: SavedStateHandle): TeamDetailViewModel + } + + companion object { + const val KEY_TEAM_ID = "team_id" + private const val KEY_SELECTED_TAB = "selected_tab" + private const val KEY_ADD_MEMBER_QUERY = "add_member_query" + private val SEARCH_DEBOUNCE = 300.milliseconds + private val STOP_TIMEOUT = 5.seconds + } +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt index 4f808eb8be42..2f125775dcc9 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt @@ -20,10 +20,12 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds class TeamsViewModel @AssistedInject constructor( private val repository: TeamsRepository, @@ -36,7 +38,7 @@ class TeamsViewModel @AssistedInject constructor( val uiState: StateFlow = combine(teamsState, searchQuery) { state, query -> if (state is TeamsUiState.Content) state.matching(query) else state - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), TeamsUiState.Loading) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT), TeamsUiState.Loading) val createTeamName: StateFlow = savedStateHandle.getStateFlow(KEY_CREATE_TEAM_NAME, null) @@ -112,7 +114,7 @@ class TeamsViewModel @AssistedInject constructor( companion object { private const val KEY_CREATE_TEAM_NAME = "create_team_name" private const val KEY_SEARCH_QUERY = "search_query" - private const val STOP_TIMEOUT_MILLIS = 5_000L + private val STOP_TIMEOUT = 5.seconds const val MIN_TEAM_NAME_BYTES = 3 const val MAX_TEAM_NAME_BYTES = 127 diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index ac2ed15dcfef..d5772a2b6b35 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -181,4 +181,5 @@ @dimen/standard_padding @dimen/standard_padding 8dp + 240dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e1ebf4dfa3c..0c1a56ed88b3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1490,6 +1490,22 @@ Search teams Clear search No teams found + Resources + Members + Team settings + Nothing shared with this team yet + No members + Could not load team + Team not found + Add member + Search users, groups or email + Could not add member + You are not allowed to add members to this team + This member cannot be added to the team + Owner + Admin + Moderator + Member Could not load teams Could not create team Use %1$d to %2$d characters From 95a65347c954b7ea95cd0cc7adc05fe03bab3aea Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 09:38:18 +0100 Subject: [PATCH 07/17] add nav3 since another screen added to compose Signed-off-by: alperozturk96 --- AGENTS.md | 4 +- app/build.gradle.kts | 7 + app/src/main/AndroidManifest.xml | 6 +- .../client/assistant/AssistantDestination.kt | 50 ++++ .../client/assistant/AssistantScreen.kt | 10 +- .../component}/TaskSelectionAlertDialog.kt | 2 +- .../conversation/ConversationScreen.kt | 2 +- .../nextcloud/client/di/ComponentsModule.kt | 2 +- .../nextcloud/ui/compose/ComposeActivity.kt | 156 ++++++++++++ .../ComposeProcessTextAlias.kt | 4 +- .../ComposeViewModel.kt | 2 +- .../alertDialog/SimpleAlertDialog.kt | 2 +- .../bottomSheet/MoreActionsBottomSheet.kt | 2 +- .../component}/webView/WebPageScreen.kt | 2 +- .../navigation}/ComposeDestination.kt | 2 +- .../ui/compose/navigation/ComposeNavHost.kt | 50 ++++ .../ui/compose/navigation/ComposeNavigator.kt | 50 ++++ .../ui/composeActivity/ComposeActivity.kt | 227 ------------------ .../ui/fileactions/ClientIntegration.kt | 4 +- .../ui/teams/detail/TeamResourceOpener.kt | 15 +- .../ui/teams/navigation/TeamsNavEntries.kt | 71 ++++++ .../main/java/com/owncloud/android/MainApp.kt | 2 +- .../ui/activity/FileDisplayActivity.kt | 2 +- .../navigation/DrawerMenuItemClickHandler.kt | 2 +- .../drawer/navigation/DrawerNavigator.kt | 4 +- gradle/libs.versions.toml | 10 +- gradle/verification-metadata.xml | 7 + 27 files changed, 432 insertions(+), 265 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/assistant/AssistantDestination.kt rename app/src/main/java/com/nextcloud/{ui/composeComponents/alertDialog => client/assistant/component}/TaskSelectionAlertDialog.kt (97%) create mode 100644 app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt rename app/src/main/java/com/nextcloud/ui/{composeActivity => compose}/ComposeProcessTextAlias.kt (90%) rename app/src/main/java/com/nextcloud/ui/{composeActivity => compose}/ComposeViewModel.kt (93%) rename app/src/main/java/com/nextcloud/ui/{composeComponents => compose/component}/alertDialog/SimpleAlertDialog.kt (97%) rename app/src/main/java/com/nextcloud/ui/{composeComponents => compose/component}/bottomSheet/MoreActionsBottomSheet.kt (98%) rename app/src/main/java/com/nextcloud/ui/{composeComponents => compose/component}/webView/WebPageScreen.kt (98%) rename app/src/main/java/com/nextcloud/ui/{composeActivity => compose/navigation}/ComposeDestination.kt (97%) create mode 100644 app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt create mode 100644 app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavigator.kt delete mode 100644 app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/navigation/TeamsNavEntries.kt diff --git a/AGENTS.md b/AGENTS.md index 62d0a7127e8e..17b7622f8673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,8 +93,8 @@ Avoid creating source files that implement multiple types; instead, place each t ### Jetpack Compose Modern UI is built with Jetpack Compose (Material 3). Key directories: -- `com.nextcloud.ui.composeActivity` - Activities hosting Compose content -- `com.nextcloud.ui.composeComponents` - Reusable Compose components +- `com.nextcloud.ui.compose` - Activities hosting Compose content +- `com.nextcloud.ui.compose.component` - Reusable Compose components - `com.nextcloud.client.assistant` - Compose-based Assistant screens and features Use `StateFlow` and `MutableStateFlow` in ViewModels for state management. Collect state in Compose functions with `collectAsState()`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d95ccd45ec18..5c3eef6ac574 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -406,6 +406,13 @@ dependencies { implementation(libs.lifecycle.service) implementation(libs.lifecycle.runtime.ktx) implementation(libs.lifecycle.runtime.compose) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.viewmodel.navigation3) + // endregion + + // region Navigation 3 + implementation(libs.navigation3.runtime) + implementation(libs.navigation3.ui) // endregion // region JUnit diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8014d33ba07c..86832ea81de6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -132,13 +132,13 @@ android:resource="@xml/app_config" /> diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantDestination.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantDestination.kt new file mode 100644 index 000000000000..a6123ccf2330 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantDestination.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.assistant + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import com.nextcloud.client.assistant.chat.ChatViewModel +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.ui.compose.ComposeViewModel +import com.nextcloud.ui.compose.navigation.ComposeDestination +import com.owncloud.android.lib.resources.status.OCCapability + +@Composable +fun AssistantDestination( + screen: ComposeDestination.AssistantScreen, + composeViewModel: ComposeViewModel, + accountName: String, + nextcloudClient: NextcloudClient?, + capability: OCCapability? +) { + val activity = LocalActivity.current ?: return + val client = nextcloudClient ?: return + val ocCapability = capability ?: return + val remoteRepository = AssistantRemoteRepositoryImpl(client, ocCapability) + + AssistantScreen( + composeViewModel = composeViewModel, + viewModel = AssistantViewModel( + accountName = accountName, + remoteRepository = remoteRepository, + localRepository = AssistantLocalRepositoryImpl(NextcloudDatabase.instance().assistantDao()), + sessionIdArg = screen.sessionId + ), + chatViewModel = ChatViewModel(remoteRepository), + conversationViewModel = ConversationViewModel( + remoteRepository = ConversationRemoteRepositoryImpl(client) + ), + activity = activity, + capability = ocCapability + ) +} 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 3802bb583157..2659ca3b39ad 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nextcloud.client.assistant.chat.ChatContent import com.nextcloud.client.assistant.chat.ChatViewModel +import com.nextcloud.client.assistant.component.TaskSelectionAlertDialog import com.nextcloud.client.assistant.conversation.ConversationScreen import com.nextcloud.client.assistant.conversation.ConversationViewModel import com.nextcloud.client.assistant.conversation.repository.MockConversationRemoteRepository @@ -70,11 +71,10 @@ import com.nextcloud.client.assistant.task.TaskView import com.nextcloud.client.assistant.taskTypes.TaskTypesRow import com.nextcloud.client.assistant.translate.TranslationScreen import com.nextcloud.client.assistant.translate.TranslationViewModel -import com.nextcloud.ui.composeActivity.ComposeActivity -import com.nextcloud.ui.composeActivity.ComposeViewModel -import com.nextcloud.ui.composeComponents.alertDialog.SimpleAlertDialog -import com.nextcloud.ui.composeComponents.alertDialog.TaskSelectionAlertDialog -import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet +import com.nextcloud.ui.compose.ComposeActivity +import com.nextcloud.ui.compose.ComposeViewModel +import com.nextcloud.ui.compose.component.alertDialog.SimpleAlertDialog +import com.nextcloud.ui.compose.component.bottomSheet.MoreActionsBottomSheet import com.nextcloud.utils.extensions.getChat import com.owncloud.android.R import com.owncloud.android.lib.resources.assistant.v2.model.Task diff --git a/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/TaskSelectionAlertDialog.kt b/app/src/main/java/com/nextcloud/client/assistant/component/TaskSelectionAlertDialog.kt similarity index 97% rename from app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/TaskSelectionAlertDialog.kt rename to app/src/main/java/com/nextcloud/client/assistant/component/TaskSelectionAlertDialog.kt index 9cae1dd238fb..ddffcd56da1e 100644 --- a/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/TaskSelectionAlertDialog.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/component/TaskSelectionAlertDialog.kt @@ -4,7 +4,7 @@ * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.ui.composeComponents.alertDialog +package com.nextcloud.client.assistant.component import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn 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 index 8d58a5d32b3e..4e820601112b 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/conversation/ConversationScreen.kt @@ -53,7 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nextcloud.client.assistant.conversation.model.ConversationScreenState -import com.nextcloud.ui.composeComponents.bottomSheet.MoreActionsBottomSheet +import com.nextcloud.ui.compose.component.bottomSheet.MoreActionsBottomSheet import com.owncloud.android.R import com.owncloud.android.lib.resources.assistant.chat.model.Conversation diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt index 745696d45571..14d6ddbc2ea9 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt @@ -28,7 +28,7 @@ import com.nextcloud.client.widget.DashboardWidgetConfigurationActivity import com.nextcloud.client.widget.DashboardWidgetProvider import com.nextcloud.client.widget.DashboardWidgetService import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet -import com.nextcloud.ui.composeActivity.ComposeActivity +import com.nextcloud.ui.compose.ComposeActivity import com.nextcloud.ui.dialog.account.ChooseAccountDialogFragment import com.nextcloud.ui.dialog.account.onlineStatus.SetOnlineStatusBottomSheet import com.nextcloud.ui.dialog.account.statusMessage.SetStatusMessageBottomSheet diff --git a/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt new file mode 100644 index 000000000000..bf78d005fd1f --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt @@ -0,0 +1,156 @@ +/* + * 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.ui.compose + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.viewModels +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.view.isVisible +import com.nextcloud.client.assistant.AssistantDestination +import com.nextcloud.client.di.ViewModelFactory +import com.nextcloud.common.NextcloudClient +import com.nextcloud.ui.compose.navigation.ComposeDestination +import com.nextcloud.ui.compose.navigation.ComposeNavHost +import com.nextcloud.ui.compose.navigation.rememberComposeNavigator +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.R +import com.owncloud.android.databinding.ActivityComposeBinding +import com.owncloud.android.ui.activity.drawer.DrawerActivity +import javax.inject.Inject + +class ComposeActivity : DrawerActivity() { + + @Inject + lateinit var vmFactory: ViewModelFactory + + lateinit var binding: ActivityComposeBinding + private val composeViewModel: ComposeViewModel by viewModels() + + private val destination: ComposeDestination by lazy { + intent.getParcelableArgument(DESTINATION, ComposeDestination::class.java) + ?: ComposeDestination.getAssistantScreen(this) + } + + companion object { + const val DESTINATION = "DESTINATION" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityComposeBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupActivityUIFor(destination) + + binding.composeView.setContent { + MaterialTheme( + colorScheme = viewThemeUtils.getColorScheme(this), + content = { + Content() + } + ) + } + + processText(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + processText(intent) + } + + private fun processText(intent: Intent) { + val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) + if (text.isNullOrEmpty()) { + return + } + + composeViewModel.updateSelectedText(text.toString()) + } + + override val menuItemId: Int + get() = if (destination is ComposeDestination.TeamsScreen) Menu.NONE else R.id.nav_assistant + + override fun onResume() { + super.onResume() + highlightNavigationViewItem(menuItemId) + } + + private fun setupActivityUIFor(destination: ComposeDestination) { + when (destination) { + is ComposeDestination.AssistantScreen -> setupDrawerScreen(destination.title) + is ComposeDestination.TeamsScreen -> setupDrawerScreen(destination.title) + else -> setSupportActionBar(null) + } + } + + private fun setupDrawerScreen(title: String) { + setupDrawer(menuItemId) + showMenuAndTitleActionBar(title) { + openDrawer() + } + } + + private fun showActivityBarsFor(screen: ComposeDestination) { + val isDrawerScreen = screen is ComposeDestination.AssistantScreen || screen is ComposeDestination.TeamsScreen + findViewById(R.id.appbar)?.isVisible = isDrawerScreen + binding.bottomNavigation.isVisible = isDrawerScreen + + if (screen is ComposeDestination.AssistantScreen) { + binding.bottomNavigation.menu.findItem(R.id.nav_assistant)?.isChecked = true + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + toggleDrawer() + true + } + + else -> super.onOptionsItemSelected(item) + } + + @Composable + private fun Content() { + var nextcloudClient by remember { mutableStateOf(null) } + val navigator = rememberComposeNavigator(destination, onExit = ::finish) + + LaunchedEffect(Unit) { + nextcloudClient = clientRepository.getNextcloudClient() + } + + LaunchedEffect(navigator.current) { + showActivityBarsFor(navigator.current) + } + + ComposeNavHost( + navigator = navigator, + vmFactory = vmFactory, + user = userAccountManager.user, + nextcloudClient = nextcloudClient + ) { screen -> + AssistantDestination( + screen = screen, + composeViewModel = composeViewModel, + accountName = userAccountManager.user.accountName, + nextcloudClient = nextcloudClient, + capability = capabilities.orElse(null) + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeProcessTextAlias.kt b/app/src/main/java/com/nextcloud/ui/compose/ComposeProcessTextAlias.kt similarity index 90% rename from app/src/main/java/com/nextcloud/ui/composeActivity/ComposeProcessTextAlias.kt rename to app/src/main/java/com/nextcloud/ui/compose/ComposeProcessTextAlias.kt index 5b280dcc9a02..a2c70d62c52e 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeProcessTextAlias.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/ComposeProcessTextAlias.kt @@ -4,7 +4,7 @@ * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.ui.composeActivity +package com.nextcloud.ui.compose import android.content.ComponentName import android.content.Context @@ -22,7 +22,7 @@ class ComposeProcessTextAlias @Inject constructor(private val context: Context) val componentName = ComponentName( context, - "com.nextcloud.ui.composeActivity.ComposeProcessTextAlias" + "com.nextcloud.ui.compose.ComposeProcessTextAlias" ) val newState = if (isAssistantAvailable) { diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt b/app/src/main/java/com/nextcloud/ui/compose/ComposeViewModel.kt similarity index 93% rename from app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt rename to app/src/main/java/com/nextcloud/ui/compose/ComposeViewModel.kt index 86b94a633cd8..4571b7c8606d 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/ComposeViewModel.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.ui.composeActivity +package com.nextcloud.ui.compose import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/SimpleAlertDialog.kt b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/SimpleAlertDialog.kt similarity index 97% rename from app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/SimpleAlertDialog.kt rename to app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/SimpleAlertDialog.kt index 987aa3d0059d..6076f5e73403 100644 --- a/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/SimpleAlertDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/SimpleAlertDialog.kt @@ -5,7 +5,7 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ -package com.nextcloud.ui.composeComponents.alertDialog +package com.nextcloud.ui.compose.component.alertDialog import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer diff --git a/app/src/main/java/com/nextcloud/ui/composeComponents/bottomSheet/MoreActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/compose/component/bottomSheet/MoreActionsBottomSheet.kt similarity index 98% rename from app/src/main/java/com/nextcloud/ui/composeComponents/bottomSheet/MoreActionsBottomSheet.kt rename to app/src/main/java/com/nextcloud/ui/compose/component/bottomSheet/MoreActionsBottomSheet.kt index 94dd7c39b9b6..3fccf5971400 100644 --- a/app/src/main/java/com/nextcloud/ui/composeComponents/bottomSheet/MoreActionsBottomSheet.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/component/bottomSheet/MoreActionsBottomSheet.kt @@ -4,7 +4,7 @@ * SPDX-FileCopyrightText: 2024 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ -package com.nextcloud.ui.composeComponents.bottomSheet +package com.nextcloud.ui.compose.component.bottomSheet import android.annotation.SuppressLint import androidx.compose.foundation.clickable diff --git a/app/src/main/java/com/nextcloud/ui/composeComponents/webView/WebPageScreen.kt b/app/src/main/java/com/nextcloud/ui/compose/component/webView/WebPageScreen.kt similarity index 98% rename from app/src/main/java/com/nextcloud/ui/composeComponents/webView/WebPageScreen.kt rename to app/src/main/java/com/nextcloud/ui/compose/component/webView/WebPageScreen.kt index 72d4b4c4328d..08ff06201ba2 100644 --- a/app/src/main/java/com/nextcloud/ui/composeComponents/webView/WebPageScreen.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/component/webView/WebPageScreen.kt @@ -4,7 +4,7 @@ * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.ui.composeComponents.webView +package com.nextcloud.ui.compose.component.webView import android.annotation.SuppressLint import android.net.Uri diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt similarity index 97% rename from app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt rename to app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt index b38df7b1f16e..187c129884a7 100644 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeDestination.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt @@ -5,7 +5,7 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ -package com.nextcloud.ui.composeActivity +package com.nextcloud.ui.compose.navigation import android.content.Context import android.os.Parcelable diff --git a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt new file mode 100644 index 000000000000..6a7720ac07cc --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.compose.navigation + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.nextcloud.client.account.User +import com.nextcloud.client.di.ViewModelFactory +import com.nextcloud.common.NextcloudClient +import com.nextcloud.ui.ClientIntegrationScreen +import com.nextcloud.ui.compose.component.webView.WebPageScreen +import com.nextcloud.ui.teams.navigation.teamsEntries + +@Composable +fun ComposeNavHost( + navigator: ComposeNavigator, + vmFactory: ViewModelFactory, + user: User, + nextcloudClient: NextcloudClient?, + assistantContent: @Composable (ComposeDestination.AssistantScreen) -> Unit +) { + NavDisplay( + backStack = navigator.backStack, + onBack = navigator::goBack, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { screen -> assistantContent(screen) } + + entry { screen -> + ClientIntegrationScreen(screen.data, nextcloudClient?.baseUri.toString()) + } + + teamsEntries(navigator, vmFactory, user) + + entry { screen -> + WebPageScreen(screen.title, screen.url, nextcloudClient, onClose = navigator::goBack) + } + } + ) +} diff --git a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavigator.kt b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavigator.kt new file mode 100644 index 000000000000..837de59d08a7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavigator.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.compose.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList + +class ComposeNavigator(private val entries: SnapshotStateList, private val onExit: () -> Unit) { + + val backStack: List + get() = entries + + val current: ComposeDestination + get() = entries.last() + + fun navigate(destination: ComposeDestination) { + entries.add(destination) + } + + fun goBack() { + if (entries.size > 1) { + entries.removeLastOrNull() + } else { + onExit() + } + } +} + +private val backStackSaver = listSaver, ComposeDestination>( + save = { it.toList() }, + restore = { it.toMutableStateList() } +) + +@Composable +fun rememberComposeNavigator(startDestination: ComposeDestination, onExit: () -> Unit): ComposeNavigator { + val entries = rememberSaveable(saver = backStackSaver) { mutableStateListOf(startDestination) } + val currentOnExit by rememberUpdatedState(onExit) + return remember(entries) { ComposeNavigator(entries) { currentOnExit() } } +} diff --git a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt deleted file mode 100644 index 642e1d482745..000000000000 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt +++ /dev/null @@ -1,227 +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.ui.composeActivity - -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.viewModels -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModelProvider -import com.nextcloud.client.assistant.AssistantScreen -import com.nextcloud.client.assistant.AssistantViewModel -import com.nextcloud.client.assistant.chat.ChatViewModel -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.client.di.ViewModelFactory -import com.nextcloud.common.NextcloudClient -import com.nextcloud.android.lib.resources.teams.model.Team -import com.nextcloud.ui.ClientIntegrationScreen -import com.nextcloud.ui.composeComponents.webView.WebPageScreen -import com.nextcloud.ui.teams.detail.TeamResourceOpener -import com.nextcloud.ui.teams.detail.TeamDetailScreen -import com.nextcloud.ui.teams.detail.model.TeamResourceTarget -import com.nextcloud.ui.teams.detail.viewmodel.TeamDetailViewModel -import com.nextcloud.ui.teams.TeamsScreen -import com.nextcloud.ui.teams.viewmodel.TeamsViewModel -import com.nextcloud.utils.extensions.getParcelableArgument -import com.owncloud.android.R -import com.owncloud.android.databinding.ActivityComposeBinding -import com.owncloud.android.ui.activity.drawer.DrawerActivity -import javax.inject.Inject - -class ComposeActivity : DrawerActivity() { - - @Inject - lateinit var vmFactory: ViewModelFactory - - lateinit var binding: ActivityComposeBinding - private val composeViewModel: ComposeViewModel by viewModels() - - private val destination: ComposeDestination by lazy { - intent.getParcelableArgument(DESTINATION, ComposeDestination::class.java) - ?: ComposeDestination.getAssistantScreen(this) - } - - companion object { - const val DESTINATION = "DESTINATION" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityComposeBinding.inflate(layoutInflater) - setContentView(binding.root) - - setupActivityUIFor(destination) - - binding.composeView.setContent { - MaterialTheme( - colorScheme = viewThemeUtils.getColorScheme(this), - content = { - Content() - } - ) - } - - processText(intent) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - processText(intent) - } - - private fun processText(intent: Intent) { - val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) - if (text.isNullOrEmpty()) { - return - } - - composeViewModel.updateSelectedText(text.toString()) - } - - override val menuItemId: Int - get() = when (destination) { - is ComposeDestination.TeamsScreen, - is ComposeDestination.TeamDetailScreen, - is ComposeDestination.WebPageScreen -> Menu.NONE - - else -> R.id.nav_assistant - } - - override fun onResume() { - super.onResume() - highlightNavigationViewItem(menuItemId) - } - - private fun setupActivityUIFor(destination: ComposeDestination) { - when (destination) { - is ComposeDestination.AssistantScreen -> setupDrawerScreen(destination.title) - is ComposeDestination.TeamsScreen -> setupDrawerScreen(destination.title) - is ComposeDestination.ClientIntegrationScreen, - is ComposeDestination.TeamDetailScreen, - is ComposeDestination.WebPageScreen -> hideActionBar() - } - } - - private fun hideActionBar() { - setSupportActionBar(null) - findViewById(R.id.appbar)?.let { - it.visibility = View.GONE - } - } - - private fun openTeam(team: Team) { - val destination = ComposeDestination.TeamDetailScreen(team.id, team.displayName, team.url) - val intent = Intent(this, ComposeActivity::class.java) - .putExtra(DESTINATION, destination) - .putExtra(TeamDetailViewModel.KEY_TEAM_ID, team.id) - startActivity(intent) - } - - private fun setupDrawerScreen(title: String) { - setupDrawer(menuItemId) - showMenuAndTitleActionBar(title) { - openDrawer() - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - android.R.id.home -> { - toggleDrawer() - true - } - - else -> super.onOptionsItemSelected(item) - } - - @Composable - private fun Content() { - var nextcloudClient by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - nextcloudClient = clientRepository.getNextcloudClient() - } - - when (val screen = destination) { - is ComposeDestination.AssistantScreen -> { - binding.bottomNavigation.menu.findItem(R.id.nav_assistant).run { - isChecked = true - } - - val dao = NextcloudDatabase.instance().assistantDao() - val client = nextcloudClient ?: return - val optionalCapability = capabilities - if (optionalCapability.isEmpty) { - return - } - val capability = optionalCapability.get() - val remoteRepository = AssistantRemoteRepositoryImpl(client, capability) - - AssistantScreen( - composeViewModel = composeViewModel, - viewModel = AssistantViewModel( - accountName = userAccountManager.user.accountName, - remoteRepository = remoteRepository, - localRepository = AssistantLocalRepositoryImpl(dao), - sessionIdArg = screen.sessionId - ), - chatViewModel = ChatViewModel(remoteRepository), - conversationViewModel = ConversationViewModel( - remoteRepository = ConversationRemoteRepositoryImpl(client) - ), - activity = this, - capability = capability - ) - } - - is ComposeDestination.ClientIntegrationScreen -> { - binding.bottomNavigation.visibility = View.GONE - ClientIntegrationScreen(screen.data, nextcloudClient?.baseUri.toString()) - } - - is ComposeDestination.TeamsScreen -> { - val viewModel = remember { - ViewModelProvider(this, vmFactory)[TeamsViewModel::class.java] - } - TeamsScreen(viewModel, onTeamClick = ::openTeam) - } - - is ComposeDestination.TeamDetailScreen -> { - binding.bottomNavigation.visibility = View.GONE - val viewModel = remember { - ViewModelProvider(this, vmFactory)[TeamDetailViewModel::class.java] - } - val opener = remember { TeamResourceOpener(this, userAccountManager.user) } - TeamDetailScreen( - viewModel = viewModel, - title = screen.title, - onBack = ::finish, - onOpenSettings = { opener.open(TeamResourceTarget.WebPage(screen.settingsUrl, screen.title)) }, - onOpenResource = opener::open - ) - } - - is ComposeDestination.WebPageScreen -> { - binding.bottomNavigation.visibility = View.GONE - WebPageScreen(screen.title, screen.url, nextcloudClient, onClose = ::finish) - } - } - } -} diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt b/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt index 1857507773b8..ef0b7b247e73 100644 --- a/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt +++ b/app/src/main/java/com/nextcloud/ui/fileactions/ClientIntegration.kt @@ -35,8 +35,8 @@ import com.nextcloud.client.account.User import com.nextcloud.common.JSONRequestBody import com.nextcloud.operations.GetMethod import com.nextcloud.operations.PostMethod -import com.nextcloud.ui.composeActivity.ComposeActivity -import com.nextcloud.ui.composeActivity.ComposeDestination +import com.nextcloud.ui.compose.ComposeActivity +import com.nextcloud.ui.compose.navigation.ComposeDestination import com.nextcloud.utils.GlideHelper import com.nextcloud.utils.SnackbarUtil import com.nextcloud.utils.view.ScreenMetrics diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt index 65ebe38d5c8c..14476daad715 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt @@ -11,12 +11,14 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import com.nextcloud.client.account.User -import com.nextcloud.ui.composeActivity.ComposeActivity -import com.nextcloud.ui.composeActivity.ComposeDestination import com.nextcloud.ui.teams.detail.model.TeamResourceTarget import com.owncloud.android.ui.activity.FileDisplayActivity -class TeamResourceOpener(private val activity: Activity, private val user: User) { +class TeamResourceOpener( + private val activity: Activity, + private val user: User, + private val openWebPage: (TeamResourceTarget.WebPage) -> Unit +) { fun open(target: TeamResourceTarget) { when (target) { @@ -55,13 +57,6 @@ class TeamResourceOpener(private val activity: Activity, private val user: User) }.build() } - private fun openWebPage(target: TeamResourceTarget.WebPage) { - val intent = Intent(activity, ComposeActivity::class.java).apply { - putExtra(ComposeActivity.DESTINATION, ComposeDestination.WebPageScreen(target.title, target.url)) - } - activity.startActivity(intent) - } - companion object { private const val TALK_SCHEME = "nextcloudtalk" private const val TALK_CALL_PATH = "/call/" diff --git a/app/src/main/java/com/nextcloud/ui/teams/navigation/TeamsNavEntries.kt b/app/src/main/java/com/nextcloud/ui/teams/navigation/TeamsNavEntries.kt new file mode 100644 index 000000000000..05d8eda0fedb --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/navigation/TeamsNavEntries.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.navigation + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.core.os.bundleOf +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.EntryProviderScope +import com.nextcloud.client.account.User +import com.nextcloud.client.di.ViewModelFactory +import com.nextcloud.ui.compose.navigation.ComposeDestination +import com.nextcloud.ui.compose.navigation.ComposeNavigator +import com.nextcloud.ui.teams.TeamsScreen +import com.nextcloud.ui.teams.detail.TeamDetailScreen +import com.nextcloud.ui.teams.detail.TeamResourceOpener +import com.nextcloud.ui.teams.detail.viewmodel.TeamDetailViewModel +import com.nextcloud.ui.teams.viewmodel.TeamsViewModel + +fun EntryProviderScope.teamsEntries( + navigator: ComposeNavigator, + vmFactory: ViewModelFactory, + user: User +) { + entry { + TeamsScreen( + viewModel = viewModel(factory = vmFactory), + onTeamClick = { team -> + navigator.navigate(ComposeDestination.TeamDetailScreen(team.id, team.displayName, team.url)) + } + ) + } + + entry { screen -> + val activity = checkNotNull(LocalActivity.current) + val resourceOpener = remember(activity) { + TeamResourceOpener(activity, user) { page -> + navigator.navigate(ComposeDestination.WebPageScreen(page.title, page.url)) + } + } + + TeamDetailScreen( + viewModel = teamDetailViewModel(vmFactory, screen.teamId), + title = screen.title, + onBack = navigator::goBack, + onOpenSettings = { + navigator.navigate(ComposeDestination.WebPageScreen(screen.title, screen.settingsUrl)) + }, + onOpenResource = resourceOpener::open + ) + } +} + +@Composable +private fun teamDetailViewModel(vmFactory: ViewModelFactory, teamId: String): TeamDetailViewModel { + val owner = LocalViewModelStoreOwner.current as? HasDefaultViewModelProviderFactory + val extras = MutableCreationExtras(owner?.defaultViewModelCreationExtras ?: CreationExtras.Empty).apply { + set(DEFAULT_ARGS_KEY, bundleOf(TeamDetailViewModel.KEY_TEAM_ID to teamId)) + } + return viewModel(factory = vmFactory, extras = extras) +} diff --git a/app/src/main/java/com/owncloud/android/MainApp.kt b/app/src/main/java/com/owncloud/android/MainApp.kt index f6bfa309fa53..db4d3fb4e7af 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.kt +++ b/app/src/main/java/com/owncloud/android/MainApp.kt @@ -40,7 +40,7 @@ import com.nextcloud.client.onboarding.OnboardingService import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.preferences.AppPreferencesImpl import com.nextcloud.client.systembars.SystemBarBackgroundCallbacks -import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias +import com.nextcloud.ui.compose.ComposeProcessTextAlias import com.nextcloud.utils.extensions.registerBroadcastReceiver import com.nextcloud.utils.extensions.showToast import com.nextcloud.utils.mdm.MDMConfig diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index d3ae4fa89eee..feb1d6ef665b 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -67,7 +67,7 @@ import com.nextcloud.client.player.model.file.toPlaybackCollection import com.nextcloud.client.player.ui.audio.AudioPlayerLauncher import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.utils.IntentUtil -import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias +import com.nextcloud.ui.compose.ComposeProcessTextAlias import com.nextcloud.utils.SnackbarUtil import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getSerializableArgument diff --git a/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerMenuItemClickHandler.kt b/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerMenuItemClickHandler.kt index c5c6bf2d32ea..a278d824a113 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerMenuItemClickHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerMenuItemClickHandler.kt @@ -7,7 +7,7 @@ package com.owncloud.android.ui.activity.drawer.navigation import android.view.MenuItem -import com.nextcloud.ui.composeActivity.ComposeActivity +import com.nextcloud.ui.compose.ComposeActivity import com.nextcloud.utils.extensions.navigateToAllFiles import com.owncloud.android.R import com.owncloud.android.lib.common.utils.Log_OC diff --git a/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerNavigator.kt b/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerNavigator.kt index 739f6cf8a146..26d8e3f18e48 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerNavigator.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/drawer/navigation/DrawerNavigator.kt @@ -9,8 +9,8 @@ package com.owncloud.android.ui.activity.drawer.navigation import android.content.Intent import android.view.MenuItem import androidx.fragment.app.commit -import com.nextcloud.ui.composeActivity.ComposeActivity -import com.nextcloud.ui.composeActivity.ComposeDestination +import com.nextcloud.ui.compose.ComposeActivity +import com.nextcloud.ui.compose.navigation.ComposeDestination import com.nextcloud.utils.extensions.navigateToAllFiles import com.owncloud.android.MainApp import com.owncloud.android.R diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31b10131d7eb..d4c75fbb51cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later [versions] +activityComposeVersion = "1.13.0" androidCommonLibraryVersion = "0.34.0" androidGifDrawableVersion = "1.2.32" androidImageCropperVersion = "4.7.0" @@ -57,6 +58,7 @@ markwonVersion = "4.6.2" materialIconsCoreVersion = "1.7.8" materialVersion = "1.14.0" media3 = "1.11.1" +navigation3Version = "1.2.1" mockitoKotlinVersion = "6.4.0" mockitoVersion = "5.22.0" mockkVersion = "1.14.12" @@ -167,6 +169,12 @@ lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", v lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtxVersion" } lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycleViewmodelKtxVersion" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtxVersion" } +lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelKtxVersion" } +lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelKtxVersion" } + +# Navigation 3 +navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3Version" } +navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3Version" } # ksp ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" } @@ -178,7 +186,7 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-material3 = { module = "androidx.compose.material3:material3" } -compose-activity = { module = "androidx.activity:activity-compose" } +compose-activity = { module = "androidx.activity:activity-compose", version.ref = "activityComposeVersion" } # Media3 media3-datasource = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index c0b04ea69bc9..2edf1fe4fa70 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -54,6 +54,7 @@ + @@ -64,6 +65,7 @@ + @@ -77,6 +79,7 @@ + @@ -93,6 +96,7 @@ + @@ -1353,6 +1357,9 @@ + + + From 7a5fb7f37c28ba3b91ac603bb9f189984ec1c7c2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 10:00:54 +0100 Subject: [PATCH 08/17] centralize navigation for compose Signed-off-by: alperozturk96 --- .../client/assistant/AssistantDestination.kt | 50 ------- .../client/assistant/AssistantScreen.kt | 4 + .../nextcloud/ui/compose/ComposeActivity.kt | 28 +--- .../component/webView/WebPageScreen.kt | 21 ++- .../compose/navigation/ComposeDestination.kt | 12 +- .../ui/compose/navigation/ComposeNavHost.kt | 133 ++++++++++++++++-- .../ui/teams/component/CreateTeamDialog.kt | 6 +- .../ui/teams/navigation/TeamsNavEntries.kt | 71 ---------- app/src/main/res/values/strings.xml | 5 +- 9 files changed, 154 insertions(+), 176 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/client/assistant/AssistantDestination.kt delete mode 100644 app/src/main/java/com/nextcloud/ui/teams/navigation/TeamsNavEntries.kt diff --git a/app/src/main/java/com/nextcloud/client/assistant/AssistantDestination.kt b/app/src/main/java/com/nextcloud/client/assistant/AssistantDestination.kt deleted file mode 100644 index a6123ccf2330..000000000000 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantDestination.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.nextcloud.client.assistant - -import androidx.activity.compose.LocalActivity -import androidx.compose.runtime.Composable -import com.nextcloud.client.assistant.chat.ChatViewModel -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.ui.compose.ComposeViewModel -import com.nextcloud.ui.compose.navigation.ComposeDestination -import com.owncloud.android.lib.resources.status.OCCapability - -@Composable -fun AssistantDestination( - screen: ComposeDestination.AssistantScreen, - composeViewModel: ComposeViewModel, - accountName: String, - nextcloudClient: NextcloudClient?, - capability: OCCapability? -) { - val activity = LocalActivity.current ?: return - val client = nextcloudClient ?: return - val ocCapability = capability ?: return - val remoteRepository = AssistantRemoteRepositoryImpl(client, ocCapability) - - AssistantScreen( - composeViewModel = composeViewModel, - viewModel = AssistantViewModel( - accountName = accountName, - remoteRepository = remoteRepository, - localRepository = AssistantLocalRepositoryImpl(NextcloudDatabase.instance().assistantDao()), - sessionIdArg = screen.sessionId - ), - chatViewModel = ChatViewModel(remoteRepository), - conversationViewModel = ConversationViewModel( - remoteRepository = ConversationRemoteRepositoryImpl(client) - ), - activity = activity, - capability = ocCapability - ) -} 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 2659ca3b39ad..3cb77eab232f 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -8,6 +8,7 @@ package com.nextcloud.client.assistant import android.app.Activity +import android.annotation.SuppressLint import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -289,6 +290,7 @@ fun AssistantScreen( val task = (screenState as AssistantScreenState.Translation).task val textToTranslate = task?.input?.input ?: selectedText ?: "" + @SuppressLint("ViewModelConstructorInComposable") val translationViewModel = TranslationViewModel(remoteRepository = viewModel.getRemoteRepository()) @@ -524,6 +526,7 @@ private fun EmptyContent(paddingValues: PaddingValues, iconId: Int?, description } @Suppress("MagicNumber") +@SuppressLint("ViewModelConstructorInComposable") @Composable @Preview private fun AssistantScreenPreview() { @@ -544,6 +547,7 @@ private fun AssistantScreenPreview() { } @Suppress("MagicNumber") +@SuppressLint("ViewModelConstructorInComposable") @Composable @Preview private fun AssistantEmptyScreenPreview() { diff --git a/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt index bf78d005fd1f..6ecbde440608 100644 --- a/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt @@ -16,14 +16,8 @@ import androidx.activity.viewModels import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.core.view.isVisible -import com.nextcloud.client.assistant.AssistantDestination import com.nextcloud.client.di.ViewModelFactory -import com.nextcloud.common.NextcloudClient import com.nextcloud.ui.compose.navigation.ComposeDestination import com.nextcloud.ui.compose.navigation.ComposeNavHost import com.nextcloud.ui.compose.navigation.rememberComposeNavigator @@ -39,7 +33,7 @@ class ComposeActivity : DrawerActivity() { lateinit var vmFactory: ViewModelFactory lateinit var binding: ActivityComposeBinding - private val composeViewModel: ComposeViewModel by viewModels() + val composeViewModel: ComposeViewModel by viewModels() private val destination: ComposeDestination by lazy { intent.getParcelableArgument(DESTINATION, ComposeDestination::class.java) @@ -127,30 +121,12 @@ class ComposeActivity : DrawerActivity() { @Composable private fun Content() { - var nextcloudClient by remember { mutableStateOf(null) } val navigator = rememberComposeNavigator(destination, onExit = ::finish) - LaunchedEffect(Unit) { - nextcloudClient = clientRepository.getNextcloudClient() - } - LaunchedEffect(navigator.current) { showActivityBarsFor(navigator.current) } - ComposeNavHost( - navigator = navigator, - vmFactory = vmFactory, - user = userAccountManager.user, - nextcloudClient = nextcloudClient - ) { screen -> - AssistantDestination( - screen = screen, - composeViewModel = composeViewModel, - accountName = userAccountManager.user.accountName, - nextcloudClient = nextcloudClient, - capability = capabilities.orElse(null) - ) - } + ComposeNavHost(navigator) } } diff --git a/app/src/main/java/com/nextcloud/ui/compose/component/webView/WebPageScreen.kt b/app/src/main/java/com/nextcloud/ui/compose/component/webView/WebPageScreen.kt index 08ff06201ba2..a30764b37c09 100644 --- a/app/src/main/java/com/nextcloud/ui/compose/component/webView/WebPageScreen.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/component/webView/WebPageScreen.kt @@ -9,6 +9,7 @@ package com.nextcloud.ui.compose.component.webView import android.annotation.SuppressLint import android.net.Uri import android.webkit.CookieManager +import android.webkit.RenderProcessGoneDetail import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebViewClient @@ -47,7 +48,7 @@ private const val AUTHORIZATION_HEADER = "Authorization" @OptIn(ExperimentalMaterial3Api::class) @Composable -fun WebPageScreen(title: String, url: String, client: NextcloudClient?, onClose: () -> Unit) { +fun WebPageScreen(title: String, url: String, client: NextcloudClient, onClose: () -> Unit) { var webView by remember { mutableStateOf(null) } var progress by remember { mutableIntStateOf(0) } var canGoBack by remember { mutableStateOf(false) } @@ -80,15 +81,14 @@ fun WebPageScreen(title: String, url: String, client: NextcloudClient?, onClose: ) } - client ?: return@Column - AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> WebView(context).apply { configure( onProgressChange = { progress = it }, - onHistoryChange = { canGoBack = it } + onHistoryChange = { canGoBack = it }, + onRendererGone = onClose ) loadAuthenticated(url, client) webView = this @@ -101,7 +101,11 @@ fun WebPageScreen(title: String, url: String, client: NextcloudClient?, onClose: } @SuppressLint("SetJavaScriptEnabled") -private fun WebView.configure(onProgressChange: (Int) -> Unit, onHistoryChange: (Boolean) -> Unit) { +private fun WebView.configure( + onProgressChange: (Int) -> Unit, + onHistoryChange: (Boolean) -> Unit, + onRendererGone: () -> Unit +) { settings.javaScriptEnabled = true settings.domStorageEnabled = true if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { @@ -112,6 +116,11 @@ private fun WebView.configure(onProgressChange: (Int) -> Unit, onHistoryChange: override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { onHistoryChange(view.canGoBack()) } + + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { + onRendererGone() + return true + } } webChromeClient = object : WebChromeClient() { override fun onProgressChanged(view: WebView, newProgress: Int) { @@ -120,8 +129,6 @@ private fun WebView.configure(onProgressChange: (Int) -> Unit, onHistoryChange: } } -// Cookies are shared by every WebView of the app: drop a session that may belong to another account -// before signing in with this account's credentials. private fun WebView.loadAuthenticated(url: String, client: NextcloudClient) { if (!url.toUri().hasSameOrigin(client.baseUri)) { loadUrl(url) diff --git a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt index 187c129884a7..90a31f5e3872 100644 --- a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt @@ -14,21 +14,21 @@ import com.owncloud.android.R import kotlinx.parcelize.Parcelize @Parcelize -sealed class ComposeDestination(val id: Int) : Parcelable { +sealed class ComposeDestination : Parcelable { @Parcelize - data class AssistantScreen(val title: String, val sessionId: Long?) : ComposeDestination(0) + data class AssistantScreen(val title: String, val sessionId: Long?) : ComposeDestination() @Parcelize - data class ClientIntegrationScreen(val title: String, val data: ClientIntegrationUI) : ComposeDestination(1) + data class ClientIntegrationScreen(val title: String, val data: ClientIntegrationUI) : ComposeDestination() @Parcelize - data class TeamsScreen(val title: String) : ComposeDestination(2) + data class TeamsScreen(val title: String) : ComposeDestination() @Parcelize - data class TeamDetailScreen(val teamId: String, val title: String, val settingsUrl: String) : ComposeDestination(3) + data class TeamDetailScreen(val teamId: String, val title: String, val settingsUrl: String) : ComposeDestination() @Parcelize - data class WebPageScreen(val title: String, val url: String) : ComposeDestination(4) + data class WebPageScreen(val title: String, val url: String) : ComposeDestination() companion object { fun getTeamsScreen(context: Context): TeamsScreen = TeamsScreen(context.getString(R.string.navigation_teams)) diff --git a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt index 6a7720ac07cc..1085e91db37f 100644 --- a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt @@ -6,26 +6,46 @@ */ package com.nextcloud.ui.compose.navigation +import android.os.Bundle +import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay -import com.nextcloud.client.account.User +import com.nextcloud.client.assistant.AssistantScreen +import com.nextcloud.client.assistant.AssistantViewModel +import com.nextcloud.client.assistant.chat.ChatViewModel +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.client.di.ViewModelFactory import com.nextcloud.common.NextcloudClient import com.nextcloud.ui.ClientIntegrationScreen +import com.nextcloud.ui.compose.ComposeActivity import com.nextcloud.ui.compose.component.webView.WebPageScreen -import com.nextcloud.ui.teams.navigation.teamsEntries +import com.nextcloud.ui.teams.TeamsScreen +import com.nextcloud.ui.teams.detail.TeamDetailScreen +import com.nextcloud.ui.teams.detail.TeamResourceOpener +import com.nextcloud.ui.teams.detail.viewmodel.TeamDetailViewModel +import com.nextcloud.ui.teams.viewmodel.TeamsViewModel +import com.nextcloud.utils.SnackbarUtil +import com.owncloud.android.R +import kotlin.jvm.optionals.getOrNull @Composable -fun ComposeNavHost( - navigator: ComposeNavigator, - vmFactory: ViewModelFactory, - user: User, - nextcloudClient: NextcloudClient?, - assistantContent: @Composable (ComposeDestination.AssistantScreen) -> Unit -) { +fun ComposeNavHost(navigator: ComposeNavigator) { NavDisplay( backStack = navigator.backStack, onBack = navigator::goBack, @@ -34,17 +54,104 @@ fun ComposeNavHost( rememberViewModelStoreNavEntryDecorator() ), entryProvider = entryProvider { - entry { screen -> assistantContent(screen) } + entry { screen -> + val activity = composeActivity() + val capability = activity.capabilities.getOrNull() ?: return@entry + val client = rememberNextcloudClient() ?: return@entry + val remoteRepository = remember(client, capability) { + AssistantRemoteRepositoryImpl(client, capability) + } + + AssistantScreen( + composeViewModel = activity.composeViewModel, + viewModel = viewModel { + AssistantViewModel( + accountName = activity.userAccountManager.user.accountName, + remoteRepository = remoteRepository, + localRepository = AssistantLocalRepositoryImpl(NextcloudDatabase.instance().assistantDao()), + sessionIdArg = screen.sessionId + ) + }, + chatViewModel = viewModel { ChatViewModel(remoteRepository) }, + conversationViewModel = viewModel { + ConversationViewModel(remoteRepository = ConversationRemoteRepositoryImpl(client)) + }, + activity = activity, + capability = capability + ) + } entry { screen -> - ClientIntegrationScreen(screen.data, nextcloudClient?.baseUri.toString()) + val client = rememberNextcloudClient() ?: return@entry + + ClientIntegrationScreen( + clientIntegrationUI = screen.data, + baseUrl = client.baseUri.toString() + ) + } + + entry { + TeamsScreen( + viewModel = viewModel(factory = composeActivity().vmFactory), + onTeamClick = { team -> + navigator.navigate(ComposeDestination.TeamDetailScreen(team.id, team.displayName, team.url)) + } + ) } - teamsEntries(navigator, vmFactory, user) + entry { screen -> + val activity = composeActivity() + val resourceOpener = remember(activity) { + TeamResourceOpener(activity, activity.userAccountManager.user) { page -> + navigator.navigate(ComposeDestination.WebPageScreen(page.title, page.url)) + } + } + + TeamDetailScreen( + viewModel = teamDetailViewModel(activity.vmFactory, screen.teamId), + title = screen.title, + onBack = navigator::goBack, + onOpenSettings = { + navigator.navigate(ComposeDestination.WebPageScreen(screen.title, screen.settingsUrl)) + }, + onOpenResource = resourceOpener::open + ) + } entry { screen -> - WebPageScreen(screen.title, screen.url, nextcloudClient, onClose = navigator::goBack) + val client = rememberNextcloudClient() ?: return@entry + + WebPageScreen( + title = screen.title, + url = screen.url, + client = client, + onClose = navigator::goBack + ) } } ) } + +@Composable +private fun composeActivity(): ComposeActivity = LocalActivity.current as ComposeActivity + +@Composable +private fun rememberNextcloudClient(): NextcloudClient? { + val activity = composeActivity() + val client by produceState(initialValue = null, activity) { + value = activity.clientRepository.getNextcloudClient() + if (value == null) { + SnackbarUtil.show(activity, R.string.account_not_found) + } + } + return client +} + +@Composable +private fun teamDetailViewModel(vmFactory: ViewModelFactory, teamId: String): TeamDetailViewModel { + val owner = LocalViewModelStoreOwner.current as? HasDefaultViewModelProviderFactory + val extras = MutableCreationExtras(owner?.defaultViewModelCreationExtras ?: CreationExtras.Empty).apply { + set(DEFAULT_ARGS_KEY, Bundle().apply { putString(TeamDetailViewModel.KEY_TEAM_ID, teamId) }) + } + return viewModel(factory = vmFactory, extras = extras) +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt index 511948571e9d..bc4372caeb9b 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.nextcloud.ui.teams.model.CreateTeamStatus @@ -105,8 +106,9 @@ private fun CreateTeamSupportingText(name: String, status: CreateTeamStatus, isN if (name.isNotBlank() && !isNameValid) { Text( - stringResource( - R.string.teams_name_length, + pluralStringResource( + R.plurals.teams_name_length, + TeamsViewModel.MAX_TEAM_NAME_BYTES, TeamsViewModel.MIN_TEAM_NAME_BYTES, TeamsViewModel.MAX_TEAM_NAME_BYTES ) diff --git a/app/src/main/java/com/nextcloud/ui/teams/navigation/TeamsNavEntries.kt b/app/src/main/java/com/nextcloud/ui/teams/navigation/TeamsNavEntries.kt deleted file mode 100644 index 05d8eda0fedb..000000000000 --- a/app/src/main/java/com/nextcloud/ui/teams/navigation/TeamsNavEntries.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.nextcloud.ui.teams.navigation - -import androidx.activity.compose.LocalActivity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.core.os.bundleOf -import androidx.lifecycle.DEFAULT_ARGS_KEY -import androidx.lifecycle.HasDefaultViewModelProviderFactory -import androidx.lifecycle.viewmodel.CreationExtras -import androidx.lifecycle.viewmodel.MutableCreationExtras -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation3.runtime.EntryProviderScope -import com.nextcloud.client.account.User -import com.nextcloud.client.di.ViewModelFactory -import com.nextcloud.ui.compose.navigation.ComposeDestination -import com.nextcloud.ui.compose.navigation.ComposeNavigator -import com.nextcloud.ui.teams.TeamsScreen -import com.nextcloud.ui.teams.detail.TeamDetailScreen -import com.nextcloud.ui.teams.detail.TeamResourceOpener -import com.nextcloud.ui.teams.detail.viewmodel.TeamDetailViewModel -import com.nextcloud.ui.teams.viewmodel.TeamsViewModel - -fun EntryProviderScope.teamsEntries( - navigator: ComposeNavigator, - vmFactory: ViewModelFactory, - user: User -) { - entry { - TeamsScreen( - viewModel = viewModel(factory = vmFactory), - onTeamClick = { team -> - navigator.navigate(ComposeDestination.TeamDetailScreen(team.id, team.displayName, team.url)) - } - ) - } - - entry { screen -> - val activity = checkNotNull(LocalActivity.current) - val resourceOpener = remember(activity) { - TeamResourceOpener(activity, user) { page -> - navigator.navigate(ComposeDestination.WebPageScreen(page.title, page.url)) - } - } - - TeamDetailScreen( - viewModel = teamDetailViewModel(vmFactory, screen.teamId), - title = screen.title, - onBack = navigator::goBack, - onOpenSettings = { - navigator.navigate(ComposeDestination.WebPageScreen(screen.title, screen.settingsUrl)) - }, - onOpenResource = resourceOpener::open - ) - } -} - -@Composable -private fun teamDetailViewModel(vmFactory: ViewModelFactory, teamId: String): TeamDetailViewModel { - val owner = LocalViewModelStoreOwner.current as? HasDefaultViewModelProviderFactory - val extras = MutableCreationExtras(owner?.defaultViewModelCreationExtras ?: CreationExtras.Empty).apply { - set(DEFAULT_ARGS_KEY, bundleOf(TeamDetailViewModel.KEY_TEAM_ID to teamId)) - } - return viewModel(factory = vmFactory, extras = extras) -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c1a56ed88b3..7b7267fdc1ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1508,7 +1508,10 @@ Member Could not load teams Could not create team - Use %1$d to %2$d characters + + Use %1$d to %2$d character + Use %1$d to %2$d characters + This team name is not allowed You are not allowed to create teams Teams are not available on this server From deeb8c2794f35f0e49ca2132fe11283def317c87 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 10:41:13 +0100 Subject: [PATCH 09/17] handle other events Signed-off-by: alperozturk96 --- app/src/main/AndroidManifest.xml | 4 + .../nextcloud/client/di/ComponentsModule.kt | 4 + .../component/webView/WebPageScreen.kt | 143 ------------------ .../compose/navigation/ComposeDestination.kt | 3 - .../ui/compose/navigation/ComposeNavHost.kt | 27 ++-- .../ui/teams/detail/TeamResourceOpener.kt | 20 ++- .../nextcloud/ui/teams/detail/TeamWebView.kt | 86 +++++++++++ .../detail/component/TeamResourceListItem.kt | 2 + .../detail/model/TeamResourceProviderId.kt | 2 + .../teams/detail/model/TeamResourceTarget.kt | 2 + .../repository/TeamDetailRemoteRepository.kt | 5 + .../detail/repository/TeamDetailRepository.kt | 3 + .../detail/viewmodel/TeamDetailViewModel.kt | 16 ++ 13 files changed, 150 insertions(+), 167 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/ui/compose/component/webView/WebPageScreen.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/TeamWebView.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 86832ea81de6..6bf5c727d1a0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -340,6 +340,10 @@ android:name=".ui.activity.TextEditorWebView" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:exported="false" /> + - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.nextcloud.ui.compose.component.webView - -import android.annotation.SuppressLint -import android.net.Uri -import android.webkit.CookieManager -import android.webkit.RenderProcessGoneDetail -import android.webkit.WebChromeClient -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.net.toUri -import androidx.webkit.WebSettingsCompat -import androidx.webkit.WebViewFeature -import com.nextcloud.common.NextcloudClient -import com.owncloud.android.R - -private const val MAX_PROGRESS = 100 -private const val AUTHORIZATION_HEADER = "Authorization" - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WebPageScreen(title: String, url: String, client: NextcloudClient, onClose: () -> Unit) { - var webView by remember { mutableStateOf(null) } - var progress by remember { mutableIntStateOf(0) } - var canGoBack by remember { mutableStateOf(false) } - - BackHandler(enabled = canGoBack) { webView?.goBack() } - - Scaffold( - contentWindowInsets = WindowInsets(0), - topBar = { - TopAppBar( - title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - navigationIcon = { - IconButton(onClick = onClose) { - Icon(painterResource(R.drawable.ic_arrow_back), stringResource(R.string.common_back)) - } - }, - windowInsets = WindowInsets(0) - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - if (progress < MAX_PROGRESS) { - LinearProgressIndicator( - progress = { progress.toFloat() / MAX_PROGRESS }, - modifier = Modifier.fillMaxWidth() - ) - } - - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { context -> - WebView(context).apply { - configure( - onProgressChange = { progress = it }, - onHistoryChange = { canGoBack = it }, - onRendererGone = onClose - ) - loadAuthenticated(url, client) - webView = this - } - }, - onRelease = { it.destroy() } - ) - } - } -} - -@SuppressLint("SetJavaScriptEnabled") -private fun WebView.configure( - onProgressChange: (Int) -> Unit, - onHistoryChange: (Boolean) -> Unit, - onRendererGone: () -> Unit -) { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, true) - } - - webViewClient = object : WebViewClient() { - override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { - onHistoryChange(view.canGoBack()) - } - - override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { - onRendererGone() - return true - } - } - webChromeClient = object : WebChromeClient() { - override fun onProgressChanged(view: WebView, newProgress: Int) { - onProgressChange(newProgress) - } - } -} - -private fun WebView.loadAuthenticated(url: String, client: NextcloudClient) { - if (!url.toUri().hasSameOrigin(client.baseUri)) { - loadUrl(url) - return - } - - CookieManager.getInstance().removeAllCookies { - loadUrl(url, mapOf(AUTHORIZATION_HEADER to client.credentials)) - } -} - -private fun Uri.hasSameOrigin(other: Uri): Boolean = scheme == other.scheme && host == other.host && port == other.port diff --git a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt index 90a31f5e3872..a5b387f99004 100644 --- a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt @@ -27,9 +27,6 @@ sealed class ComposeDestination : Parcelable { @Parcelize data class TeamDetailScreen(val teamId: String, val title: String, val settingsUrl: String) : ComposeDestination() - @Parcelize - data class WebPageScreen(val title: String, val url: String) : ComposeDestination() - companion object { fun getTeamsScreen(context: Context): TeamsScreen = TeamsScreen(context.getString(R.string.navigation_teams)) diff --git a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt index 1085e91db37f..203afc5f8eea 100644 --- a/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt @@ -8,6 +8,9 @@ package com.nextcloud.ui.compose.navigation import android.os.Bundle import androidx.activity.compose.LocalActivity +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState @@ -34,16 +37,18 @@ import com.nextcloud.client.di.ViewModelFactory import com.nextcloud.common.NextcloudClient import com.nextcloud.ui.ClientIntegrationScreen import com.nextcloud.ui.compose.ComposeActivity -import com.nextcloud.ui.compose.component.webView.WebPageScreen import com.nextcloud.ui.teams.TeamsScreen import com.nextcloud.ui.teams.detail.TeamDetailScreen import com.nextcloud.ui.teams.detail.TeamResourceOpener +import com.nextcloud.ui.teams.detail.model.TeamResourceTarget import com.nextcloud.ui.teams.detail.viewmodel.TeamDetailViewModel import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.nextcloud.utils.SnackbarUtil import com.owncloud.android.R import kotlin.jvm.optionals.getOrNull +private val noTransition = ContentTransform(EnterTransition.None, ExitTransition.None, sizeTransform = null) + @Composable fun ComposeNavHost(navigator: ComposeNavigator) { NavDisplay( @@ -53,6 +58,9 @@ fun ComposeNavHost(navigator: ComposeNavigator) { rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator() ), + transitionSpec = { noTransition }, + popTransitionSpec = { noTransition }, + predictivePopTransitionSpec = { noTransition }, entryProvider = entryProvider { entry { screen -> val activity = composeActivity() @@ -102,9 +110,7 @@ fun ComposeNavHost(navigator: ComposeNavigator) { entry { screen -> val activity = composeActivity() val resourceOpener = remember(activity) { - TeamResourceOpener(activity, activity.userAccountManager.user) { page -> - navigator.navigate(ComposeDestination.WebPageScreen(page.title, page.url)) - } + TeamResourceOpener(activity, activity.userAccountManager.user) } TeamDetailScreen( @@ -112,22 +118,11 @@ fun ComposeNavHost(navigator: ComposeNavigator) { title = screen.title, onBack = navigator::goBack, onOpenSettings = { - navigator.navigate(ComposeDestination.WebPageScreen(screen.title, screen.settingsUrl)) + resourceOpener.open(TeamResourceTarget.WebPage(screen.settingsUrl, screen.title)) }, onOpenResource = resourceOpener::open ) } - - entry { screen -> - val client = rememberNextcloudClient() ?: return@entry - - WebPageScreen( - title = screen.title, - url = screen.url, - client = client, - onClose = navigator::goBack - ) - } } ) } diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt index 14476daad715..4df7495b282b 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt @@ -14,15 +14,12 @@ import com.nextcloud.client.account.User import com.nextcloud.ui.teams.detail.model.TeamResourceTarget import com.owncloud.android.ui.activity.FileDisplayActivity -class TeamResourceOpener( - private val activity: Activity, - private val user: User, - private val openWebPage: (TeamResourceTarget.WebPage) -> Unit -) { +class TeamResourceOpener(private val activity: Activity, private val user: User) { fun open(target: TeamResourceTarget) { when (target) { is TeamResourceTarget.Folder -> openFolder(target.remotePath) + is TeamResourceTarget.File -> openFile(target.fileId) is TeamResourceTarget.TalkConversation -> openTalkConversation(target) is TeamResourceTarget.WebPage -> openWebPage(target) } @@ -37,6 +34,19 @@ class TeamResourceOpener( activity.startActivity(intent) } + private fun openWebPage(target: TeamResourceTarget.WebPage) { + activity.startActivity(TeamWebView.intent(activity, target.title, target.url)) + } + + private fun openFile(fileId: String) { + val intent = Intent(activity, FileDisplayActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra(FileDisplayActivity.KEY_ACCOUNT, user.accountName) + putExtra(FileDisplayActivity.KEY_FILE_ID, fileId) + } + activity.startActivity(intent) + } + private fun openTalkConversation(target: TeamResourceTarget.TalkConversation) { val intent = Intent(Intent.ACTION_VIEW, talkConversationUri(target.token)) try { diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamWebView.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamWebView.kt new file mode 100644 index 000000000000..e5a1f83234b2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamWebView.kt @@ -0,0 +1,86 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.detail + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.utils.IntentUtil +import com.owncloud.android.ui.NextcloudWebViewClient +import com.owncloud.android.ui.activity.ExternalSiteWebView +import kotlinx.coroutines.launch + +class TeamWebView : ExternalSiteWebView() { + + override fun postOnCreate() { + super.postOnCreate() + val pageUrl = intent.getStringExtra(EXTRA_PAGE_URL) ?: return + + webView.settings.userAgentString = null + webView.webViewClient = object : NextcloudWebViewClient(supportFragmentManager) { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest): Boolean { + if (request.url.hasSameOrigin(pageUrl.toUri())) { + return false + } + + IntentUtil.startLinkIntent(this@TeamWebView, request.url) + return true + } + } + + proxyUtil?.applyProxy { loadSignedIn(pageUrl) } + } + + private fun loadSignedIn(pageUrl: String) { + lifecycleScope.launch { + val client = clientRepository.getNextcloudClient() + if (client == null || !pageUrl.toUri().hasSameOrigin(client.baseUri)) { + webView.loadUrl(pageUrl) + return@launch + } + + val cookieManager = CookieManager.getInstance() + cookieManager.removeAllCookies { + cookieManager.setCookies(client.baseUri.toString(), BROWSER_STATE_COOKIES) { + webView.loadUrl(pageUrl, mapOf(AUTHORIZATION_HEADER to client.credentials)) + } + } + } + } + + private fun CookieManager.setCookies(url: String, cookies: List, onDone: () -> Unit) { + if (cookies.isEmpty()) { + onDone() + return + } + setCookie(url, cookies.first()) { setCookies(url, cookies.drop(1), onDone) } + } + + private fun Uri.hasSameOrigin(other: Uri): Boolean = + scheme == other.scheme && host == other.host && port == other.port + + companion object { + private const val EXTRA_PAGE_URL = "PAGE_URL" + private const val AUTHORIZATION_HEADER = "Authorization" + private val BROWSER_STATE_COOKIES = listOf( + "cookie_test=test; path=/", + "nc_sameSiteCookielax=true; path=/; SameSite=Lax", + "nc_sameSiteCookiestrict=true; path=/; SameSite=Strict" + ) + + fun intent(context: Context, title: String, pageUrl: String): Intent = + Intent(context, TeamWebView::class.java).apply { + putExtra(EXTRA_TITLE, title) + putExtra(EXTRA_PAGE_URL, pageUrl) + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt index 482ce8e1dc6a..cd40ff168a34 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt @@ -41,8 +41,10 @@ fun TeamResourceListItem(resource: TeamResource, onClick: () -> Unit) { @DrawableRes private fun iconId(providerId: String): Int = when (providerId) { + TeamResourceProviderId.FILES -> R.drawable.file TeamResourceProviderId.TEAM_FOLDER -> R.drawable.folder TeamResourceProviderId.TALK -> R.drawable.ic_talk TeamResourceProviderId.DECK -> R.drawable.ic_deck + TeamResourceProviderId.COLLECTIVES -> R.drawable.ic_notes else -> R.drawable.ic_link } diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt index ac75fc26e725..6ea9dc518138 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt @@ -7,7 +7,9 @@ package com.nextcloud.ui.teams.detail.model object TeamResourceProviderId { + const val FILES = "files" const val TEAM_FOLDER = "groupfolders" const val TALK = "talk" const val DECK = "deck" + const val COLLECTIVES = "collectives" } diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.kt index a0ae30f5d7e8..daaa9dd43766 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.kt @@ -9,6 +9,8 @@ package com.nextcloud.ui.teams.detail.model sealed interface TeamResourceTarget { data class Folder(val remotePath: String) : TeamResourceTarget + data class File(val fileId: String) : TeamResourceTarget + data class TalkConversation(val token: String, val webPage: WebPage) : TeamResourceTarget data class WebPage(val url: String, val title: String) : TeamResourceTarget diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt index ca4cbf5909fc..b7ac91c72a17 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt @@ -6,6 +6,8 @@ */ package com.nextcloud.ui.teams.detail.repository +import com.nextcloud.android.lib.resources.collectives.GetCollectivesUserFolderRemoteOperation +import com.nextcloud.android.lib.resources.collectives.model.CollectivesUserFolder import com.nextcloud.android.lib.resources.teams.get.folder.GetTeamFolderRemoteOperation import com.nextcloud.android.lib.resources.teams.get.folder.model.TeamFolder import com.nextcloud.android.lib.resources.teams.get.members.GetTeamMembersRemoteOperation @@ -36,6 +38,9 @@ class TeamDetailRemoteRepository @Inject constructor(private val clientRepositor override suspend fun getFolder(teamId: String): RemoteOperationResult = execute(GetTeamFolderRemoteOperation(teamId)) + override suspend fun getCollectivesUserFolder(): RemoteOperationResult = + execute(GetCollectivesUserFolderRemoteOperation()) + override suspend fun getMembers(teamId: String): RemoteOperationResult> = execute(GetTeamMembersRemoteOperation(teamId)) diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRepository.kt index 50e67e1f7542..96c6dd4e5e82 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRepository.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRepository.kt @@ -6,6 +6,7 @@ */ package com.nextcloud.ui.teams.detail.repository +import com.nextcloud.android.lib.resources.collectives.model.CollectivesUserFolder import com.nextcloud.android.lib.resources.teams.get.folder.model.TeamFolder import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResource import com.nextcloud.android.lib.resources.teams.model.TeamMember @@ -18,6 +19,8 @@ interface TeamDetailRepository { suspend fun getFolder(teamId: String): RemoteOperationResult + suspend fun getCollectivesUserFolder(): RemoteOperationResult + suspend fun getMembers(teamId: String): RemoteOperationResult> suspend fun addMember(teamId: String, member: NewTeamMember): RemoteOperationResult diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt index 4343c057cf89..07a3941d1f41 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt @@ -47,6 +47,8 @@ class TeamDetailViewModel @AssistedInject constructor( private var folderPath: String? = null + private var collectivesFolderPath: String? = null + val selectedTab: StateFlow = savedStateHandle.getStateFlow(KEY_SELECTED_TAB, TeamDetailTab.RESOURCES) private val _resources = MutableStateFlow>(TeamDetailListUiState.Loading) @@ -96,8 +98,11 @@ class TeamDetailViewModel @AssistedInject constructor( fun targetFor(resource: TeamResource): TeamResourceTarget { val webPage = TeamResourceTarget.WebPage(resource.url, resource.label) return when (resource.provider.id) { + TeamResourceProviderId.FILES -> TeamResourceTarget.File(resource.id) TeamResourceProviderId.TEAM_FOLDER -> folderPath?.let { TeamResourceTarget.Folder(it) } ?: webPage TeamResourceProviderId.TALK -> TeamResourceTarget.TalkConversation(resource.id, webPage) + TeamResourceProviderId.COLLECTIVES -> + collectivesFolderPath?.let { TeamResourceTarget.Folder("$it${resource.label}/") } ?: webPage else -> webPage } } @@ -145,9 +150,20 @@ class TeamDetailViewModel @AssistedInject constructor( if (resources.any { it.provider.id == TeamResourceProviderId.TEAM_FOLDER }) { folderPath = loadFolderPath() } + if (resources.any { it.provider.id == TeamResourceProviderId.COLLECTIVES }) { + collectivesFolderPath = loadCollectivesFolderPath() + } _resources.value = TeamDetailListUiState.Content(resources) } + private suspend fun loadCollectivesFolderPath(): String? { + val result = repository.getCollectivesUserFolder() + if (!result.isSuccess) { + return null + } + return result.resultData?.path?.let { "/${it.trim('/')}/" } + } + private suspend fun loadFolderPath(): String? { val result = repository.getFolder(teamId) if (!result.isSuccess) { From 8811ed453c724fb6d409f208c9a5c7c4a71d027b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 10:54:22 +0100 Subject: [PATCH 10/17] formatter Signed-off-by: alperozturk96 --- .../ui/teams/detail/viewmodel/TeamDetailViewModel.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt index 07a3941d1f41..b3c3a784260f 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt @@ -101,12 +101,14 @@ class TeamDetailViewModel @AssistedInject constructor( TeamResourceProviderId.FILES -> TeamResourceTarget.File(resource.id) TeamResourceProviderId.TEAM_FOLDER -> folderPath?.let { TeamResourceTarget.Folder(it) } ?: webPage TeamResourceProviderId.TALK -> TeamResourceTarget.TalkConversation(resource.id, webPage) - TeamResourceProviderId.COLLECTIVES -> - collectivesFolderPath?.let { TeamResourceTarget.Folder("$it${resource.label}/") } ?: webPage + TeamResourceProviderId.COLLECTIVES -> collectiveFolder(resource.label) ?: webPage else -> webPage } } + private fun collectiveFolder(collectiveName: String): TeamResourceTarget.Folder? = + collectivesFolderPath?.let { TeamResourceTarget.Folder("$it$collectiveName/") } + fun openAddMemberDialog() { _addMemberStatus.value = AddTeamMemberStatus.Editing savedStateHandle[KEY_ADD_MEMBER_QUERY] = "" From 6a94bb734cd6c5309215975923a74a16ae5dbdaa Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 11:37:38 +0100 Subject: [PATCH 11/17] wip Signed-off-by: alperozturk96 --- .../nextcloud/client/di/ViewModelModule.kt | 19 ++++++++----------- .../ui/teams/detail/TeamDetailScreen.kt | 2 +- .../detail/viewmodel/TeamDetailViewModel.kt | 7 ++++--- .../ui/teams/viewmodel/TeamsViewModel.kt | 5 +++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index e6d912dcddfc..162bba10cf30 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -25,7 +25,6 @@ import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel import dagger.Binds import dagger.Module -import dagger.Provides import dagger.multibindings.IntoMap @Module @@ -88,15 +87,13 @@ abstract class ViewModelModule { @ViewModelKey(FileDetailFragmentViewModel::class) abstract fun fileDetailFragmentViewModel(vm: FileDetailFragmentViewModel): ViewModel - companion object { - @Provides - @IntoMap - @ViewModelKey(TeamsViewModel::class) - fun teamsViewModel(factory: TeamsViewModel.Factory) = SavedStateViewModelCreator(factory::create) + @Binds + @IntoMap + @ViewModelKey(TeamsViewModel::class) + abstract fun teamsViewModel(factory: TeamsViewModel.Factory): SavedStateViewModelCreator - @Provides - @IntoMap - @ViewModelKey(TeamDetailViewModel::class) - fun teamDetailViewModel(factory: TeamDetailViewModel.Factory) = SavedStateViewModelCreator(factory::create) - } + @Binds + @IntoMap + @ViewModelKey(TeamDetailViewModel::class) + abstract fun teamDetailViewModel(factory: TeamDetailViewModel.Factory): SavedStateViewModelCreator } diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt index 134c388329c9..66b8052ba069 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt @@ -30,8 +30,8 @@ import com.nextcloud.ui.teams.detail.component.AddTeamMemberDialog import com.nextcloud.ui.teams.detail.component.TeamDetailListStateContent import com.nextcloud.ui.teams.detail.component.TeamMemberListItem import com.nextcloud.ui.teams.detail.component.TeamResourceListItem -import com.nextcloud.ui.teams.detail.model.TeamResourceTarget import com.nextcloud.ui.teams.detail.model.TeamDetailTab +import com.nextcloud.ui.teams.detail.model.TeamResourceTarget import com.nextcloud.ui.teams.detail.viewmodel.TeamDetailViewModel import com.owncloud.android.R diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt index b3c3a784260f..8988c6783c97 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt @@ -11,12 +11,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResource import com.nextcloud.android.lib.resources.teams.model.TeamMember +import com.nextcloud.client.di.SavedStateViewModelCreator import com.nextcloud.ui.teams.detail.model.AddTeamMemberStatus import com.nextcloud.ui.teams.detail.model.TeamDetailListUiState +import com.nextcloud.ui.teams.detail.model.TeamDetailTab import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion import com.nextcloud.ui.teams.detail.model.TeamResourceProviderId import com.nextcloud.ui.teams.detail.model.TeamResourceTarget -import com.nextcloud.ui.teams.detail.model.TeamDetailTab import com.nextcloud.ui.teams.detail.model.addTeamMemberErrorMessageId import com.nextcloud.ui.teams.detail.model.loadTeamDetailErrorMessageId import com.nextcloud.ui.teams.detail.repository.TeamDetailRepository @@ -184,8 +185,8 @@ class TeamDetailViewModel @AssistedInject constructor( } @AssistedFactory - interface Factory { - fun create(savedStateHandle: SavedStateHandle): TeamDetailViewModel + interface Factory : SavedStateViewModelCreator { + override fun create(savedStateHandle: SavedStateHandle): TeamDetailViewModel } companion object { diff --git a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt index 2f125775dcc9..0c88532b1bf8 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt @@ -9,6 +9,7 @@ package com.nextcloud.ui.teams.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nextcloud.client.di.SavedStateViewModelCreator import com.nextcloud.ui.teams.model.CreateTeamStatus import com.nextcloud.ui.teams.model.TeamsUiState import com.nextcloud.ui.teams.model.createTeamErrorMessageId @@ -107,8 +108,8 @@ class TeamsViewModel @AssistedInject constructor( } @AssistedFactory - interface Factory { - fun create(savedStateHandle: SavedStateHandle): TeamsViewModel + interface Factory : SavedStateViewModelCreator { + override fun create(savedStateHandle: SavedStateHandle): TeamsViewModel } companion object { From 04039774757af3aaca6171c8a5acf57dc54a5bc3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 13:17:26 +0100 Subject: [PATCH 12/17] de-duplicate alert dialog logic Signed-off-by: alperozturk96 --- .../client/assistant/AssistantScreen.kt | 14 ++- .../nextcloud/ui/compose/ComposeActivity.kt | 5 +- .../component/alertDialog/BaseAlertDialog.kt | 82 +++++++++++++++ .../alertDialog/SimpleAlertDialog.kt | 83 ---------------- .../ui/teams/component/CreateTeamDialog.kt | 99 +++++++------------ .../detail/component/AddTeamMemberDialog.kt | 90 +++++------------ 6 files changed, 153 insertions(+), 220 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt delete mode 100644 app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/SimpleAlertDialog.kt 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 3cb77eab232f..b9290274ab6d 100644 --- a/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt +++ b/app/src/main/java/com/nextcloud/client/assistant/AssistantScreen.kt @@ -74,7 +74,7 @@ import com.nextcloud.client.assistant.translate.TranslationScreen import com.nextcloud.client.assistant.translate.TranslationViewModel import com.nextcloud.ui.compose.ComposeActivity import com.nextcloud.ui.compose.ComposeViewModel -import com.nextcloud.ui.compose.component.alertDialog.SimpleAlertDialog +import com.nextcloud.ui.compose.component.alertDialog.BaseAlertDialog import com.nextcloud.ui.compose.component.bottomSheet.MoreActionsBottomSheet import com.nextcloud.utils.extensions.getChat import com.owncloud.android.R @@ -417,12 +417,16 @@ private fun OverlayState(state: ScreenOverlayState?, activity: Activity, viewMod state?.let { when (state) { is ScreenOverlayState.DeleteTask -> { - SimpleAlertDialog( + BaseAlertDialog( title = stringResource(id = R.string.assistant_screen_delete_task_alert_dialog_title), - description = stringResource(id = R.string.assistant_screen_delete_task_alert_dialog_description), onDismiss = { viewModel.updateScreenOverlayState(null) }, - onComplete = { viewModel.deleteTask(state.id) } - ) + onComplete = { + viewModel.deleteTask(state.id) + viewModel.updateScreenOverlayState(null) + } + ) { + Text(stringResource(id = R.string.assistant_screen_delete_task_alert_dialog_description)) + } } is ScreenOverlayState.TaskActions -> { diff --git a/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt b/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt index 6ecbde440608..b0b1ffa78a1c 100644 --- a/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt @@ -1,9 +1,8 @@ /* * 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 + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.ui.compose diff --git a/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt new file mode 100644 index 000000000000..424d9de5fa01 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt @@ -0,0 +1,82 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.compose.component.alertDialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import com.owncloud.android.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseAlertDialog( + title: String, + onDismiss: () -> Unit, + onComplete: (() -> Unit)? = null, + completeText: String = stringResource(R.string.common_ok), + isCompleteEnabled: Boolean = true, + content: @Composable ColumnScope.() -> Unit +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + BaseAlertDialogContent(title, onDismiss, onComplete, completeText, isCompleteEnabled, content) + } +} + +@Composable +fun BaseAlertDialogContent( + title: String, + onDismiss: () -> Unit, + onComplete: (() -> Unit)? = null, + completeText: String = stringResource(R.string.common_ok), + isCompleteEnabled: Boolean = true, + content: @Composable ColumnScope.() -> Unit +) { + Surface( + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + modifier = Modifier.padding(dimensionResource(R.dimen.dialog_padding)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_padding)) + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = AlertDialogDefaults.titleContentColor + ) + content() + Row( + modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + onComplete?.let { + TextButton(onClick = it, enabled = isCompleteEnabled) { + Text(completeText) + } + } + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/SimpleAlertDialog.kt b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/SimpleAlertDialog.kt deleted file mode 100644 index 6076f5e73403..000000000000 --- a/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/SimpleAlertDialog.kt +++ /dev/null @@ -1,83 +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.ui.compose.component.alertDialog - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.owncloud.android.R - -@Composable -fun SimpleAlertDialog( - title: String, - description: String?, - heightFraction: Float? = null, - content: @Composable (() -> Unit)? = null, - onComplete: () -> Unit, - onDismiss: () -> Unit -) { - val modifier = if (heightFraction != null) { - Modifier - .fillMaxWidth() - .fillMaxHeight(heightFraction) - } else { - Modifier.fillMaxWidth() - } - - AlertDialog( - containerColor = MaterialTheme.colorScheme.surface, - iconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - textContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - onDismissRequest = { onDismiss() }, - title = { - Text(text = title) - }, - text = { - Column(modifier = modifier) { - description?.let { - Text(text = description) - } - - content?.let { - Spacer(modifier = Modifier.height(16.dp)) - - content() - } - } - }, - confirmButton = { - FilledTonalButton(onClick = { - onComplete() - onDismiss() - }) { - Text( - stringResource(id = R.string.common_ok) - ) - } - }, - dismissButton = { - TextButton(onClick = { onDismiss() }) { - Text( - stringResource(id = R.string.common_cancel) - ) - } - } - ) -} diff --git a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt index bc4372caeb9b..43e7afc65242 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt @@ -6,32 +6,22 @@ */ package com.nextcloud.ui.teams.component -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.ui.compose.component.alertDialog.BaseAlertDialog +import com.nextcloud.ui.compose.component.alertDialog.BaseAlertDialogContent import com.nextcloud.ui.teams.model.CreateTeamStatus import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.owncloud.android.R -@OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateTeamDialog( name: String, @@ -40,71 +30,46 @@ fun CreateTeamDialog( onDismiss: () -> Unit, onCreate: () -> Unit ) { - BasicAlertDialog(onDismissRequest = onDismiss) { - CreateTeamDialogCard(name, status, onNameChange, onDismiss, onCreate) + BaseAlertDialog( + title = stringResource(R.string.teams_create), + onDismiss = onDismiss, + onComplete = onCreate, + completeText = stringResource(R.string.create), + isCompleteEnabled = canCreate(name, status) + ) { + CreateTeamDialogFields(name, status, onNameChange) } } +private fun canCreate(name: String, status: CreateTeamStatus): Boolean = + TeamsViewModel.isValidTeamName(name) && status != CreateTeamStatus.Creating + @Composable -private fun CreateTeamDialogCard( - name: String, - status: CreateTeamStatus, - onNameChange: (String) -> Unit, - onDismiss: () -> Unit, - onCreate: () -> Unit -) { +private fun CreateTeamDialogFields(name: String, status: CreateTeamStatus, onNameChange: (String) -> Unit) { val isCreating = status == CreateTeamStatus.Creating - val isNameValid = TeamsViewModel.isValidTeamName(name) - Surface( - shape = AlertDialogDefaults.shape, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation - ) { - Column( - modifier = Modifier.padding(dimensionResource(R.dimen.dialog_padding)), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_padding)) - ) { - Text( - text = stringResource(R.string.teams_create), - style = MaterialTheme.typography.headlineSmall, - color = AlertDialogDefaults.titleContentColor - ) - OutlinedTextField( - value = name, - onValueChange = onNameChange, - label = { Text(stringResource(R.string.teams_name_hint)) }, - supportingText = { CreateTeamSupportingText(name, status, isNameValid) }, - isError = status is CreateTeamStatus.Failed, - readOnly = isCreating, - singleLine = true - ) - if (isCreating) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - Row( - modifier = Modifier.align(Alignment.End), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.common_cancel)) - } - TextButton(onClick = onCreate, enabled = isNameValid && !isCreating) { - Text(stringResource(R.string.create)) - } - } - } + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text(stringResource(R.string.teams_name_hint)) }, + supportingText = { CreateTeamSupportingText(name, status) }, + isError = status is CreateTeamStatus.Failed, + readOnly = isCreating, + singleLine = true + ) + if (isCreating) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } } @Composable -private fun CreateTeamSupportingText(name: String, status: CreateTeamStatus, isNameValid: Boolean) { +private fun CreateTeamSupportingText(name: String, status: CreateTeamStatus) { if (status is CreateTeamStatus.Failed) { Text(stringResource(status.messageId)) return } - if (name.isNotBlank() && !isNameValid) { + if (name.isNotBlank() && !TeamsViewModel.isValidTeamName(name)) { Text( pluralStringResource( R.plurals.teams_name_length, @@ -119,7 +84,15 @@ private fun CreateTeamSupportingText(name: String, status: CreateTeamStatus, isN @Composable private fun CreateTeamDialogPreview(name: String, status: CreateTeamStatus) { MaterialTheme { - CreateTeamDialogCard(name = name, status = status, onNameChange = {}, onDismiss = {}, onCreate = {}) + BaseAlertDialogContent( + title = stringResource(R.string.teams_create), + onDismiss = {}, + onComplete = {}, + completeText = stringResource(R.string.create), + isCompleteEnabled = canCreate(name, status) + ) { + CreateTeamDialogFields(name, status, onNameChange = {}) + } } } diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt index 2ceacc61f52a..99428441877c 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt @@ -7,36 +7,26 @@ package com.nextcloud.ui.teams.detail.component import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import com.nextcloud.ui.compose.component.alertDialog.BaseAlertDialog import com.nextcloud.ui.teams.detail.model.AddTeamMemberStatus import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion import com.owncloud.android.R -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddTeamMemberDialog( query: String, @@ -45,65 +35,33 @@ fun AddTeamMemberDialog( onQueryChange: (String) -> Unit, onSelect: (TeamMemberSuggestion) -> Unit, onDismiss: () -> Unit -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - AddTeamMemberDialogCard(query, status, suggestions, onQueryChange, onSelect, onDismiss) - } -} - -@Composable -private fun AddTeamMemberDialogCard( - query: String, - status: AddTeamMemberStatus, - suggestions: List, - onQueryChange: (String) -> Unit, - onSelect: (TeamMemberSuggestion) -> Unit, - onDismiss: () -> Unit ) { val isAdding = status == AddTeamMemberStatus.Adding val failure = status as? AddTeamMemberStatus.Failed - Surface( - shape = AlertDialogDefaults.shape, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation - ) { - Column( - modifier = Modifier.padding(dimensionResource(R.dimen.dialog_padding)), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) - ) { - Text( - text = stringResource(R.string.team_add_member), - style = MaterialTheme.typography.headlineSmall, - color = AlertDialogDefaults.titleContentColor - ) - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - label = { Text(stringResource(R.string.team_add_member_hint)) }, - supportingText = failure?.let { { Text(stringResource(it.messageId)) } }, - isError = failure != null, - readOnly = isAdding, - singleLine = true - ) - if (isAdding) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - val suggestionsMaxHeight = dimensionResource(R.dimen.team_member_suggestions_max_height) - LazyColumn(modifier = Modifier.heightIn(max = suggestionsMaxHeight)) { - items(suggestions, key = { "${it.member.type}:${it.member.id}" }) { suggestion -> - ListItem( - headlineContent = { - Text(text = suggestion.label, maxLines = 1, overflow = TextOverflow.Ellipsis) - }, - leadingContent = { TeamMemberIcon(suggestion.member.type) }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable(enabled = !isAdding) { onSelect(suggestion) } - ) - } - } - TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { - Text(stringResource(R.string.common_cancel)) + BaseAlertDialog(title = stringResource(R.string.team_add_member), onDismiss = onDismiss) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + label = { Text(stringResource(R.string.team_add_member_hint)) }, + supportingText = failure?.let { { Text(stringResource(it.messageId)) } }, + isError = failure != null, + readOnly = isAdding, + singleLine = true + ) + if (isAdding) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + LazyColumn(modifier = Modifier.heightIn(max = dimensionResource(R.dimen.team_member_suggestions_max_height))) { + items(suggestions, key = { "${it.member.type}:${it.member.id}" }) { suggestion -> + ListItem( + headlineContent = { + Text(text = suggestion.label, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + leadingContent = { TeamMemberIcon(suggestion.member.type) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(enabled = !isAdding) { onSelect(suggestion) } + ) } } } From 9634633e951c7d49fd85d40e7b4825cfc8ffb5ad Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 13:23:34 +0100 Subject: [PATCH 13/17] add preview for BaseAlertDialog Signed-off-by: alperozturk96 --- .../component/alertDialog/BaseAlertDialog.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt index 424d9de5fa01..397d0aef5a81 100644 --- a/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -23,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import com.owncloud.android.R @OptIn(ExperimentalMaterial3Api::class) @@ -72,7 +74,7 @@ fun BaseAlertDialogContent( Text(stringResource(R.string.common_cancel)) } onComplete?.let { - TextButton(onClick = it, enabled = isCompleteEnabled) { + FilledTonalButton(onClick = it, enabled = isCompleteEnabled) { Text(completeText) } } @@ -80,3 +82,20 @@ fun BaseAlertDialogContent( } } } + +@Preview +@Composable +private fun BaseAlertDialogPreview() { + MaterialTheme { + BaseAlertDialogContent( + title = "Title", + onDismiss = {}, + onComplete = {} + ) { + Text( + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the" + + " industry's standard dummy text ever since 1966, when designers at Letraset and James Mosley, the" + ) + } + } +} From bd0a763af6d94ded4889a4e4ce51634c4033b7fc Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 13:32:34 +0100 Subject: [PATCH 14/17] add preview for BaseAlertDialog Signed-off-by: alperozturk96 --- .../component/alertDialog/BaseAlertDialog.kt | 17 ++++++++++++----- .../ui/teams/component/CreateTeamDialog.kt | 11 +---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt index 397d0aef5a81..046438a24d9d 100644 --- a/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -37,18 +38,24 @@ fun BaseAlertDialog( isCompleteEnabled: Boolean = true, content: @Composable ColumnScope.() -> Unit ) { + // Previews cannot render the separate window opened by BasicAlertDialog, so draw the card inline there + if (LocalInspectionMode.current) { + BaseAlertDialogContent(title, onDismiss, onComplete, completeText, isCompleteEnabled, content) + return + } + BasicAlertDialog(onDismissRequest = onDismiss) { BaseAlertDialogContent(title, onDismiss, onComplete, completeText, isCompleteEnabled, content) } } @Composable -fun BaseAlertDialogContent( +private fun BaseAlertDialogContent( title: String, onDismiss: () -> Unit, - onComplete: (() -> Unit)? = null, - completeText: String = stringResource(R.string.common_ok), - isCompleteEnabled: Boolean = true, + onComplete: (() -> Unit)?, + completeText: String, + isCompleteEnabled: Boolean, content: @Composable ColumnScope.() -> Unit ) { Surface( @@ -87,7 +94,7 @@ fun BaseAlertDialogContent( @Composable private fun BaseAlertDialogPreview() { MaterialTheme { - BaseAlertDialogContent( + BaseAlertDialog( title = "Title", onDismiss = {}, onComplete = {} diff --git a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt index 43e7afc65242..2375013da070 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.nextcloud.ui.compose.component.alertDialog.BaseAlertDialog -import com.nextcloud.ui.compose.component.alertDialog.BaseAlertDialogContent import com.nextcloud.ui.teams.model.CreateTeamStatus import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.owncloud.android.R @@ -84,15 +83,7 @@ private fun CreateTeamSupportingText(name: String, status: CreateTeamStatus) { @Composable private fun CreateTeamDialogPreview(name: String, status: CreateTeamStatus) { MaterialTheme { - BaseAlertDialogContent( - title = stringResource(R.string.teams_create), - onDismiss = {}, - onComplete = {}, - completeText = stringResource(R.string.create), - isCompleteEnabled = canCreate(name, status) - ) { - CreateTeamDialogFields(name, status, onNameChange = {}) - } + CreateTeamDialog(name = name, status = status, onNameChange = {}, onDismiss = {}, onCreate = {}) } } From 4d27e630eb575622c61cb1a5e20d9bf043c4f590 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 13:37:21 +0100 Subject: [PATCH 15/17] use extensions Signed-off-by: alperozturk96 --- .../detail/component/AddTeamMemberDialog.kt | 12 ++++++++- .../teams/detail/component/TeamMemberIcon.kt | 25 ------------------ .../detail/component/TeamMemberListItem.kt | 26 +++++++++---------- .../detail/component/TeamResourceListItem.kt | 15 ++--------- .../ui/teams/util/TeamMemberTypeExtensions.kt | 19 ++++++++++++++ .../util/TeamResourceProviderExtensions.kt | 22 ++++++++++++++++ .../ui/teams/util/TeamRoleExtensions.kt | 19 ++++++++++++++ 7 files changed, 86 insertions(+), 52 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberIcon.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/util/TeamMemberTypeExtensions.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/util/TeamResourceProviderExtensions.kt create mode 100644 app/src/main/java/com/nextcloud/ui/teams/util/TeamRoleExtensions.kt diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt index 99428441877c..a935e1d4540a 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt @@ -11,20 +11,24 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import com.nextcloud.ui.compose.component.alertDialog.BaseAlertDialog import com.nextcloud.ui.teams.detail.model.AddTeamMemberStatus import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion +import com.nextcloud.ui.teams.util.iconId import com.owncloud.android.R @Composable @@ -58,7 +62,13 @@ fun AddTeamMemberDialog( headlineContent = { Text(text = suggestion.label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - leadingContent = { TeamMemberIcon(suggestion.member.type) }, + leadingContent = { + Icon( + painter = painterResource(suggestion.member.type.iconId()), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier.clickable(enabled = !isAdding) { onSelect(suggestion) } ) diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberIcon.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberIcon.kt deleted file mode 100644 index 7854f8112332..000000000000 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberIcon.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.nextcloud.ui.teams.detail.component - -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.painterResource -import com.nextcloud.android.lib.resources.teams.model.TeamMemberType -import com.owncloud.android.R - -@Composable -fun TeamMemberIcon(type: TeamMemberType?) { - val iconId = when (type) { - TeamMemberType.GROUP -> R.drawable.ic_group - TeamMemberType.MAIL -> R.drawable.ic_email - TeamMemberType.CIRCLE -> R.drawable.ic_circles - TeamMemberType.USER, TeamMemberType.CONTACT, null -> R.drawable.ic_user - } - Icon(painter = painterResource(iconId), contentDescription = null, tint = MaterialTheme.colorScheme.primary) -} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberListItem.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberListItem.kt index f493aa7243f4..1b17c4c1f664 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberListItem.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamMemberListItem.kt @@ -6,15 +6,17 @@ */ package com.nextcloud.ui.teams.detail.component -import androidx.annotation.StringRes +import androidx.compose.material3.Icon import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import com.nextcloud.android.lib.resources.teams.model.TeamMember -import com.nextcloud.android.lib.resources.teams.model.TeamRole -import com.owncloud.android.R +import com.nextcloud.ui.teams.util.iconId +import com.nextcloud.ui.teams.util.titleId @Composable fun TeamMemberListItem(member: TeamMember) { @@ -26,15 +28,13 @@ fun TeamMemberListItem(member: TeamMember) { overflow = TextOverflow.Ellipsis ) }, - supportingContent = { Text(stringResource(roleTitleId(member.role))) }, - leadingContent = { TeamMemberIcon(member.type) } + supportingContent = { Text(stringResource(member.role.titleId())) }, + leadingContent = { + Icon( + painter = painterResource(member.type.iconId()), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } ) } - -@StringRes -private fun roleTitleId(role: TeamRole): Int = when (role) { - TeamRole.OWNER -> R.string.team_role_owner - TeamRole.ADMIN -> R.string.team_role_admin - TeamRole.MODERATOR -> R.string.team_role_moderator - TeamRole.MEMBER -> R.string.team_role_member -} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt index cd40ff168a34..a66c6a121c0a 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt @@ -6,7 +6,6 @@ */ package com.nextcloud.ui.teams.detail.component -import androidx.annotation.DrawableRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon @@ -19,7 +18,7 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResource -import com.nextcloud.ui.teams.detail.model.TeamResourceProviderId +import com.nextcloud.ui.teams.util.iconId import com.owncloud.android.R @Composable @@ -29,7 +28,7 @@ fun TeamResourceListItem(resource: TeamResource, onClick: () -> Unit) { supportingContent = { Text(resource.provider.name) }, leadingContent = { Icon( - painter = painterResource(iconId(resource.provider.id)), + painter = painterResource(resource.provider.iconId()), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(dimensionResource(R.dimen.iconized_single_line_item_icon_size)) @@ -38,13 +37,3 @@ fun TeamResourceListItem(resource: TeamResource, onClick: () -> Unit) { modifier = Modifier.clickable(onClick = onClick) ) } - -@DrawableRes -private fun iconId(providerId: String): Int = when (providerId) { - TeamResourceProviderId.FILES -> R.drawable.file - TeamResourceProviderId.TEAM_FOLDER -> R.drawable.folder - TeamResourceProviderId.TALK -> R.drawable.ic_talk - TeamResourceProviderId.DECK -> R.drawable.ic_deck - TeamResourceProviderId.COLLECTIVES -> R.drawable.ic_notes - else -> R.drawable.ic_link -} diff --git a/app/src/main/java/com/nextcloud/ui/teams/util/TeamMemberTypeExtensions.kt b/app/src/main/java/com/nextcloud/ui/teams/util/TeamMemberTypeExtensions.kt new file mode 100644 index 000000000000..9b07eafefefb --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/util/TeamMemberTypeExtensions.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.util + +import androidx.annotation.DrawableRes +import com.nextcloud.android.lib.resources.teams.model.TeamMemberType +import com.owncloud.android.R + +@DrawableRes +fun TeamMemberType?.iconId(): Int = when (this) { + TeamMemberType.GROUP -> R.drawable.ic_group + TeamMemberType.MAIL -> R.drawable.ic_email + TeamMemberType.CIRCLE -> R.drawable.ic_circles + TeamMemberType.USER, TeamMemberType.CONTACT, null -> R.drawable.ic_user +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/util/TeamResourceProviderExtensions.kt b/app/src/main/java/com/nextcloud/ui/teams/util/TeamResourceProviderExtensions.kt new file mode 100644 index 000000000000..f4b769a24807 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/util/TeamResourceProviderExtensions.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.util + +import androidx.annotation.DrawableRes +import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResourceProvider +import com.nextcloud.ui.teams.detail.model.TeamResourceProviderId +import com.owncloud.android.R + +@DrawableRes +fun TeamResourceProvider.iconId(): Int = when (id) { + TeamResourceProviderId.FILES -> R.drawable.file + TeamResourceProviderId.TEAM_FOLDER -> R.drawable.folder + TeamResourceProviderId.TALK -> R.drawable.ic_talk + TeamResourceProviderId.DECK -> R.drawable.ic_deck + TeamResourceProviderId.COLLECTIVES -> R.drawable.ic_notes + else -> R.drawable.ic_link +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/util/TeamRoleExtensions.kt b/app/src/main/java/com/nextcloud/ui/teams/util/TeamRoleExtensions.kt new file mode 100644 index 000000000000..9bd6fb0a1cd7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/util/TeamRoleExtensions.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.util + +import androidx.annotation.StringRes +import com.nextcloud.android.lib.resources.teams.model.TeamRole +import com.owncloud.android.R + +@StringRes +fun TeamRole.titleId(): Int = when (this) { + TeamRole.OWNER -> R.string.team_role_owner + TeamRole.ADMIN -> R.string.team_role_admin + TeamRole.MODERATOR -> R.string.team_role_moderator + TeamRole.MEMBER -> R.string.team_role_member +} From e443abccd8f23877b7c5b1db5f9e54475ee4b114 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 13:41:51 +0100 Subject: [PATCH 16/17] make TeamResourceProviderId enum Signed-off-by: alperozturk96 --- .../teams/detail/model/TeamResourceProviderId.kt | 16 ++++++++++------ .../detail/viewmodel/TeamDetailViewModel.kt | 9 +++++---- .../teams/util/TeamResourceProviderExtensions.kt | 7 +++++-- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt index 6ea9dc518138..98b3f72850c7 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.kt @@ -6,10 +6,14 @@ */ package com.nextcloud.ui.teams.detail.model -object TeamResourceProviderId { - const val FILES = "files" - const val TEAM_FOLDER = "groupfolders" - const val TALK = "talk" - const val DECK = "deck" - const val COLLECTIVES = "collectives" +enum class TeamResourceProviderId(val id: String) { + FILES("files"), + TEAM_FOLDER("groupfolders"), + TALK("talk"), + DECK("deck"), + COLLECTIVES("collectives"); + + companion object { + fun from(id: String): TeamResourceProviderId? = entries.firstOrNull { it.id == id } + } } diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt index 8988c6783c97..992effdaf7c6 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt @@ -21,6 +21,7 @@ import com.nextcloud.ui.teams.detail.model.TeamResourceTarget import com.nextcloud.ui.teams.detail.model.addTeamMemberErrorMessageId import com.nextcloud.ui.teams.detail.model.loadTeamDetailErrorMessageId import com.nextcloud.ui.teams.detail.repository.TeamDetailRepository +import com.nextcloud.ui.teams.util.providerId import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -98,12 +99,12 @@ class TeamDetailViewModel @AssistedInject constructor( fun targetFor(resource: TeamResource): TeamResourceTarget { val webPage = TeamResourceTarget.WebPage(resource.url, resource.label) - return when (resource.provider.id) { + return when (resource.provider.providerId) { TeamResourceProviderId.FILES -> TeamResourceTarget.File(resource.id) TeamResourceProviderId.TEAM_FOLDER -> folderPath?.let { TeamResourceTarget.Folder(it) } ?: webPage TeamResourceProviderId.TALK -> TeamResourceTarget.TalkConversation(resource.id, webPage) TeamResourceProviderId.COLLECTIVES -> collectiveFolder(resource.label) ?: webPage - else -> webPage + TeamResourceProviderId.DECK, null -> webPage } } @@ -150,10 +151,10 @@ class TeamDetailViewModel @AssistedInject constructor( } val resources = result.resultData.orEmpty() - if (resources.any { it.provider.id == TeamResourceProviderId.TEAM_FOLDER }) { + if (resources.any { it.provider.providerId == TeamResourceProviderId.TEAM_FOLDER }) { folderPath = loadFolderPath() } - if (resources.any { it.provider.id == TeamResourceProviderId.COLLECTIVES }) { + if (resources.any { it.provider.providerId == TeamResourceProviderId.COLLECTIVES }) { collectivesFolderPath = loadCollectivesFolderPath() } _resources.value = TeamDetailListUiState.Content(resources) diff --git a/app/src/main/java/com/nextcloud/ui/teams/util/TeamResourceProviderExtensions.kt b/app/src/main/java/com/nextcloud/ui/teams/util/TeamResourceProviderExtensions.kt index f4b769a24807..ce3b105a3439 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/util/TeamResourceProviderExtensions.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/util/TeamResourceProviderExtensions.kt @@ -11,12 +11,15 @@ import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResourc import com.nextcloud.ui.teams.detail.model.TeamResourceProviderId import com.owncloud.android.R +val TeamResourceProvider.providerId: TeamResourceProviderId? + get() = TeamResourceProviderId.from(id) + @DrawableRes -fun TeamResourceProvider.iconId(): Int = when (id) { +fun TeamResourceProvider.iconId(): Int = when (providerId) { TeamResourceProviderId.FILES -> R.drawable.file TeamResourceProviderId.TEAM_FOLDER -> R.drawable.folder TeamResourceProviderId.TALK -> R.drawable.ic_talk TeamResourceProviderId.DECK -> R.drawable.ic_deck TeamResourceProviderId.COLLECTIVES -> R.drawable.ic_notes - else -> R.drawable.ic_link + null -> R.drawable.ic_link } From 53e11c8f7f234af4751c007f1b24ffcb88b78ff6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 13 Nov 2026 14:51:31 +0100 Subject: [PATCH 17/17] deduplicate logic Signed-off-by: alperozturk96 --- .../com/nextcloud/ui/teams/TeamsScreen.kt | 35 ++++------------ .../ui/teams/component/CreateTeamDialog.kt | 26 ++++++------ .../ui/teams/component/TeamsMessage.kt | 41 +++++++++++++++++++ .../ui/teams/detail/TeamDetailScreen.kt | 6 +-- .../ui/teams/detail/TeamResourceOpener.kt | 17 ++------ .../detail/component/AddTeamMemberDialog.kt | 8 ++-- .../component/TeamDetailListStateContent.kt | 36 ++-------------- .../teams/detail/model/AddTeamMemberStatus.kt | 17 -------- .../repository/TeamDetailRemoteRepository.kt | 18 +++----- .../detail/viewmodel/TeamDetailViewModel.kt | 30 +++++--------- ...reateTeamStatus.kt => TeamDialogStatus.kt} | 8 ++-- .../teams/repository/TeamsRemoteRepository.kt | 15 ++----- .../ui/teams/viewmodel/TeamsViewModel.kt | 30 +++++--------- .../extensions/ClientRepositoryExtensions.kt | 19 +++++++++ 14 files changed, 129 insertions(+), 177 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/ui/teams/component/TeamsMessage.kt delete mode 100644 app/src/main/java/com/nextcloud/ui/teams/detail/model/AddTeamMemberStatus.kt rename app/src/main/java/com/nextcloud/ui/teams/model/{CreateTeamStatus.kt => TeamDialogStatus.kt} (54%) create mode 100644 app/src/main/java/com/nextcloud/utils/extensions/ClientRepositoryExtensions.kt diff --git a/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt index 690f0de53230..f0d7568e2e06 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt @@ -8,7 +8,6 @@ package com.nextcloud.ui.teams import androidx.annotation.StringRes import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -24,7 +23,6 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -37,6 +35,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.android.lib.resources.teams.model.Team import com.nextcloud.ui.teams.component.CreateTeamDialog +import com.nextcloud.ui.teams.component.TeamsErrorMessage +import com.nextcloud.ui.teams.component.TeamsMessage import com.nextcloud.ui.teams.component.TeamsSearchBar import com.nextcloud.ui.teams.model.TeamsUiState import com.nextcloud.ui.teams.viewmodel.TeamsViewModel @@ -55,7 +55,7 @@ fun TeamsScreen(viewModel: TeamsViewModel, onTeamClick: (Team) -> Unit) { ExtendedFloatingActionButton( text = { Text(stringResource(R.string.teams_create)) }, icon = { Icon(painterResource(R.drawable.ic_plus), contentDescription = null) }, - onClick = viewModel::openCreateTeamDialog + onClick = { viewModel.updateCreateTeamStatus("") } ) } ) { innerPadding -> @@ -67,7 +67,7 @@ fun TeamsScreen(viewModel: TeamsViewModel, onTeamClick: (Team) -> Unit) { ) { when (val state = uiState) { TeamsUiState.Loading -> CircularProgressIndicator() - is TeamsUiState.Error -> TeamsError(state.messageId, onRetry = viewModel::retry) + is TeamsUiState.Error -> TeamsErrorMessage(state.messageId, onRetry = viewModel::retry) is TeamsUiState.Content -> TeamsContent(state, searchQuery, viewModel::updateSearchQuery, onTeamClick) } } @@ -77,8 +77,8 @@ fun TeamsScreen(viewModel: TeamsViewModel, onTeamClick: (Team) -> Unit) { CreateTeamDialog( name = name, status = createTeamStatus, - onNameChange = viewModel::updateCreateTeamName, - onDismiss = viewModel::dismissCreateTeamDialog, + onNameChange = viewModel::updateCreateTeamStatus, + onDismiss = { viewModel.updateCreateTeamStatus(null) }, onCreate = viewModel::createTeam ) } @@ -97,11 +97,7 @@ private fun TeamsContent( if (state.isEmpty()) { val messageId = if (searchQuery.isBlank()) R.string.teams_empty else R.string.teams_search_no_results Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = stringResource(messageId), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + TeamsMessage(messageId) } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { @@ -155,20 +151,3 @@ private fun TeamListItem(team: Team, onClick: () -> Unit) { } ) } - -@Composable -private fun TeamsError(@StringRes messageId: Int, onRetry: () -> Unit) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) - ) { - Text( - text = stringResource(messageId), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - TextButton(onClick = onRetry) { - Text(stringResource(R.string.retry)) - } - } -} diff --git a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt index 2375013da070..5ed57cf1fdb4 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt @@ -17,14 +17,14 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.nextcloud.ui.compose.component.alertDialog.BaseAlertDialog -import com.nextcloud.ui.teams.model.CreateTeamStatus +import com.nextcloud.ui.teams.model.TeamDialogStatus import com.nextcloud.ui.teams.viewmodel.TeamsViewModel import com.owncloud.android.R @Composable fun CreateTeamDialog( name: String, - status: CreateTeamStatus, + status: TeamDialogStatus, onNameChange: (String) -> Unit, onDismiss: () -> Unit, onCreate: () -> Unit @@ -40,19 +40,19 @@ fun CreateTeamDialog( } } -private fun canCreate(name: String, status: CreateTeamStatus): Boolean = - TeamsViewModel.isValidTeamName(name) && status != CreateTeamStatus.Creating +private fun canCreate(name: String, status: TeamDialogStatus): Boolean = + TeamsViewModel.isValidTeamName(name) && status != TeamDialogStatus.Submitting @Composable -private fun CreateTeamDialogFields(name: String, status: CreateTeamStatus, onNameChange: (String) -> Unit) { - val isCreating = status == CreateTeamStatus.Creating +private fun CreateTeamDialogFields(name: String, status: TeamDialogStatus, onNameChange: (String) -> Unit) { + val isCreating = status == TeamDialogStatus.Submitting OutlinedTextField( value = name, onValueChange = onNameChange, label = { Text(stringResource(R.string.teams_name_hint)) }, supportingText = { CreateTeamSupportingText(name, status) }, - isError = status is CreateTeamStatus.Failed, + isError = status is TeamDialogStatus.Failed, readOnly = isCreating, singleLine = true ) @@ -62,8 +62,8 @@ private fun CreateTeamDialogFields(name: String, status: CreateTeamStatus, onNam } @Composable -private fun CreateTeamSupportingText(name: String, status: CreateTeamStatus) { - if (status is CreateTeamStatus.Failed) { +private fun CreateTeamSupportingText(name: String, status: TeamDialogStatus) { + if (status is TeamDialogStatus.Failed) { Text(stringResource(status.messageId)) return } @@ -81,7 +81,7 @@ private fun CreateTeamSupportingText(name: String, status: CreateTeamStatus) { } @Composable -private fun CreateTeamDialogPreview(name: String, status: CreateTeamStatus) { +private fun CreateTeamDialogPreview(name: String, status: TeamDialogStatus) { MaterialTheme { CreateTeamDialog(name = name, status = status, onNameChange = {}, onDismiss = {}, onCreate = {}) } @@ -90,13 +90,13 @@ private fun CreateTeamDialogPreview(name: String, status: CreateTeamStatus) { @Preview @Composable private fun CreateTeamDialogEditingPreview() { - CreateTeamDialogPreview(name = "Editing", status = CreateTeamStatus.Editing) + CreateTeamDialogPreview(name = "Editing", status = TeamDialogStatus.Editing) } @Preview @Composable private fun CreateTeamDialogCreatingPreview() { - CreateTeamDialogPreview(name = "Creating", status = CreateTeamStatus.Creating) + CreateTeamDialogPreview(name = "Creating", status = TeamDialogStatus.Submitting) } @Preview @@ -104,6 +104,6 @@ private fun CreateTeamDialogCreatingPreview() { private fun CreateTeamDialogFailedPreview() { CreateTeamDialogPreview( name = "Failed", - status = CreateTeamStatus.Failed(R.string.teams_error_create_not_allowed) + status = TeamDialogStatus.Failed(R.string.teams_error_create_not_allowed) ) } diff --git a/app/src/main/java/com/nextcloud/ui/teams/component/TeamsMessage.kt b/app/src/main/java/com/nextcloud/ui/teams/component/TeamsMessage.kt new file mode 100644 index 000000000000..6197cd2f560f --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/component/TeamsMessage.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.teams.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import com.owncloud.android.R + +@Composable +fun TeamsErrorMessage(@StringRes messageId: Int, onRetry: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) + ) { + TeamsMessage(messageId) + TextButton(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } +} + +@Composable +fun TeamsMessage(@StringRes messageId: Int) { + Text( + text = stringResource(messageId), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt index 66b8052ba069..18a962b04e79 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt @@ -74,7 +74,7 @@ fun TeamDetailScreen( ExtendedFloatingActionButton( text = { Text(stringResource(R.string.team_add_member)) }, icon = { Icon(painterResource(R.drawable.ic_account_plus), contentDescription = null) }, - onClick = viewModel::openAddMemberDialog + onClick = { viewModel.updateMemberStatus("") } ) } } @@ -121,9 +121,9 @@ fun TeamDetailScreen( query = query, status = addMemberStatus, suggestions = memberSuggestions, - onQueryChange = viewModel::updateAddMemberQuery, + onQueryChange = viewModel::updateMemberStatus, onSelect = viewModel::addMember, - onDismiss = viewModel::dismissAddMemberDialog + onDismiss = { viewModel.updateMemberStatus(null) } ) } } diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt index 4df7495b282b..c6a5f104a1dd 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt @@ -18,18 +18,18 @@ class TeamResourceOpener(private val activity: Activity, private val user: User) fun open(target: TeamResourceTarget) { when (target) { - is TeamResourceTarget.Folder -> openFolder(target.remotePath) - is TeamResourceTarget.File -> openFile(target.fileId) + is TeamResourceTarget.Folder -> openInFiles(FileDisplayActivity.KEY_FILE_PATH, target.remotePath) + is TeamResourceTarget.File -> openInFiles(FileDisplayActivity.KEY_FILE_ID, target.fileId) is TeamResourceTarget.TalkConversation -> openTalkConversation(target) is TeamResourceTarget.WebPage -> openWebPage(target) } } - private fun openFolder(remotePath: String) { + private fun openInFiles(key: String, value: String) { val intent = Intent(activity, FileDisplayActivity::class.java).apply { action = Intent.ACTION_VIEW putExtra(FileDisplayActivity.KEY_ACCOUNT, user.accountName) - putExtra(FileDisplayActivity.KEY_FILE_PATH, remotePath) + putExtra(key, value) } activity.startActivity(intent) } @@ -38,15 +38,6 @@ class TeamResourceOpener(private val activity: Activity, private val user: User) activity.startActivity(TeamWebView.intent(activity, target.title, target.url)) } - private fun openFile(fileId: String) { - val intent = Intent(activity, FileDisplayActivity::class.java).apply { - action = Intent.ACTION_VIEW - putExtra(FileDisplayActivity.KEY_ACCOUNT, user.accountName) - putExtra(FileDisplayActivity.KEY_FILE_ID, fileId) - } - activity.startActivity(intent) - } - private fun openTalkConversation(target: TeamResourceTarget.TalkConversation) { val intent = Intent(Intent.ACTION_VIEW, talkConversationUri(target.token)) try { diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt index a935e1d4540a..295c0199e89a 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import com.nextcloud.ui.compose.component.alertDialog.BaseAlertDialog -import com.nextcloud.ui.teams.detail.model.AddTeamMemberStatus +import com.nextcloud.ui.teams.model.TeamDialogStatus import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion import com.nextcloud.ui.teams.util.iconId import com.owncloud.android.R @@ -34,14 +34,14 @@ import com.owncloud.android.R @Composable fun AddTeamMemberDialog( query: String, - status: AddTeamMemberStatus, + status: TeamDialogStatus, suggestions: List, onQueryChange: (String) -> Unit, onSelect: (TeamMemberSuggestion) -> Unit, onDismiss: () -> Unit ) { - val isAdding = status == AddTeamMemberStatus.Adding - val failure = status as? AddTeamMemberStatus.Failed + val isAdding = status == TeamDialogStatus.Submitting + val failure = status as? TeamDialogStatus.Failed BaseAlertDialog(title = stringResource(R.string.team_add_member), onDismiss = onDismiss) { OutlinedTextField( diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt index 5e9e93cf0a4f..e9280b0421fc 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt @@ -7,23 +7,17 @@ package com.nextcloud.ui.teams.detail.component import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.stringResource +import com.nextcloud.ui.teams.component.TeamsErrorMessage +import com.nextcloud.ui.teams.component.TeamsMessage import com.nextcloud.ui.teams.detail.model.TeamDetailListUiState -import com.owncloud.android.R @Composable fun TeamDetailListStateContent( @@ -43,30 +37,8 @@ fun TeamDetailListStateContent( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { when (state) { TeamDetailListUiState.Loading -> CircularProgressIndicator() - is TeamDetailListUiState.Error -> ErrorMessage(state.messageId, onRetry) - is TeamDetailListUiState.Content -> Message(emptyMessageId) + is TeamDetailListUiState.Error -> TeamsErrorMessage(state.messageId, onRetry) + is TeamDetailListUiState.Content -> TeamsMessage(emptyMessageId) } } } - -@Composable -private fun ErrorMessage(@StringRes messageId: Int, onRetry: () -> Unit) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) - ) { - Message(messageId) - TextButton(onClick = onRetry) { - Text(stringResource(R.string.retry)) - } - } -} - -@Composable -private fun Message(@StringRes messageId: Int) { - Text( - text = stringResource(messageId), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) -} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/model/AddTeamMemberStatus.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/AddTeamMemberStatus.kt deleted file mode 100644 index 22eb12029555..000000000000 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/model/AddTeamMemberStatus.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -package com.nextcloud.ui.teams.detail.model - -import androidx.annotation.StringRes - -sealed interface AddTeamMemberStatus { - data object Editing : AddTeamMemberStatus - - data object Adding : AddTeamMemberStatus - - data class Failed(@StringRes val messageId: Int) : AddTeamMemberStatus -} diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt index b7ac91c72a17..9ead409ea5c8 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt @@ -19,9 +19,8 @@ import com.nextcloud.android.lib.resources.teams.post.members.AddTeamMembersRemo import com.nextcloud.android.lib.resources.teams.post.members.model.NewTeamMember import com.nextcloud.repository.ClientRepository import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion -import com.owncloud.android.lib.common.operations.RemoteOperation +import com.nextcloud.utils.extensions.execute import com.owncloud.android.lib.common.operations.RemoteOperationResult -import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode import com.owncloud.android.lib.resources.shares.GetShareesRemoteOperation import com.owncloud.android.lib.resources.shares.ShareType import kotlinx.coroutines.Dispatchers @@ -33,19 +32,19 @@ class TeamDetailRemoteRepository @Inject constructor(private val clientRepositor TeamDetailRepository { override suspend fun getResources(teamId: String): RemoteOperationResult> = - execute(GetTeamResourcesRemoteOperation(teamId)) + clientRepository.execute(GetTeamResourcesRemoteOperation(teamId)) override suspend fun getFolder(teamId: String): RemoteOperationResult = - execute(GetTeamFolderRemoteOperation(teamId)) + clientRepository.execute(GetTeamFolderRemoteOperation(teamId)) override suspend fun getCollectivesUserFolder(): RemoteOperationResult = - execute(GetCollectivesUserFolderRemoteOperation()) + clientRepository.execute(GetCollectivesUserFolderRemoteOperation()) override suspend fun getMembers(teamId: String): RemoteOperationResult> = - execute(GetTeamMembersRemoteOperation(teamId)) + clientRepository.execute(GetTeamMembersRemoteOperation(teamId)) override suspend fun addMember(teamId: String, member: NewTeamMember): RemoteOperationResult = - execute(AddTeamMembersRemoteOperation(teamId, listOf(member))) + clientRepository.execute(AddTeamMembersRemoteOperation(teamId, listOf(member))) override suspend fun searchMemberSuggestions(query: String): List { val client = clientRepository.getOwncloudClient() ?: return emptyList() @@ -58,11 +57,6 @@ class TeamDetailRemoteRepository @Inject constructor(private val clientRepositor } } - private suspend fun execute(operation: RemoteOperation): RemoteOperationResult { - val client = clientRepository.getNextcloudClient() ?: return RemoteOperationResult(ResultCode.ACCOUNT_NOT_FOUND) - return withContext(Dispatchers.IO) { operation.execute(client) } - } - private fun JSONObject.toTeamMemberSuggestion(): TeamMemberSuggestion? { val value = optJSONObject(GetShareesRemoteOperation.NODE_VALUE) ?: return null val shareType = ShareType.fromValue(value.optInt(GetShareesRemoteOperation.PROPERTY_SHARE_TYPE)) diff --git a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt index 992effdaf7c6..bc240676db89 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt @@ -12,7 +12,7 @@ import androidx.lifecycle.viewModelScope import com.nextcloud.android.lib.resources.teams.get.resources.model.TeamResource import com.nextcloud.android.lib.resources.teams.model.TeamMember import com.nextcloud.client.di.SavedStateViewModelCreator -import com.nextcloud.ui.teams.detail.model.AddTeamMemberStatus +import com.nextcloud.ui.teams.model.TeamDialogStatus import com.nextcloud.ui.teams.detail.model.TeamDetailListUiState import com.nextcloud.ui.teams.detail.model.TeamDetailTab import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion @@ -61,8 +61,8 @@ class TeamDetailViewModel @AssistedInject constructor( val addMemberQuery: StateFlow = savedStateHandle.getStateFlow(KEY_ADD_MEMBER_QUERY, null) - private val _addMemberStatus = MutableStateFlow(AddTeamMemberStatus.Editing) - val addMemberStatus: StateFlow = _addMemberStatus.asStateFlow() + private val _addMemberStatus = MutableStateFlow(TeamDialogStatus.Editing) + val addMemberStatus: StateFlow = _addMemberStatus.asStateFlow() @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) private val searchResults = addMemberQuery @@ -111,34 +111,24 @@ class TeamDetailViewModel @AssistedInject constructor( private fun collectiveFolder(collectiveName: String): TeamResourceTarget.Folder? = collectivesFolderPath?.let { TeamResourceTarget.Folder("$it$collectiveName/") } - fun openAddMemberDialog() { - _addMemberStatus.value = AddTeamMemberStatus.Editing - savedStateHandle[KEY_ADD_MEMBER_QUERY] = "" - } - - fun updateAddMemberQuery(query: String) { - _addMemberStatus.value = AddTeamMemberStatus.Editing + fun updateMemberStatus(query: String?) { + _addMemberStatus.value = TeamDialogStatus.Editing savedStateHandle[KEY_ADD_MEMBER_QUERY] = query } - fun dismissAddMemberDialog() { - _addMemberStatus.value = AddTeamMemberStatus.Editing - savedStateHandle[KEY_ADD_MEMBER_QUERY] = null - } - fun addMember(suggestion: TeamMemberSuggestion) { - _addMemberStatus.value = AddTeamMemberStatus.Adding + _addMemberStatus.value = TeamDialogStatus.Submitting viewModelScope.launch { val result = repository.addMember(teamId, suggestion.member) if (!result.isSuccess) { - val failed = AddTeamMemberStatus.Failed(result.addTeamMemberErrorMessageId()) - _addMemberStatus.compareAndSet(AddTeamMemberStatus.Adding, failed) + val failed = TeamDialogStatus.Failed(result.addTeamMemberErrorMessageId()) + _addMemberStatus.compareAndSet(TeamDialogStatus.Submitting, failed) return@launch } loadMembers() - if (_addMemberStatus.compareAndSet(AddTeamMemberStatus.Adding, AddTeamMemberStatus.Editing)) { - dismissAddMemberDialog() + if (_addMemberStatus.compareAndSet(TeamDialogStatus.Submitting, TeamDialogStatus.Editing)) { + updateMemberStatus(null) } } } diff --git a/app/src/main/java/com/nextcloud/ui/teams/model/CreateTeamStatus.kt b/app/src/main/java/com/nextcloud/ui/teams/model/TeamDialogStatus.kt similarity index 54% rename from app/src/main/java/com/nextcloud/ui/teams/model/CreateTeamStatus.kt rename to app/src/main/java/com/nextcloud/ui/teams/model/TeamDialogStatus.kt index 0d2dcc3ccacf..ea6198c734a6 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/model/CreateTeamStatus.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/model/TeamDialogStatus.kt @@ -8,10 +8,10 @@ package com.nextcloud.ui.teams.model import androidx.annotation.StringRes -sealed interface CreateTeamStatus { - data object Editing : CreateTeamStatus +sealed interface TeamDialogStatus { + data object Editing : TeamDialogStatus - data object Creating : CreateTeamStatus + data object Submitting : TeamDialogStatus - data class Failed(@StringRes val messageId: Int) : CreateTeamStatus + data class Failed(@StringRes val messageId: Int) : TeamDialogStatus } diff --git a/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt index eddda917d3dd..365d10ce84c3 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt @@ -10,22 +10,15 @@ import com.nextcloud.android.lib.resources.teams.get.team.GetTeamsRemoteOperatio import com.nextcloud.android.lib.resources.teams.model.Team import com.nextcloud.android.lib.resources.teams.post.team.CreateTeamRemoteOperation import com.nextcloud.repository.ClientRepository -import com.owncloud.android.lib.common.operations.RemoteOperation +import com.nextcloud.utils.extensions.execute import com.owncloud.android.lib.common.operations.RemoteOperationResult -import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import javax.inject.Inject class TeamsRemoteRepository @Inject constructor(private val clientRepository: ClientRepository) : TeamsRepository { - override suspend fun getTeams(): RemoteOperationResult> = execute(GetTeamsRemoteOperation()) + override suspend fun getTeams(): RemoteOperationResult> = + clientRepository.execute(GetTeamsRemoteOperation()) override suspend fun createTeam(name: String): RemoteOperationResult = - execute(CreateTeamRemoteOperation(name)) - - private suspend fun execute(operation: RemoteOperation): RemoteOperationResult { - val client = clientRepository.getNextcloudClient() ?: return RemoteOperationResult(ResultCode.ACCOUNT_NOT_FOUND) - return withContext(Dispatchers.IO) { operation.execute(client) } - } + clientRepository.execute(CreateTeamRemoteOperation(name)) } diff --git a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt index 0c88532b1bf8..f2308b18eebc 100644 --- a/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt @@ -10,7 +10,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.client.di.SavedStateViewModelCreator -import com.nextcloud.ui.teams.model.CreateTeamStatus +import com.nextcloud.ui.teams.model.TeamDialogStatus import com.nextcloud.ui.teams.model.TeamsUiState import com.nextcloud.ui.teams.model.createTeamErrorMessageId import com.nextcloud.ui.teams.model.loadTeamsErrorMessageId @@ -43,8 +43,8 @@ class TeamsViewModel @AssistedInject constructor( val createTeamName: StateFlow = savedStateHandle.getStateFlow(KEY_CREATE_TEAM_NAME, null) - private val _createTeamStatus = MutableStateFlow(CreateTeamStatus.Editing) - val createTeamStatus: StateFlow = _createTeamStatus.asStateFlow() + private val _createTeamStatus = MutableStateFlow(TeamDialogStatus.Editing) + val createTeamStatus: StateFlow = _createTeamStatus.asStateFlow() init { viewModelScope.launch { loadTeams() } @@ -59,39 +59,29 @@ class TeamsViewModel @AssistedInject constructor( savedStateHandle[KEY_SEARCH_QUERY] = query } - fun openCreateTeamDialog() { - _createTeamStatus.value = CreateTeamStatus.Editing - savedStateHandle[KEY_CREATE_TEAM_NAME] = "" - } - - fun updateCreateTeamName(name: String) { - _createTeamStatus.value = CreateTeamStatus.Editing + fun updateCreateTeamStatus(name: String?) { + _createTeamStatus.value = TeamDialogStatus.Editing savedStateHandle[KEY_CREATE_TEAM_NAME] = name } - fun dismissCreateTeamDialog() { - _createTeamStatus.value = CreateTeamStatus.Editing - savedStateHandle[KEY_CREATE_TEAM_NAME] = null - } - fun createTeam() { val name = createTeamName.value?.trim() if (name == null || !isValidTeamName(name)) { return } - _createTeamStatus.value = CreateTeamStatus.Creating + _createTeamStatus.value = TeamDialogStatus.Submitting viewModelScope.launch { val result = repository.createTeam(name) if (!result.isSuccess) { - val failed = CreateTeamStatus.Failed(result.createTeamErrorMessageId()) - _createTeamStatus.compareAndSet(CreateTeamStatus.Creating, failed) + val failed = TeamDialogStatus.Failed(result.createTeamErrorMessageId()) + _createTeamStatus.compareAndSet(TeamDialogStatus.Submitting, failed) return@launch } loadTeams() - if (_createTeamStatus.compareAndSet(CreateTeamStatus.Creating, CreateTeamStatus.Editing)) { - dismissCreateTeamDialog() + if (_createTeamStatus.compareAndSet(TeamDialogStatus.Submitting, TeamDialogStatus.Editing)) { + updateCreateTeamStatus(null) } } } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ClientRepositoryExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ClientRepositoryExtensions.kt new file mode 100644 index 000000000000..92a468bb1246 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ClientRepositoryExtensions.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.extensions + +import com.nextcloud.repository.ClientRepository +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +suspend fun ClientRepository.execute(operation: RemoteOperation): RemoteOperationResult { + val client = getNextcloudClient() ?: return RemoteOperationResult(ResultCode.ACCOUNT_NOT_FOUND) + return withContext(Dispatchers.IO) { operation.execute(client) } +}