diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt index f710aae5bf9..ce080517f9f 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt @@ -62,6 +62,8 @@ import com.wire.kalium.cells.domain.usecase.publiclink.SetPublicLinkExpirationUs import com.wire.kalium.cells.domain.usecase.publiclink.UpdatePublicLinkPasswordUseCase import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase +import com.wire.kalium.cells.domain.usecase.GetConversationNameUseCase +import com.wire.kalium.cells.domain.usecase.GetUserNameUseCase import com.wire.kalium.cells.domain.usecase.offline.DeleteOfflineFileUseCase import com.wire.kalium.cells.domain.usecase.offline.GetOfflineFileUseCase import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase @@ -279,4 +281,12 @@ class CellsModule { @ViewModelScoped @Provides fun provideGetOfflineFileUseCase(cellsScope: CellsScope): GetOfflineFileUseCase = cellsScope.getOfflineFile + + @ViewModelScoped + @Provides + fun provideGetConversationNamesUseCase(cellsScope: CellsScope): GetConversationNameUseCase = cellsScope.getConversationName + + @ViewModelScoped + @Provides + fun provideGetUserNamesUseCase(cellsScope: CellsScope): GetUserNameUseCase = cellsScope.getUserName } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt index 25347a5ab34..ac1aafa85c8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt @@ -174,9 +174,9 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( download( assetId = attachment.uuid, + conversationId = null, // TODO to replace with real conversation id in next PR outFilePath = path, assetSize = attachment.assetSize ?: 0, - conversationId = null, // TODO to replace with real conversation id in next PR ) { progress -> attachment.assetSize?.let { val value = progress.toFloat() / it diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt index 3dc9c3ec67f..52d22d6ef02 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt @@ -17,11 +17,13 @@ */ package com.wire.android.feature.cells.ui +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel @@ -30,6 +32,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTa import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.WireNavigator @@ -48,26 +51,33 @@ fun AllFilesScreen( ) { val pagingListItems = viewModel.nodesFlow.collectAsLazyPagingItems() + val isOnline by viewModel.isOnline.collectAsState() WireScaffold( modifier = modifier, topBar = { Column { - SearchTopBar( - modifier = Modifier, - isSearchActive = false, - searchBarHint = stringResource(R.string.search_label), - searchQueryTextState = rememberTextFieldState(), - onTap = { - navigator.navigate( - NavigationCommand( - SearchScreenDestination( - screenType = DriveSearchScreenType.DRIVE, + AnimatedContent(isOnline) { + if (it) { + SearchTopBar( + modifier = Modifier, + isSearchActive = false, + searchBarHint = stringResource(R.string.search_label), + searchQueryTextState = rememberTextFieldState(), + onTap = { + navigator.navigate( + NavigationCommand( + SearchScreenDestination( + screenType = DriveSearchScreenType.DRIVE, + ) + ) ) - ) + }, ) - }, - ) + } else { + OfflineBanner() + } + } } }, ) { innerPadding -> @@ -79,6 +89,7 @@ fun AllFilesScreen( openFolder = { _, _, _ -> }, menuState = viewModel.menu, isAllFiles = true, + isOffline = !isOnline, isRestoreInProgress = viewModel.isRestoreInProgress.collectAsState().value, isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, isRecycleBin = viewModel.isRecycleBin(), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt index 3600ed8bf6f..a2580ce5317 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt @@ -35,7 +35,20 @@ class CellFileActionsMenu @Inject constructor( isAllFiles: Boolean, isSearching: Boolean, isCollaboraEnabled: Boolean, + isOnline: Boolean = true, ): List { + if (!isOnline) { + return buildList { + val canOpenOffline = cellNode is CellNodeUi.Folder || + (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) + if (canOpenOffline) { + add(NodeBottomSheetAction.OPEN) + } + if (cellNode is CellNodeUi.File && cellNode.isAvailableOffline) { + add(NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS) + } + } + } return when { isRecycleBin -> recycleBinActions() @@ -84,12 +97,13 @@ class CellFileActionsMenu @Inject constructor( } else -> { + + add(NodeBottomSheetAction.OPEN) + if (cellNode.localFileAvailable()) { add(NodeBottomSheetAction.SHARE) } - add(NodeBottomSheetAction.PUBLIC_LINK) - add( if (cellNode.isAvailableOffline) { NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS @@ -100,7 +114,7 @@ class CellFileActionsMenu @Inject constructor( } } } else { - add(NodeBottomSheetAction.PUBLIC_LINK) + add(NodeBottomSheetAction.OPEN) } } @@ -129,6 +143,7 @@ class CellFileActionsMenu @Inject constructor( addAll( listOf( NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, NodeBottomSheetAction.DELETE, @@ -138,6 +153,7 @@ class CellFileActionsMenu @Inject constructor( internal sealed interface MenuActionResult internal data class Action(val action: CellViewAction) : MenuActionResult + internal data class Open(val node: CellNodeUi) : MenuActionResult internal data class Share(val node: CellNodeUi.File) : MenuActionResult internal data class Edit(val node: CellNodeUi) : MenuActionResult internal data class CancelLoading(val node: CellNodeUi) : MenuActionResult @@ -153,6 +169,7 @@ class CellFileActionsMenu @Inject constructor( onResult: (MenuActionResult) -> Unit, ) { val result = when (action) { + NodeBottomSheetAction.OPEN -> Open(node) NodeBottomSheetAction.SHARE -> { if (node is CellNodeUi.File) { Share(node) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt index 658e515dbf2..0bc6436b4c6 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt @@ -66,6 +66,7 @@ internal fun CellFilesScreen( modifier: Modifier = Modifier, isPullToRefreshEnabled: Boolean = true, lazyListState: LazyListState = rememberLazyListState(), + showConversationName: Boolean = true, onItemMenuClick: (CellNodeUi) -> Unit ) { if (isPullToRefreshEnabled) { @@ -79,6 +80,7 @@ internal fun CellFilesScreen( lazyListState = lazyListState, onItemClick = onItemClick, onItemMenuClick = onItemMenuClick, + showConversationName = showConversationName, ) } } else { @@ -88,6 +90,7 @@ internal fun CellFilesScreen( lazyListState = lazyListState, onItemClick = onItemClick, onItemMenuClick = onItemMenuClick, + showConversationName = showConversationName, ) } } @@ -99,6 +102,7 @@ private fun ContentList( onItemClick: (CellNodeUi) -> Unit, onItemMenuClick: (CellNodeUi) -> Unit, modifier: Modifier = Modifier, + showConversationName: Boolean = true, ) { LazyColumn( modifier = modifier.fillMaxWidth(), @@ -118,6 +122,7 @@ private fun ContentList( .background(color = colorsScheme().surface) .clickable { onItemClick(item) }, cell = item, + showConversationName = showConversationName, onMenuClick = { onItemMenuClick(item) } ) WireDivider(modifier = Modifier.fillMaxWidth()) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt index 5c7cf8f968a..a1435da6901 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt @@ -95,6 +95,7 @@ internal fun CellListItem( cell: CellNodeUi, onMenuClick: () -> Unit, modifier: Modifier = Modifier, + showConversationName: Boolean = true, ) { val interactionSource = remember { MutableInteractionSource() } var showReadyState by remember { mutableStateOf(false) } @@ -132,7 +133,7 @@ internal fun CellListItem( ) Row(verticalAlignment = Alignment.CenterVertically) { - CellItemSubtitle(cell = cell, showReadyState = showReadyState) + CellItemSubtitle(cell = cell, showReadyState = showReadyState, showConversationName = showConversationName) } } @@ -184,7 +185,7 @@ private fun CellItemIcon(cell: CellNodeUi, showReadyState: Boolean) { } @Composable -private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { +private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean, showConversationName: Boolean) { when { cell.openLoadState is OpenLoadState.Loading -> Text( text = stringResource(R.string.tap_to_cancel_loading), @@ -241,7 +242,7 @@ private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { modifier = Modifier.padding(end = dimensions().spacing4x) ) } - cell.subtitle()?.let { + cell.subtitle(showConversationName)?.let { Text( text = it, textAlign = TextAlign.Left, @@ -446,19 +447,19 @@ private fun PublicLinkIcon( } @Composable -private fun CellNodeUi.subtitle(): String? { +private fun CellNodeUi.subtitle(showConversationName: Boolean): String? { val formattedTime = modifiedTime?.let { remember(it) { Instant.fromEpochMilliseconds(it).cellFileDateTime() } } return when { - userName != null && conversationName != null -> + showConversationName && userName != null && conversationName != null -> stringResource(R.string.file_subtitle, userName!!, conversationName!!) userName != null && formattedTime != null -> stringResource(R.string.file_subtitle_modified, formattedTime, userName!!) userName != null -> userName - conversationName != null -> conversationName + showConversationName && conversationName != null -> conversationName formattedTime != null -> formattedTime else -> null } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index c13a7e54316..fde360bda53 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt @@ -94,6 +94,7 @@ internal fun CellScreenContent( isRecycleBin: Boolean = false, isAllFiles: Boolean = false, isSearchResult: Boolean = false, + isOffline: Boolean = false, isPullToRefreshEnabled: Boolean = true, lazyListState: LazyListState = rememberLazyListState(), retryEditNodeError: (String) -> Unit = {}, @@ -142,6 +143,7 @@ internal fun CellScreenContent( onItemMenuClick = { sendIntent(CellViewIntent.OnItemMenuClick(it)) }, isRefreshing = isRefreshing, onRefresh = onRefresh, + showConversationName = !isOffline || isAllFiles || isRecycleBin, ) } @@ -246,7 +248,7 @@ internal fun CellScreenContent( is ShowFileDeletedMessage -> showDeleteConfirmation(context, action.isFile, action.permanently) is OpenFolder -> openFolder(action.path, action.title, action.parentFolderUuid) is ShowEditErrorDialog -> editNodeError = action.nodeUuid - is ShowOfflineFileSaved -> { + is ShowOfflineFileSaved -> { Toast.makeText( context, offlineFileSavedToastDescription, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index c2267803fb1..8e917a9929f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -34,6 +34,7 @@ import com.wire.android.feature.cells.ui.model.NodeBottomSheetAction import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.ui.model.canOpenWithUrl import com.wire.android.feature.cells.ui.model.localFileAvailable +import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.model.toUiModel import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.feature.cells.ui.search.SearchNavArgs @@ -45,18 +46,23 @@ import com.wire.kalium.cells.data.FileFilters import com.wire.kalium.cells.data.SortingSpec import com.wire.kalium.cells.domain.model.Node import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase +import com.wire.kalium.cells.domain.usecase.GetConversationNameUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase +import com.wire.kalium.cells.domain.usecase.GetUserNameUseCase import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase import com.wire.kalium.cells.domain.usecase.IsAtLeastOneCellAvailableUseCase import com.wire.kalium.cells.domain.usecase.RestoreNodeFromRecycleBinUseCase import com.wire.kalium.cells.domain.usecase.offline.DeleteOfflineFileUseCase import com.wire.kalium.cells.domain.usecase.offline.GetOfflineFileUseCase import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase +import com.wire.kalium.cells.domain.usecase.offline.OfflineFileInfo import com.wire.kalium.common.functional.fold import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.featureConfig.CollaboraEdition +import com.wire.kalium.network.NetworkState +import com.wire.kalium.network.NetworkStateObserver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -72,7 +78,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -99,6 +107,9 @@ class CellViewModel @Inject constructor( private val observeOfflineFiles: ObserveOfflineFilesUseCase, private val deleteOfflineFile: DeleteOfflineFileUseCase, private val getOfflineFile: GetOfflineFileUseCase, + private val networkStateObserver: NetworkStateObserver, + private val getConversationName: GetConversationNameUseCase, + private val getUserName: GetUserNameUseCase, ) : ActionsViewModel() { private val navArgs: CellFilesNavArgs = ConversationFilesScreenDestination.argsFrom(savedStateHandle) @@ -147,6 +158,14 @@ class CellViewModel @Inject constructor( } ) + val isOnline: StateFlow = networkStateObserver.observeNetworkState() + .map { it is NetworkState.ConnectedWithInternet } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = networkStateObserver.observeNetworkState().value is NetworkState.ConnectedWithInternet, + ) + private var isCollaboraEnabled: Boolean = false init { @@ -219,15 +238,47 @@ class CellViewModel @Inject constructor( } }.shareIn(viewModelScope, started = SharingStarted.Eagerly, replay = 1) - internal val nodesFlow = cellAvailableFlow.flatMapLatest { cellAvailable -> - if (!cellAvailable || searchNavArgs != null) { - flowOf(emptyData) - } else { - sharedNodesFlow + private val offlineNodesFlow: Flow> = + combine( + observeOfflineFiles(), + sharedPathCache.openLoadStates, + offlineFileDownloadController.downloadProgresses, + ) { offlineFiles, openLoadStates, downloadProgresses -> + val rootConversationId = navArgs.conversationId?.substringBefore("/") + val filtered = if (rootConversationId != null) { + offlineFiles.filter { it.conversationId == rootConversationId } + } else { + offlineFiles + } + PagingData.from( + data = filtered.map { info -> + info.toCellNodeUi( + conversationName = info.conversationId?.let { getConversationName(it) }, + userName = info.owner.ifEmpty { null }?.let { getUserName(it) }, + openLoadState = openLoadStates[info.id], + downloadProgress = downloadProgresses[info.id], + ) + }, + sourceLoadStates = LoadStates( + refresh = LoadState.NotLoading(true), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true), + ) + ) + } + + internal val nodesFlow = combine(cellAvailableFlow, isOnline) { cellAvailable, online -> + cellAvailable to online + }.flatMapLatest { (cellAvailable, online) -> + when { + !cellAvailable || searchNavArgs != null -> flowOf(emptyData) + !online -> offlineNodesFlow + else -> sharedNodesFlow } } fun onPullToRefresh() { + if (!isOnline.value) return _isPullToRefresh.value = true refreshNodes() } @@ -357,6 +408,7 @@ class CellViewModel @Inject constructor( isSearching = searchNavArgs?.screenType == DriveSearchScreenType.SHARED_DRIVE || searchNavArgs?.screenType == DriveSearchScreenType.DRIVE, isCollaboraEnabled = isCollaboraEnabled, + isOnline = isOnline.value, ) _menu.emit(MenuOptions(cellNode, menuItems)) @@ -371,6 +423,7 @@ class CellViewModel @Inject constructor( ) { result -> when (result) { is CellFileActionsMenu.Action -> sendAction(result.action) + is CellFileActionsMenu.Open -> sendIntent(CellViewIntent.OnItemClick(result.node)) is CellFileActionsMenu.Edit -> editNode(result.node.uuid) is CellFileActionsMenu.Share -> shareFile(result.node) is CellFileActionsMenu.CancelLoading -> cancelDownload(result.node.uuid) @@ -384,7 +437,9 @@ class CellViewModel @Inject constructor( private fun makeAvailableOffline(node: CellNodeUi.File) { offlineFileDownloadController.start( scope = viewModelScope, - cellNode = node, + cellNode = node.copy( + conversationId = navArgs.conversationId + ), onSuccess = { _ -> sendAction(ShowOfflineFileSaved) }, onError = { sendAction(ShowError(it)) }, ) @@ -506,6 +561,37 @@ class CellViewModel @Inject constructor( isCollaboraEnabled = config?.collabora != CollaboraEdition.NO } + private fun OfflineFileInfo.toCellNodeUi( + conversationName: String? = null, + userName: String? = null, + openLoadState: OpenLoadState? = null, + downloadProgress: Float? = null, + ): CellNodeUi.File { + val resolvedMimeType = mimeType.orEmpty() + val extension = name.substringAfterLast('.', "") + return CellNodeUi.File( + uuid = id, + conversationId = conversationId, + name = name, + mimeType = resolvedMimeType, + assetType = if (resolvedMimeType.isNotBlank()) { + AttachmentFileType.fromMimeType(resolvedMimeType) + } else { + AttachmentFileType.fromExtension(extension) + }, + size = size, + localPath = localPath, + ownerUserId = owner.ifEmpty { null }, + userName = userName, + userHandle = null, + conversationName = conversationName, + modifiedTime = modifiedAt, + isAvailableOffline = true, + openLoadState = openLoadState, + downloadProgress = downloadProgress, + ) + } + companion object { private val emptyData: PagingData = PagingData.empty( LoadStates( diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index c39395edaea..84d58aab39d 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -56,6 +57,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.SearchScree import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet @@ -103,6 +105,8 @@ fun ConversationFilesScreen( animatedVisibilityScope: AnimatedVisibilityScope, viewModel: CellViewModel = hiltViewModel(), ) { + val isOnline by viewModel.isOnline.collectAsState() + ConversationFilesScreenContent( animatedVisibilityScope = animatedVisibilityScope, navigator = navigator, @@ -112,6 +116,7 @@ fun ConversationFilesScreen( pagingListItems = viewModel.nodesFlow.collectAsLazyPagingItems(), menu = viewModel.menu, isSearchResult = false, + isOnline = isOnline, isRestoreInProgress = viewModel.isRestoreInProgress.collectAsState().value, isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, isRefreshing = viewModel.isPullToRefresh.collectAsState(), @@ -146,6 +151,7 @@ internal fun ConversationFilesScreenContent( screenTitle: String? = null, isRecycleBin: Boolean = false, isRestoreInProgress: Boolean = false, + isOnline: Boolean = true, breadcrumbs: Array? = emptyArray(), fileReadyFlow: Flow = emptyFlow(), ) { @@ -223,7 +229,7 @@ internal fun ConversationFilesScreenContent( navigationIconType = NavigationIconType.Back(), elevation = dimensions().spacing0x, actions = { - if (!isRecycleBin) { + if (!isRecycleBin && isOnline) { MoreOptionIcon( contentDescription = R.string.content_description_conversation_files_more_button, onButtonClicked = { optionsBottomSheetState.show() } @@ -232,23 +238,27 @@ internal fun ConversationFilesScreenContent( } ) - SearchTopBar( - modifier = Modifier - .sharedElement( - sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), - animatedVisibilityScope = animatedVisibilityScope - ), - isSearchActive = false, - searchBarHint = stringResource(R.string.search_label), - searchQueryTextState = TextFieldState(), - onTap = { - currentNodeUuid?.let { - navigator.navigate( - NavigationCommand(SearchScreenDestination(conversationId = it)) - ) - } - }, - ) + if (isOnline) { + SearchTopBar( + modifier = Modifier + .sharedElement( + sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), + animatedVisibilityScope = animatedVisibilityScope + ), + isSearchActive = false, + searchBarHint = stringResource(R.string.search_label), + searchQueryTextState = TextFieldState(), + onTap = { + currentNodeUuid?.let { + navigator.navigate( + NavigationCommand(SearchScreenDestination(conversationId = it)) + ) + } + }, + ) + } else { + OfflineBanner() + } } }, floatingActionButton = { @@ -290,6 +300,7 @@ internal fun ConversationFilesScreenContent( isRestoreInProgress = isRestoreInProgress, isDeleteInProgress = isDeleteInProgress, isRecycleBin = isRecycleBin, + isOffline = !isOnline, openFolder = { path, title, parentFolderUuid -> navigator.navigate( NavigationCommand( diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt index 4542b2d8478..31263a60eb9 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt @@ -103,6 +103,7 @@ class OfflineFileDownloadController @Inject constructor( OfflineFileInfo( id = cellNode.uuid, name = nodeName, + mimeType = cellNode.mimeType, owner = cellNode.ownerUserId ?: "", localPath = existingPath, size = cellNode.size, @@ -150,6 +151,7 @@ class OfflineFileDownloadController @Inject constructor( OfflineFileInfo( id = cellNode.uuid, name = nodeName, + mimeType = cellNode.mimeType, owner = cellNode.ownerUserId ?: "", localPath = filePath.toString(), size = cellNode.size, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/common/OfflineBanner.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/common/OfflineBanner.kt new file mode 100644 index 00000000000..589482bf5fb --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/common/OfflineBanner.kt @@ -0,0 +1,79 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import com.wire.android.feature.cells.R +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireTypography + +@Composable +internal fun OfflineBanner(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(dimensions().spacing12x), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_wifi_signal), + modifier = Modifier + .width(dimensions().spacing14x) + .height(dimensions().spacing14x) + .align(Alignment.CenterVertically), + contentDescription = null, + tint = colorsScheme().onBackground + ) + Text( + modifier = Modifier.padding(start = dimensions().spacing6x), + text = stringResource(R.string.offline_banner_message), + style = MaterialTheme.wireTypography.body01, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@MultipleThemePreviews +@Composable +private fun PreviewOfflineBanner() { + WireTheme { + OfflineBanner() + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt index 71da93176f0..ed934ba2f52 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt @@ -24,6 +24,7 @@ enum class NodeBottomSheetAction( val icon: Int, val isHighlighted: Boolean = false ) { + OPEN(R.string.open_label, R.drawable.ic_open), SHARE(R.string.share_label, R.drawable.ic_share), PUBLIC_LINK(R.string.public_link, R.drawable.ic_link), ADD_REMOVE_TAGS(R.string.add_remove_tags_label, R.drawable.ic_tags), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt index fcdbd4af8a4..3120007e4bd 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt @@ -17,6 +17,7 @@ */ package com.wire.android.feature.cells.ui.search +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Column @@ -47,6 +48,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.VersionHist import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.CellScreenContent import com.wire.android.feature.cells.ui.CellViewModel +import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.search.filter.FilterChipsRow import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FilterByTypeBottomSheet @@ -63,6 +65,8 @@ import com.wire.android.navigation.transition.SHARED_ELEMENT_SEARCH_INPUT_KEY import com.wire.android.ui.common.bottomsheet.WireSheetValue import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.search.SearchTopBar @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -79,6 +83,7 @@ fun SearchScreen( searchScreenViewModel: SearchScreenViewModel = hiltViewModel(), ) { val uiState by searchScreenViewModel.uiState.collectAsStateWithLifecycle() + val isOnline by cellViewModel.isOnline.collectAsState() val filterTypeSheetState = rememberWireModalSheetState(WireSheetValue.Hidden) val filterTagsSheetState = rememberWireModalSheetState(WireSheetValue.Hidden) @@ -101,67 +106,80 @@ fun SearchScreen( WireScaffold( modifier = modifier, topBar = { - Column { - SearchTopBar( - modifier = Modifier.sharedElement( - sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), - animatedVisibilityScope = animatedVisibilityScope - ), - isSearchActive = uiState.isSearchActive, - shouldClearTextOnClearFocus = false, - keepBackButtonVisible = true, - searchBarHint = when (searchScreenViewModel.screenType) { - DriveSearchScreenType.SHARED_DRIVE -> stringResource(R.string.search_shared_drive_text_input_hint) - DriveSearchScreenType.DRIVE -> stringResource(R.string.search_drive_text_input_hint) - }, - searchQueryTextState = searchState, - onCloseSearchClicked = { navigator.navigateBack() }, - onActiveChanged = { - searchScreenViewModel.onSetSearchActive(it) - }, - ) - FilterChipsRow( - state = uiState.chipsState, - screenType = searchScreenViewModel.screenType, - onFilterByTagsClicked = { - searchScreenViewModel.onSetSearchActive(false) - filterTagsSheetState.show(Unit, isImeVisible) - }, - onFilterByTypeClicked = { - searchScreenViewModel.onSetSearchActive(false) - filterTypeSheetState.show(Unit, isImeVisible) - }, - onFilterByOwnerClicked = { - searchScreenViewModel.onSetSearchActive(false) - filterOwnerSheetState.show(Unit, isImeVisible) - }, - onFilterBySharedByLinkClicked = { - searchScreenViewModel.onSharedByMeClicked() - }, - onFilterByConversationClicked = { - searchScreenViewModel.onSetSearchActive(false) - filterConversationSheetState.show(Unit, isImeVisible) - }, - onRemoveAllFiltersClicked = { - searchScreenViewModel.onRemoveAllFilters() - } - ) + AnimatedContent(isOnline) { online -> + if (online) { + Column { + SearchTopBar( + modifier = Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), + animatedVisibilityScope = animatedVisibilityScope + ), + isSearchActive = uiState.isSearchActive, + shouldClearTextOnClearFocus = false, + keepBackButtonVisible = true, + searchBarHint = when (searchScreenViewModel.screenType) { + DriveSearchScreenType.SHARED_DRIVE -> stringResource(R.string.search_shared_drive_text_input_hint) + DriveSearchScreenType.DRIVE -> stringResource(R.string.search_drive_text_input_hint) + }, + searchQueryTextState = searchState, + onCloseSearchClicked = { navigator.navigateBack() }, + onActiveChanged = { + searchScreenViewModel.onSetSearchActive(it) + }, + ) + FilterChipsRow( + state = uiState.chipsState, + screenType = searchScreenViewModel.screenType, + onFilterByTagsClicked = { + searchScreenViewModel.onSetSearchActive(false) + filterTagsSheetState.show(Unit, isImeVisible) + }, + onFilterByTypeClicked = { + searchScreenViewModel.onSetSearchActive(false) + filterTypeSheetState.show(Unit, isImeVisible) + }, + onFilterByOwnerClicked = { + searchScreenViewModel.onSetSearchActive(false) + filterOwnerSheetState.show(Unit, isImeVisible) + }, + onFilterBySharedByLinkClicked = { + searchScreenViewModel.onSharedByMeClicked() + }, + onFilterByConversationClicked = { + searchScreenViewModel.onSetSearchActive(false) + filterConversationSheetState.show(Unit, isImeVisible) + }, + onRemoveAllFiltersClicked = { + searchScreenViewModel.onRemoveAllFilters() + } + ) - with(uiState) { - SortRowWithMenu( - screenType = searchScreenViewModel.screenType, - sortingCriteria = sortingCriteria, - isSearchResult = searchState.text.isNotEmpty() || hasAnyFilter, - onSortByClicked = { - searchScreenViewModel.setSortBy(it) - }, - onOrderClicked = { - searchScreenViewModel.setSorting(it) + with(uiState) { + SortRowWithMenu( + screenType = searchScreenViewModel.screenType, + sortingCriteria = sortingCriteria, + isSearchResult = searchState.text.isNotEmpty() || hasAnyFilter, + onSortByClicked = { + searchScreenViewModel.setSortBy(it) + }, + onOrderClicked = { + searchScreenViewModel.setSorting(it) + } + ) } - ) + } + } else { + Column { + WireCenterAlignedTopAppBar( + title = "", + navigationIconType = NavigationIconType.Close(), + onNavigationPressed = { navigator.navigateBack() }, + ) + OfflineBanner() + } } } - } + }, ) { innerPadding -> val lazyListState = rememberLazyListState() @@ -173,7 +191,7 @@ fun SearchScreen( val lazyItems = if (isShowingFilteredResults) filteredItems else initialItems LaunchedEffect(uiState.sortingCriteria) { - lazyListState.animateScrollToItem(0) + lazyListState.animateScrollToItem(0) } CellScreenContent( diff --git a/features/cells/src/main/res/drawable/ic_cross_in_circle.xml b/features/cells/src/main/res/drawable/ic_cross_in_circle.xml index 646b6668fb0..a728332c130 100644 --- a/features/cells/src/main/res/drawable/ic_cross_in_circle.xml +++ b/features/cells/src/main/res/drawable/ic_cross_in_circle.xml @@ -19,12 +19,12 @@ android:viewportWidth="16" android:viewportHeight="16"> + android:fillColor="#000000" + android:pathData="M8,0C12.418,0 16,3.582 16,8C16,12.418 12.418,16 8,16C3.582,16 0,12.418 0,8C0,3.582 3.582,0 8,0ZM8,2C4.686,2 2,4.686 2,8C2,11.314 4.686,14 8,14C11.314,14 14,11.314 14,8C14,4.686 11.314,2 8,2Z" /> + android:fillColor="#000000" + android:pathData="M4.667,5.86L5.86,4.667L11.537,10.343L10.343,11.536L4.667,5.86Z" /> + android:fillColor="#000000" + android:pathData="M10.343,4.667L11.536,5.86L5.86,11.536L4.666,10.343L10.343,4.667Z" /> diff --git a/features/cells/src/main/res/drawable/ic_open.xml b/features/cells/src/main/res/drawable/ic_open.xml new file mode 100644 index 00000000000..c0bd6a871a3 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_open.xml @@ -0,0 +1,24 @@ + + + + diff --git a/features/cells/src/main/res/drawable/ic_wifi_signal.xml b/features/cells/src/main/res/drawable/ic_wifi_signal.xml new file mode 100644 index 00000000000..26356772174 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_wifi_signal.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 5cc6a00a466..a0cee253cd3 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -85,8 +85,10 @@ Make available offline Remove offline access File saved for offline use + You\'re offline and can see only saved files \"%1$s\" ready to open Open + Open Unable to create folder. Please try again Move to folder Move Here diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt index fc32810c05a..ed61f9985cc 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt @@ -35,6 +35,8 @@ import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase +import com.wire.kalium.cells.domain.usecase.GetConversationNameUseCase +import com.wire.kalium.cells.domain.usecase.GetUserNameUseCase import com.wire.kalium.cells.domain.usecase.IsAtLeastOneCellAvailableUseCase import com.wire.kalium.cells.domain.usecase.RestoreNodeFromRecycleBinUseCase import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase @@ -42,6 +44,9 @@ import com.wire.kalium.cells.domain.usecase.offline.DeleteOfflineFileUseCase import com.wire.kalium.cells.domain.usecase.offline.GetOfflineFileUseCase import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase import com.wire.kalium.common.functional.right +import com.wire.kalium.network.NetworkState +import com.wire.kalium.network.NetworkStateObserver +import kotlinx.coroutines.flow.MutableStateFlow import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -308,6 +313,15 @@ class CellViewModelTest { @MockK lateinit var getOfflineFile: GetOfflineFileUseCase + @MockK + lateinit var networkStateObserver: NetworkStateObserver + + @MockK + lateinit var getConversationNames: GetConversationNameUseCase + + @MockK + lateinit var getUserNames: GetUserNameUseCase + init { MockKAnnotations.init(this, relaxUnitFun = true) @@ -324,6 +338,9 @@ class CellViewModelTest { every { observeOfflineFiles() } returns flowOf(emptyList()) coEvery { getOfflineFile(any()) } returns null + every { networkStateObserver.observeNetworkState() } returns MutableStateFlow(NetworkState.ConnectedWithInternet) + coEvery { getConversationNames(any()) } returns null + coEvery { getUserNames(any()) } returns null coEvery { getCellFilesPagedUseCase.invoke(any(), any(), any(), any()) } returns flowOf( PagingData.from( @@ -410,6 +427,9 @@ class CellViewModelTest { observeOfflineFiles = observeOfflineFiles, deleteOfflineFile = deleteOfflineFile, getOfflineFile = getOfflineFile, + networkStateObserver = networkStateObserver, + getConversationName = getConversationNames, + getUserName = getUserNames, ) } } diff --git a/kalium b/kalium index 79ebff4e595..012b5dee559 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 79ebff4e595fffc136114a5fc5bd2138db4c0b8b +Subproject commit 012b5dee559d8f3111e948d9b83e8d84f0e1e9de