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 8ad94b13f8e8..5c3eef6ac574 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -405,6 +405,14 @@ dependencies { implementation(libs.lifecycle.process) 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..6bf5c727d1a0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -132,13 +132,13 @@ android:resource="@xml/app_config" /> @@ -340,6 +340,10 @@ android:name=".ui.activity.TextEditorWebView" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:exported="false" /> + { - 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 -> { @@ -524,6 +530,7 @@ private fun EmptyContent(paddingValues: PaddingValues, iconId: Int?, description } @Suppress("MagicNumber") +@SuppressLint("ViewModelConstructorInComposable") @Composable @Preview private fun AssistantScreenPreview() { @@ -544,6 +551,7 @@ private fun AssistantScreenPreview() { } @Suppress("MagicNumber") +@SuppressLint("ViewModelConstructorInComposable") @Composable @Preview private fun AssistantEmptyScreenPreview() { 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/AppModule.kt b/app/src/main/java/com/nextcloud/client/di/AppModule.kt index 8154074c608d..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,10 @@ 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 import com.nextcloud.utils.e2ee.E2EEKeyInspector import com.nextcloud.utils.thumbnail.FolderThumbnailGenerator @@ -119,6 +123,12 @@ internal class AppModule { @Provides fun clientRepository(repository: RemoteClientRepository): ClientRepository = repository + @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/ComponentsModule.kt b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt index 745696d45571..3182bdce5ab9 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 @@ -36,6 +36,7 @@ import com.nextcloud.ui.dialog.chooseStorage.ChooseStorageLocationDialogFragment import com.nextcloud.ui.fileInfo.FileInfoFragment import com.nextcloud.ui.fileactions.FileActionsBottomSheet import com.nextcloud.ui.tags.TagManagementBottomSheet +import com.nextcloud.ui.teams.detail.TeamWebView import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet import com.nmc.android.ui.LauncherActivity import com.owncloud.android.MainApp @@ -253,6 +254,9 @@ internal interface ComponentsModule { @ContributesAndroidInjector fun textEditorWebView(): TextEditorWebView + @ContributesAndroidInjector + fun teamWebView(): TeamWebView + @ContributesAndroidInjector fun extendedListFragment(): ExtendedListFragment 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..162bba10cf30 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,8 @@ 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 import com.owncloud.android.ui.fragment.filedetail.FileDetailFragmentViewModel @@ -84,4 +86,14 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(FileDetailFragmentViewModel::class) abstract fun fileDetailFragmentViewModel(vm: FileDetailFragmentViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(TeamsViewModel::class) + abstract fun teamsViewModel(factory: TeamsViewModel.Factory): SavedStateViewModelCreator + + @Binds + @IntoMap + @ViewModelKey(TeamDetailViewModel::class) + abstract fun teamDetailViewModel(factory: TeamDetailViewModel.Factory): SavedStateViewModelCreator } 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..b0b1ffa78a1c --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/compose/ComposeActivity.kt @@ -0,0 +1,131 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +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.core.view.isVisible +import com.nextcloud.client.di.ViewModelFactory +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 + 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() { + val navigator = rememberComposeNavigator(destination, onExit = ::finish) + + LaunchedEffect(navigator.current) { + showActivityBarsFor(navigator.current) + } + + ComposeNavHost(navigator) + } +} 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/compose/component/alertDialog/BaseAlertDialog.kt b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt new file mode 100644 index 000000000000..046438a24d9d --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/compose/component/alertDialog/BaseAlertDialog.kt @@ -0,0 +1,108 @@ +/* + * 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.FilledTonalButton +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.platform.LocalInspectionMode +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) +@Composable +fun BaseAlertDialog( + title: String, + onDismiss: () -> Unit, + onComplete: (() -> Unit)? = null, + completeText: String = stringResource(R.string.common_ok), + 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 +private fun BaseAlertDialogContent( + title: String, + onDismiss: () -> Unit, + onComplete: (() -> Unit)?, + completeText: String, + isCompleteEnabled: Boolean, + 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 { + FilledTonalButton(onClick = it, enabled = isCompleteEnabled) { + Text(completeText) + } + } + } + } + } +} + +@Preview +@Composable +private fun BaseAlertDialogPreview() { + MaterialTheme { + BaseAlertDialog( + 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" + ) + } + } +} 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/composeActivity/ComposeDestination.kt b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeDestination.kt similarity index 63% 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 6564d61cd515..a5b387f99004 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 @@ -14,14 +14,22 @@ 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() + + @Parcelize + data class TeamDetailScreen(val teamId: String, val title: String, val settingsUrl: String) : ComposeDestination() 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/compose/navigation/ComposeNavHost.kt b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt new file mode 100644 index 000000000000..203afc5f8eea --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/compose/navigation/ComposeNavHost.kt @@ -0,0 +1,152 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +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 +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.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.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( + backStack = navigator.backStack, + onBack = navigator::goBack, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + transitionSpec = { noTransition }, + popTransitionSpec = { noTransition }, + predictivePopTransitionSpec = { noTransition }, + entryProvider = entryProvider { + 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 -> + 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)) + } + ) + } + + entry { screen -> + val activity = composeActivity() + val resourceOpener = remember(activity) { + TeamResourceOpener(activity, activity.userAccountManager.user) + } + + TeamDetailScreen( + viewModel = teamDetailViewModel(activity.vmFactory, screen.teamId), + title = screen.title, + onBack = navigator::goBack, + onOpenSettings = { + resourceOpener.open(TeamResourceTarget.WebPage(screen.settingsUrl, screen.title)) + }, + onOpenResource = resourceOpener::open + ) + } + } + ) +} + +@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/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 3251a30cb908..000000000000 --- a/app/src/main/java/com/nextcloud/ui/composeActivity/ComposeActivity.kt +++ /dev/null @@ -1,167 +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.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 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.common.NextcloudClient -import com.nextcloud.ui.ClientIntegrationScreen -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 - -class ComposeActivity : DrawerActivity() { - - lateinit var binding: ActivityComposeBinding - private val composeViewModel: ComposeViewModel by viewModels() - - companion object { - const val DESTINATION = "DESTINATION" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - 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) - } - ) - } - - 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() = R.id.nav_assistant - - override fun onResume() { - super.onResume() - highlightNavigationViewItem(menuItemId) - } - - 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 - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - android.R.id.home -> { - toggleDrawer() - true - } - - else -> super.onOptionsItemSelected(item) - } - - @Composable - private fun Content(destination: ComposeDestination) { - val currentScreen by ComposeNavigation.currentScreen.collectAsState() - var nextcloudClient by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - ComposeNavigation.navigate(destination) - nextcloudClient = clientRepository.getNextcloudClient() - } - - binding.bottomNavigation.menu.findItem(R.id.nav_assistant).run { - isChecked = true - } - - when (currentScreen) { - is ComposeDestination.AssistantScreen -> { - val dao = NextcloudDatabase.instance().assistantDao() - val sessionId = (currentScreen as? ComposeDestination.AssistantScreen)?.sessionId - 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 = sessionId - ), - chatViewModel = ChatViewModel(remoteRepository), - conversationViewModel = ConversationViewModel( - remoteRepository = ConversationRemoteRepositoryImpl(client) - ), - activity = this, - capability = capability - ) - } - - is ComposeDestination.ClientIntegrationScreen -> { - binding.bottomNavigation.visibility = View.GONE - val integrationScreen = (currentScreen as ComposeDestination.ClientIntegrationScreen) - ClientIntegrationScreen(integrationScreen.data, nextcloudClient?.baseUri.toString()) - } - - else -> Unit - } - } -} 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/composeComponents/alertDialog/SimpleAlertDialog.kt b/app/src/main/java/com/nextcloud/ui/composeComponents/alertDialog/SimpleAlertDialog.kt deleted file mode 100644 index 987aa3d0059d..000000000000 --- a/app/src/main/java/com/nextcloud/ui/composeComponents/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.composeComponents.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/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/TeamsScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt new file mode 100644 index 000000000000..f0d7568e2e06 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/TeamsScreen.kt @@ -0,0 +1,153 @@ +/* + * 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.clickable +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 +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.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 +import com.owncloud.android.R + +@Composable +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() + val createTeamStatus by viewModel.createTeamStatus.collectAsStateWithLifecycle() + + Scaffold( + contentWindowInsets = WindowInsets(0), + floatingActionButton = { + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.teams_create)) }, + icon = { Icon(painterResource(R.drawable.ic_plus), contentDescription = null) }, + onClick = { viewModel.updateCreateTeamStatus("") } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + when (val state = uiState) { + TeamsUiState.Loading -> CircularProgressIndicator() + is TeamsUiState.Error -> TeamsErrorMessage(state.messageId, onRetry = viewModel::retry) + is TeamsUiState.Content -> TeamsContent(state, searchQuery, viewModel::updateSearchQuery, onTeamClick) + } + } + } + + createTeamName?.let { name -> + CreateTeamDialog( + name = name, + status = createTeamStatus, + onNameChange = viewModel::updateCreateTeamStatus, + onDismiss = { viewModel.updateCreateTeamStatus(null) }, + onCreate = viewModel::createTeam + ) + } +} + +@Composable +private fun TeamsContent( + state: TeamsUiState.Content, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onTeamClick: (Team) -> Unit +) { + Column(modifier = Modifier.fillMaxSize()) { + TeamsSearchBar(searchQuery, onSearchQueryChange) + + 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) { + TeamsMessage(messageId) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + 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, onTeamClick: (Team) -> Unit) { + if (teams.isEmpty()) { + return + } + + item(key = titleId) { SectionHeader(titleId) } + items(teams, key = { it.id }) { team -> TeamListItem(team) { onTeamClick(team) } } +} + +@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, onClick: () -> Unit) { + ListItem( + modifier = Modifier.clickable(onClick = onClick), + 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 + ) + } + ) +} 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..5ed57cf1fdb4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/component/CreateTeamDialog.kt @@ -0,0 +1,109 @@ +/* + * 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.material3.LinearProgressIndicator +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.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.TeamDialogStatus +import com.nextcloud.ui.teams.viewmodel.TeamsViewModel +import com.owncloud.android.R + +@Composable +fun CreateTeamDialog( + name: String, + status: TeamDialogStatus, + onNameChange: (String) -> Unit, + onDismiss: () -> Unit, + onCreate: () -> Unit +) { + 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: TeamDialogStatus): Boolean = + TeamsViewModel.isValidTeamName(name) && status != TeamDialogStatus.Submitting + +@Composable +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 TeamDialogStatus.Failed, + readOnly = isCreating, + singleLine = true + ) + if (isCreating) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun CreateTeamSupportingText(name: String, status: TeamDialogStatus) { + if (status is TeamDialogStatus.Failed) { + Text(stringResource(status.messageId)) + return + } + + if (name.isNotBlank() && !TeamsViewModel.isValidTeamName(name)) { + Text( + pluralStringResource( + R.plurals.teams_name_length, + TeamsViewModel.MAX_TEAM_NAME_BYTES, + TeamsViewModel.MIN_TEAM_NAME_BYTES, + TeamsViewModel.MAX_TEAM_NAME_BYTES + ) + ) + } +} + +@Composable +private fun CreateTeamDialogPreview(name: String, status: TeamDialogStatus) { + MaterialTheme { + CreateTeamDialog(name = name, status = status, onNameChange = {}, onDismiss = {}, onCreate = {}) + } +} + +@Preview +@Composable +private fun CreateTeamDialogEditingPreview() { + CreateTeamDialogPreview(name = "Editing", status = TeamDialogStatus.Editing) +} + +@Preview +@Composable +private fun CreateTeamDialogCreatingPreview() { + CreateTeamDialogPreview(name = "Creating", status = TeamDialogStatus.Submitting) +} + +@Preview +@Composable +private fun CreateTeamDialogFailedPreview() { + CreateTeamDialogPreview( + name = "Failed", + 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/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/detail/TeamDetailScreen.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamDetailScreen.kt new file mode 100644 index 000000000000..18a962b04e79 --- /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.TeamDetailTab +import com.nextcloud.ui.teams.detail.model.TeamResourceTarget +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.updateMemberStatus("") } + ) + } + } + ) { 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::updateMemberStatus, + onSelect = viewModel::addMember, + 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 new file mode 100644 index 000000000000..c6a5f104a1dd --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/TeamResourceOpener.kt @@ -0,0 +1,67 @@ +/* + * 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.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 -> 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 openInFiles(key: String, value: String) { + val intent = Intent(activity, FileDisplayActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra(FileDisplayActivity.KEY_ACCOUNT, user.accountName) + putExtra(key, value) + } + activity.startActivity(intent) + } + + private fun openWebPage(target: TeamResourceTarget.WebPage) { + activity.startActivity(TeamWebView.intent(activity, target.title, target.url)) + } + + 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() + } + + 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/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/AddTeamMemberDialog.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt new file mode 100644 index 000000000000..295c0199e89a --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/AddTeamMemberDialog.kt @@ -0,0 +1,78 @@ +/* + * 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.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.model.TeamDialogStatus +import com.nextcloud.ui.teams.detail.model.TeamMemberSuggestion +import com.nextcloud.ui.teams.util.iconId +import com.owncloud.android.R + +@Composable +fun AddTeamMemberDialog( + query: String, + status: TeamDialogStatus, + suggestions: List, + onQueryChange: (String) -> Unit, + onSelect: (TeamMemberSuggestion) -> Unit, + onDismiss: () -> Unit +) { + val isAdding = status == TeamDialogStatus.Submitting + val failure = status as? TeamDialogStatus.Failed + + 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 = { + 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/TeamDetailListStateContent.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt new file mode 100644 index 000000000000..e9280b0421fc --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamDetailListStateContent.kt @@ -0,0 +1,44 @@ +/* + * 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.Box +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.nextcloud.ui.teams.component.TeamsErrorMessage +import com.nextcloud.ui.teams.component.TeamsMessage +import com.nextcloud.ui.teams.detail.model.TeamDetailListUiState + +@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 -> TeamsErrorMessage(state.messageId, onRetry) + is TeamDetailListUiState.Content -> TeamsMessage(emptyMessageId) + } + } +} 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..1b17c4c1f664 --- /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.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.ui.teams.util.iconId +import com.nextcloud.ui.teams.util.titleId + +@Composable +fun TeamMemberListItem(member: TeamMember) { + ListItem( + headlineContent = { + Text( + text = member.displayName.ifEmpty { member.userId.orEmpty() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { Text(stringResource(member.role.titleId())) }, + leadingContent = { + Icon( + painter = painterResource(member.type.iconId()), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) +} 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..a66c6a121c0a --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/component/TeamResourceListItem.kt @@ -0,0 +1,39 @@ +/* + * 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.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.util.iconId +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(resource.provider.iconId()), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(dimensionResource(R.dimen.iconized_single_line_item_icon_size)) + ) + }, + modifier = Modifier.clickable(onClick = onClick) + ) +} 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..98b3f72850c7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceProviderId.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.detail.model + +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/model/TeamResourceTarget.kt b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.kt new file mode 100644 index 000000000000..daaa9dd43766 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/model/TeamResourceTarget.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 + +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 new file mode 100644 index 000000000000..9ead409ea5c8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRemoteRepository.kt @@ -0,0 +1,73 @@ +/* + * 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.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 +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.nextcloud.utils.extensions.execute +import com.owncloud.android.lib.common.operations.RemoteOperationResult +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> = + clientRepository.execute(GetTeamResourcesRemoteOperation(teamId)) + + override suspend fun getFolder(teamId: String): RemoteOperationResult = + clientRepository.execute(GetTeamFolderRemoteOperation(teamId)) + + override suspend fun getCollectivesUserFolder(): RemoteOperationResult = + clientRepository.execute(GetCollectivesUserFolderRemoteOperation()) + + override suspend fun getMembers(teamId: String): RemoteOperationResult> = + clientRepository.execute(GetTeamMembersRemoteOperation(teamId)) + + override suspend fun addMember(teamId: String, member: NewTeamMember): RemoteOperationResult = + clientRepository.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 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..96c6dd4e5e82 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/repository/TeamDetailRepository.kt @@ -0,0 +1,29 @@ +/* + * 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.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 +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 getCollectivesUserFolder(): 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..bc240676db89 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/detail/viewmodel/TeamDetailViewModel.kt @@ -0,0 +1,190 @@ +/* + * 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.client.di.SavedStateViewModelCreator +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 +import com.nextcloud.ui.teams.detail.model.TeamResourceProviderId +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 +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 + + private var collectivesFolderPath: 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(TeamDialogStatus.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.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 + TeamResourceProviderId.DECK, null -> webPage + } + } + + private fun collectiveFolder(collectiveName: String): TeamResourceTarget.Folder? = + collectivesFolderPath?.let { TeamResourceTarget.Folder("$it$collectiveName/") } + + fun updateMemberStatus(query: String?) { + _addMemberStatus.value = TeamDialogStatus.Editing + savedStateHandle[KEY_ADD_MEMBER_QUERY] = query + } + + fun addMember(suggestion: TeamMemberSuggestion) { + _addMemberStatus.value = TeamDialogStatus.Submitting + viewModelScope.launch { + val result = repository.addMember(teamId, suggestion.member) + if (!result.isSuccess) { + val failed = TeamDialogStatus.Failed(result.addTeamMemberErrorMessageId()) + _addMemberStatus.compareAndSet(TeamDialogStatus.Submitting, failed) + return@launch + } + + loadMembers() + if (_addMemberStatus.compareAndSet(TeamDialogStatus.Submitting, TeamDialogStatus.Editing)) { + updateMemberStatus(null) + } + } + } + + 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.providerId == TeamResourceProviderId.TEAM_FOLDER }) { + folderPath = loadFolderPath() + } + if (resources.any { it.provider.providerId == 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) { + 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 : SavedStateViewModelCreator { + override 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/model/TeamDialogStatus.kt b/app/src/main/java/com/nextcloud/ui/teams/model/TeamDialogStatus.kt new file mode 100644 index 000000000000..ea6198c734a6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/model/TeamDialogStatus.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 TeamDialogStatus { + data object Editing : TeamDialogStatus + + data object Submitting : TeamDialogStatus + + data class Failed(@StringRes val messageId: Int) : TeamDialogStatus +} 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 new file mode 100644 index 000000000000..ea62c939f173 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/model/TeamsUiState.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.android.lib.resources.teams.model.Team + +sealed interface TeamsUiState { + data object Loading : TeamsUiState + + data class Error(@StringRes val messageId: Int) : 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/repository/TeamsRemoteRepository.kt b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt new file mode 100644 index 000000000000..365d10ce84c3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRemoteRepository.kt @@ -0,0 +1,24 @@ +/* + * 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.nextcloud.utils.extensions.execute +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import javax.inject.Inject + +class TeamsRemoteRepository @Inject constructor(private val clientRepository: ClientRepository) : TeamsRepository { + + override suspend fun getTeams(): RemoteOperationResult> = + clientRepository.execute(GetTeamsRemoteOperation()) + + override suspend fun createTeam(name: String): RemoteOperationResult = + clientRepository.execute(CreateTeamRemoteOperation(name)) +} 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..6f21d5a4ce11 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/repository/TeamsRepository.kt @@ -0,0 +1,16 @@ +/* + * 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 +import com.owncloud.android.lib.common.operations.RemoteOperationResult + +interface TeamsRepository { + suspend fun getTeams(): RemoteOperationResult> + + suspend fun createTeam(name: String): RemoteOperationResult +} 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..ce3b105a3439 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/util/TeamResourceProviderExtensions.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.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 + +val TeamResourceProvider.providerId: TeamResourceProviderId? + get() = TeamResourceProviderId.from(id) + +@DrawableRes +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 + null -> 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 +} 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..f2308b18eebc --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/teams/viewmodel/TeamsViewModel.kt @@ -0,0 +1,115 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +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.TeamDialogStatus +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 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.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, + @Assisted private val savedStateHandle: SavedStateHandle +) : ViewModel() { + + 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), TeamsUiState.Loading) + + val createTeamName: StateFlow = savedStateHandle.getStateFlow(KEY_CREATE_TEAM_NAME, null) + + private val _createTeamStatus = MutableStateFlow(TeamDialogStatus.Editing) + val createTeamStatus: StateFlow = _createTeamStatus.asStateFlow() + + init { + viewModelScope.launch { loadTeams() } + } + + fun retry() { + teamsState.value = TeamsUiState.Loading + viewModelScope.launch { loadTeams() } + } + + fun updateSearchQuery(query: String) { + savedStateHandle[KEY_SEARCH_QUERY] = query + } + + fun updateCreateTeamStatus(name: String?) { + _createTeamStatus.value = TeamDialogStatus.Editing + savedStateHandle[KEY_CREATE_TEAM_NAME] = name + } + + fun createTeam() { + val name = createTeamName.value?.trim() + if (name == null || !isValidTeamName(name)) { + return + } + + _createTeamStatus.value = TeamDialogStatus.Submitting + viewModelScope.launch { + val result = repository.createTeam(name) + if (!result.isSuccess) { + val failed = TeamDialogStatus.Failed(result.createTeamErrorMessageId()) + _createTeamStatus.compareAndSet(TeamDialogStatus.Submitting, failed) + return@launch + } + + loadTeams() + if (_createTeamStatus.compareAndSet(TeamDialogStatus.Submitting, TeamDialogStatus.Editing)) { + updateCreateTeamStatus(null) + } + } + } + + private suspend fun loadTeams() { + val result = repository.getTeams() + if (!result.isSuccess) { + teamsState.value = TeamsUiState.Error(result.loadTeamsErrorMessageId()) + return + } + + val (myTeams, otherTeams) = result.resultData.orEmpty().partition { it.isMember } + teamsState.value = TeamsUiState.Content(myTeams, otherTeams) + } + + @AssistedFactory + interface Factory : SavedStateViewModelCreator { + override fun create(savedStateHandle: SavedStateHandle): TeamsViewModel + } + + companion object { + private const val KEY_CREATE_TEAM_NAME = "create_team_name" + private const val KEY_SEARCH_QUERY = "search_query" + private val STOP_TIMEOUT = 5.seconds + 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/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) } +} 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/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/header/EcosystemAppsBanner.kt b/app/src/main/java/com/owncloud/android/ui/activity/drawer/header/EcosystemAppsBanner.kt index 2e36c0ea8e99..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 @@ -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) } @@ -51,9 +52,10 @@ 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).forEach { tintAppView(it, color) } + listOf(notesView, talkView, moreView, assistantView, teamsView).forEach { tintAppView(it, color) } banner.visibility = View.VISIBLE } 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 3e4eda7df728..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 @@ -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/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/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 f3d6f60bf3e1..7b7267fdc1ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1481,6 +1481,45 @@ Notes Talk More + Teams + My teams + Other teams + Create team + Team name + No teams yet + 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 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 + Too many teams created in a short time. Try again in a minute. + + %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..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" @@ -164,8 +166,15 @@ 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" } +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" } @@ -177,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 @@ + + + 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