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 5adfe59138..f5c4c4db5c 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 @@ -19,8 +19,10 @@ package com.wire.android.di.accountScoped import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic +import com.wire.android.feature.cells.ui.versioning.VersionGroupHelper import com.wire.android.feature.cells.util.FileNameResolver import com.wire.android.ui.home.conversations.model.messagetypes.multipart.CellAssetRefreshHelper +import com.wire.android.util.FileSizeFormatter import com.wire.kalium.cells.CellsScope import com.wire.kalium.cells.domain.CellUploadManager import com.wire.kalium.cells.domain.usecase.AddAttachmentDraftUseCase @@ -220,4 +222,8 @@ class CellsModule { @Provides fun provideRestoreNodeVersionUseCase(cellsScope: CellsScope): RestoreNodeVersionUseCase = cellsScope.restoreNodeVersion + + @ViewModelScoped + @Provides + fun provideVersionGroupHelper(fileSizeFormatter: FileSizeFormatter): VersionGroupHelper = VersionGroupHelper(fileSizeFormatter) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionGroupHelper.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionGroupHelper.kt new file mode 100644 index 0000000000..fdbadc2a58 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionGroupHelper.kt @@ -0,0 +1,86 @@ +/* + * Wire + * Copyright (C) 2025 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.versioning + +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel.Companion.DATE_PATTERN +import com.wire.android.util.FileSizeFormatter +import com.wire.android.util.ui.UIText +import com.wire.kalium.cells.domain.model.NodeVersion +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +class VersionGroupHelper @OptIn(ExperimentalTime::class) constructor( + private val fileSizeFormatter: FileSizeFormatter, + private val currentTime: () -> Long = { Clock.System.now().toEpochMilliseconds() } +) { + + fun groupByDay(versions: List): List { + val today = LocalDate.ofInstant(Instant.ofEpochMilli(currentTime()), ZoneId.systemDefault()) + val yesterday = today.minusDays(1) + + val grouped = versions.groupBy { item -> + Instant.ofEpochSecond(item.modifiedTime?.toLong() ?: 0L) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + + return grouped.entries + .sortedByDescending { it.key } + .mapIndexed { groupIndex, (date, items) -> + val dateFormat = DateTimeFormatter.ofPattern(DATE_PATTERN) + val formattedDate = date.format(dateFormat) + + val dateLabel: UIText = when (date) { + today -> UIText.StringResource( + R.string.date_label_today, + formattedDate + ) + + yesterday -> UIText.StringResource( + R.string.date_label_yesterday, + formattedDate + ) + + else -> UIText.DynamicString(formattedDate) + } + + val uiItems = items.mapIndexed { itemIndex, apiItem -> + + val formattedTime = Instant.ofEpochSecond(apiItem.modifiedTime?.toLong() ?: 0L) + .atZone(ZoneId.systemDefault()) + .toLocalTime() + .format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)) + + CellVersion( + versionId = apiItem.id, + modifiedBy = apiItem.ownerName ?: "", + fileSize = fileSizeFormatter.formatSize(apiItem.size?.toLong() ?: 0), + modifiedAt = formattedTime, + isCurrentVersion = groupIndex == 0 && itemIndex == 0 + ) + } + VersionGroup(dateLabel, uiItems) + } + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt index 7e17a7ce66..7d1c82ab97 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt @@ -22,13 +22,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.navArgs import com.wire.android.feature.cells.ui.versioning.restore.RestoreDialogState import com.wire.android.feature.cells.ui.versioning.restore.RestoreVersionState -import com.wire.android.util.FileSizeFormatter -import com.wire.android.util.ui.UIText -import com.wire.kalium.cells.domain.model.NodeVersion import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase import com.wire.kalium.common.functional.onFailure @@ -36,19 +32,14 @@ import com.wire.kalium.common.functional.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle import javax.inject.Inject @HiltViewModel class VersionHistoryViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val getNodeVersionsUseCase: GetNodeVersionsUseCase, - private val fileSizeFormatter: FileSizeFormatter, private val restoreNodeVersionUseCase: RestoreNodeVersionUseCase, + private val versionGroupHelper: VersionGroupHelper, ) : ViewModel() { private val navArgs: VersionHistoryNavArgs = savedStateHandle.navArgs() @@ -81,7 +72,7 @@ class VersionHistoryViewModel @Inject constructor( getNodeVersionsUseCase(navArgs.uuid) .onSuccess { versionHistoryState.value = VersionHistoryState.Success - versionsGroupedByTime.value = it.groupByDay() + versionsGroupedByTime.value = versionGroupHelper.groupByDay(it) } // TODO: Handle error on UI .onFailure { @@ -89,55 +80,6 @@ class VersionHistoryViewModel @Inject constructor( } } - private fun List.groupByDay(): List { - val today = LocalDate.now() - val yesterday = today.minusDays(1) - - val grouped = this.groupBy { item -> - Instant.ofEpochSecond(item.modifiedTime?.toLong() ?: 0L) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - } - - return grouped.entries - .sortedByDescending { it.key } - .mapIndexed { groupIndex, (date, items) -> - val dateFormat = DateTimeFormatter.ofPattern(DATE_PATTERN) - val formattedDate = date.format(dateFormat) - - val dateLabel: UIText = when (date) { - today -> UIText.StringResource( - R.string.date_label_today, - formattedDate - ) - - yesterday -> UIText.StringResource( - R.string.date_label_yesterday, - formattedDate - ) - - else -> UIText.DynamicString(formattedDate) - } - - val uiItems = items.mapIndexed { itemIndex, apiItem -> - - val formattedTime = Instant.ofEpochSecond(apiItem.modifiedTime?.toLong() ?: 0L) - .atZone(ZoneId.systemDefault()) - .toLocalTime() - .format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)) - - CellVersion( - versionId = apiItem.id, - modifiedBy = apiItem.ownerName ?: "", - fileSize = fileSizeFormatter.formatSize(apiItem.size?.toLong() ?: 0), - modifiedAt = formattedTime, - isCurrentVersion = groupIndex == 0 && itemIndex == 0 - ) - } - VersionGroup(dateLabel, uiItems) - } - } - // TODO: Unit test coming in another PR fun showRestoreConfirmationDialog(versionId: String) { restoreDialogState.value = restoreDialogState.value.copy( diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt index a6157059e1..05d5210e93 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -42,9 +43,12 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.Instant import java.time.LocalDate +import java.time.ZoneId import java.time.ZoneOffset import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle @ExperimentalCoroutinesApi class VersionHistoryViewModelTest { @@ -75,7 +79,8 @@ class VersionHistoryViewModelTest { fun givenViewModel_whenItInits_thenIsFetchingStateIsManagedCorrectly() = runTest { coEvery { getNodeVersionsUseCase(testNodeUuid) } returns Either.Right(emptyList()) - val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, fileSizeFormatter, restoreNodeVersionUseCase) + val versionGroupHelper = VersionGroupHelper(fileSizeFormatter, { currentTime }) + val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, restoreNodeVersionUseCase, versionGroupHelper) assertEquals(VersionHistoryState.Idle, viewModel.versionHistoryState.value) advanceUntilIdle() @@ -85,7 +90,8 @@ class VersionHistoryViewModelTest { @Suppress("LongMethod") @Test fun givenSuccessfulFetch_whenViewModelInits_thenVersionsAreGroupedCorrectly() = runTest { - val today = LocalDate.now() + + val today = LocalDate.ofInstant(Instant.ofEpochSecond(currentTime), ZoneId.systemDefault()) val yesterday = today.minusDays(1) val twoDaysAgo = today.minusDays(2) val versionNode = NodeVersion( @@ -128,7 +134,14 @@ class VersionHistoryViewModelTest { coEvery { getNodeVersionsUseCase(testNodeUuid) } returns Either.Right(versionsFromApi) every { fileSizeFormatter.formatSize(any()) } returns "30 MB" - val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, fileSizeFormatter, restoreNodeVersionUseCase) + val versionGroupHelper = VersionGroupHelper(fileSizeFormatter, { currentTime }) + + val viewModel = VersionHistoryViewModel( + savedStateHandle = savedStateHandle, + getNodeVersionsUseCase = getNodeVersionsUseCase, + restoreNodeVersionUseCase = restoreNodeVersionUseCase, + versionGroupHelper = versionGroupHelper, + ) testDispatcher.scheduler.advanceUntilIdle() // Versions should be grouped into three sections (Today, Yesterday, and an older date) @@ -145,7 +158,13 @@ class VersionHistoryViewModelTest { assertEquals("Today, $todayFormattedDate", actualTodayText) assertEquals(1, groupedVersions[0].versions.size) assertEquals("User A", groupedVersions[0].versions[0].modifiedBy) - assertEquals("11:30 AM", groupedVersions[0].versions[0].modifiedAt) + + val expectedTime = Instant + .ofEpochSecond(versionNode.modifiedTime!!.toLong()) + .atZone(ZoneId.systemDefault()) + .toLocalTime() + .format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)) + assertEquals(expectedTime, groupedVersions[0].versions[0].modifiedAt) // Verify "Yesterday" group is correct every { fileSizeFormatter.formatSize(any()) } returns groupedVersions[1].versions[0].fileSize @@ -168,7 +187,8 @@ class VersionHistoryViewModelTest { fun givenApiFailure_whenViewModelInits_thenVersionListIsEmpty() = runTest { coEvery { getNodeVersionsUseCase(testNodeUuid) } returns Either.Left(CoreFailure.MissingClientRegistration) - val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, fileSizeFormatter, restoreNodeVersionUseCase) + val versionGroupHelper = VersionGroupHelper(fileSizeFormatter, { currentTime }) + val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, restoreNodeVersionUseCase, versionGroupHelper) advanceUntilIdle() assertTrue(viewModel.versionsGroupedByTime.value.isEmpty()) @@ -179,7 +199,8 @@ class VersionHistoryViewModelTest { fun givenMissingUuid_whenViewModelInits_thenNoFetchIsAttempted() = runTest { every { savedStateHandle.get("uuid") } returns null - val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, fileSizeFormatter, restoreNodeVersionUseCase) + val versionGroupHelper = VersionGroupHelper(fileSizeFormatter, { currentTime }) + val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, restoreNodeVersionUseCase, versionGroupHelper) advanceUntilIdle() assertTrue(viewModel.versionsGroupedByTime.value.isEmpty())