mirror of
https://github.com/ProtonDriveApps/android-drive.git
synced 2026-05-15 09:50:34 +00:00
2.19.0
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"project": "android-drive",
|
||||
"locale": "c45081f9f1a84cc7c5d9344759bfe91b69a985c2"
|
||||
}
|
||||
"locale": "4c89919839fd831eddec9a556ab6e713aa607375"
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
package me.proton.android.drive.ui.dialog
|
||||
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -35,7 +36,7 @@ import androidx.lifecycle.flowWithLifecycle
|
||||
import me.proton.android.drive.photos.presentation.extension.details
|
||||
import me.proton.android.drive.ui.viewmodel.AlbumOptionsViewModel
|
||||
import me.proton.core.compose.component.bottomsheet.BottomSheetContent
|
||||
import me.proton.core.drive.base.presentation.R
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.drive.base.presentation.component.BottomSheetEntry
|
||||
import me.proton.core.drive.base.presentation.component.RunAction
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
@@ -45,6 +46,7 @@ import me.proton.core.drive.files.presentation.entry.FileOptionEntry
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.thumbnail.presentation.extension.thumbnailPainter
|
||||
import me.proton.core.drive.base.presentation.R as BasePresentation
|
||||
|
||||
@Composable
|
||||
fun AlbumOptions(
|
||||
@@ -132,18 +134,33 @@ fun AlbumOptionsHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coverPainter = coverLink?.thumbnailPainter()?.painter
|
||||
?: painterResource(id = R.drawable.img_whats_new_public_sharing) //TODO: once available put proper no-cover illustration
|
||||
val localContext = LocalContext.current
|
||||
val details = remember (localContext) {
|
||||
album.details(localContext, useCreationTime = false)
|
||||
}
|
||||
OptionsHeader(
|
||||
painter = coverPainter,
|
||||
title = album.name,
|
||||
isTitleEncrypted = album.isNameEncrypted,
|
||||
subtitle = details,
|
||||
modifier = modifier,
|
||||
)
|
||||
if (coverPainter != null) {
|
||||
OptionsHeader(
|
||||
painter = coverPainter,
|
||||
title = album.name,
|
||||
isTitleEncrypted = album.isNameEncrypted,
|
||||
subtitle = details,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
OptionsHeader(
|
||||
title = album.name,
|
||||
isTitleEncrypted = album.isNameEncrypted,
|
||||
subtitle = details,
|
||||
modifier = modifier,
|
||||
) { contentModifier ->
|
||||
Icon(
|
||||
painter = painterResource(id = BasePresentation.drawable.ic_proton_images),
|
||||
contentDescription = null,
|
||||
tint = ProtonTheme.colors.iconNorm,
|
||||
modifier = contentModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object AlbumOptionsTestTag {
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
|
||||
package me.proton.android.drive.ui.effect
|
||||
|
||||
import me.proton.core.drive.volume.domain.entity.VolumeId
|
||||
|
||||
sealed class TrashEffect {
|
||||
data class ShowSnackbar(val message: String) : TrashEffect()
|
||||
object MoreOptions : TrashEffect()
|
||||
data class MoreOptions(val volumeId: VolumeId) : TrashEffect()
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ import me.proton.android.drive.ui.navigation.internal.MutableNavControllerSaver
|
||||
import me.proton.android.drive.ui.navigation.internal.createNavController
|
||||
import me.proton.android.drive.ui.navigation.internal.modalBottomSheet
|
||||
import me.proton.android.drive.ui.navigation.internal.rememberAnimatedNavController
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.screen.AccountSettingsScreen
|
||||
import me.proton.android.drive.ui.screen.AlbumScreen
|
||||
import me.proton.android.drive.ui.screen.AppAccessScreen
|
||||
@@ -113,6 +112,8 @@ import me.proton.android.drive.ui.screen.MoveToFolder
|
||||
import me.proton.android.drive.ui.screen.OfflineScreen
|
||||
import me.proton.android.drive.ui.screen.PhotosBackupScreen
|
||||
import me.proton.android.drive.ui.screen.PhotosUpsellScreen
|
||||
import me.proton.android.drive.ui.screen.PickerAlbumScreen
|
||||
import me.proton.android.drive.ui.screen.PickerPhotosAndAlbumsScreen
|
||||
import me.proton.android.drive.ui.screen.PreviewScreen
|
||||
import me.proton.android.drive.ui.screen.SettingsScreen
|
||||
import me.proton.android.drive.ui.screen.SigningOutScreen
|
||||
@@ -387,6 +388,8 @@ fun AppNavGraph(
|
||||
addAlbum(navController)
|
||||
addAlbumOptions(navController)
|
||||
addConfirmDeleteAlbumDialog(navController)
|
||||
addPickerPhotosAndAlbums(navController)
|
||||
addPickerAlbum(navController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,9 +489,13 @@ fun NavGraphBuilder.addFileOrFolderOptions(
|
||||
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.FileOrFolderOptions.SHARE_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.FileOrFolderOptions.LINK_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.FileOrFolderOptions.OPTIONS_FILTER) {
|
||||
type = NavType.EnumType(OptionsFilter::class.java)
|
||||
defaultValue = OptionsFilter.FILES
|
||||
navArgument(Screen.FileOrFolderOptions.ALBUM_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
navArgument(Screen.FileOrFolderOptions.KEY_ALBUM_SHARE_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
),
|
||||
) { navBackStackEntry, runAction ->
|
||||
@@ -557,9 +564,13 @@ fun NavGraphBuilder.addMultipleFileOrFolderOptions(
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.MultipleFileOrFolderOptions.SELECTION_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.MultipleFileOrFolderOptions.OPTIONS_FILTER) {
|
||||
type = NavType.EnumType(OptionsFilter::class.java)
|
||||
defaultValue = OptionsFilter.FILES
|
||||
navArgument(Screen.MultipleFileOrFolderOptions.ALBUM_SHARE_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
navArgument(Screen.MultipleFileOrFolderOptions.ALBUM_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
),
|
||||
) { navBackStackEntry, runAction ->
|
||||
@@ -807,6 +818,7 @@ fun NavGraphBuilder.addConfirmEmptyTrashDialog(navController: NavHostController)
|
||||
route = Screen.Files.Dialogs.ConfirmEmptyTrash.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Files.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.Files.VOLUME_ID) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
ConfirmEmptyTrashDialog(
|
||||
@@ -872,8 +884,8 @@ internal fun NavGraphBuilder.addHome(
|
||||
navigateToOffline = {
|
||||
navController.navigate(Screen.OfflineFiles(userId))
|
||||
},
|
||||
navigateToPreview = { fileId, pagerType, optionsFilter ->
|
||||
navController.navigate(Screen.PagerPreview(pagerType, userId, fileId, optionsFilter))
|
||||
navigateToPreview = { fileId, pagerType ->
|
||||
navController.navigate(Screen.PagerPreview(pagerType, userId, fileId))
|
||||
},
|
||||
navigateToSorting = { sorting ->
|
||||
navController.navigate(
|
||||
@@ -883,14 +895,14 @@ internal fun NavGraphBuilder.addHome(
|
||||
navigateToSettings = {
|
||||
navController.navigate(Screen.Settings(userId))
|
||||
},
|
||||
navigateToFileOrFolderOptions = { linkId, optionsFilter ->
|
||||
navigateToFileOrFolderOptions = { linkId ->
|
||||
navController.navigate(
|
||||
Screen.FileOrFolderOptions(userId, linkId, optionsFilter)
|
||||
Screen.FileOrFolderOptions(userId, linkId)
|
||||
)
|
||||
},
|
||||
navigateToMultipleFileOrFolderOptions = { selectionId, optionsFilter ->
|
||||
navigateToMultipleFileOrFolderOptions = { selectionId ->
|
||||
navController.navigate(
|
||||
Screen.MultipleFileOrFolderOptions(userId, selectionId, optionsFilter)
|
||||
Screen.MultipleFileOrFolderOptions(userId, selectionId)
|
||||
)
|
||||
},
|
||||
navigateToParentFolderOptions = { folderId ->
|
||||
@@ -1269,8 +1281,8 @@ fun NavGraphBuilder.addTrash(navController: NavHostController) = composable(
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
navigateToEmptyTrash = {
|
||||
navController.navigate(Screen.Files.Dialogs.ConfirmEmptyTrash(userId))
|
||||
navigateToEmptyTrash = { volumeId ->
|
||||
navController.navigate(Screen.Files.Dialogs.ConfirmEmptyTrash(userId, volumeId))
|
||||
},
|
||||
navigateToFileOrFolderOptions = { linkId ->
|
||||
navController.navigate(Screen.FileOrFolderOptions(userId, linkId))
|
||||
@@ -1341,18 +1353,19 @@ fun NavGraphBuilder.addPagerPreview(navController: NavHostController) = composab
|
||||
arguments = listOf(
|
||||
navArgument(Screen.PagerPreview.PAGER_TYPE) { type = NavType.EnumType(PagerType::class.java) },
|
||||
navArgument(Screen.PagerPreview.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.PagerPreview.SHARE_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.PagerPreview.FILE_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.PagerPreview.OPTIONS_FILTER) {
|
||||
type = NavType.EnumType(OptionsFilter::class.java)
|
||||
defaultValue = OptionsFilter.FILES
|
||||
navArgument(Screen.PagerPreview.ALBUM_SHARE_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
navArgument(Screen.PagerPreview.ALBUM_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
),
|
||||
) { navBackStackEntry ->
|
||||
val userId = UserId(navBackStackEntry.require(Screen.Files.USER_ID))
|
||||
val optionsFilter = navBackStackEntry.requireSerializable(
|
||||
Screen.PagerPreview.OPTIONS_FILTER,
|
||||
OptionsFilter::class.java
|
||||
)
|
||||
PreviewScreen(
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
@@ -1360,9 +1373,9 @@ fun NavGraphBuilder.addPagerPreview(navController: NavHostController) = composab
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
navigateToFileOrFolderOptions = { linkId ->
|
||||
navigateToFileOrFolderOptions = { linkId, albumId ->
|
||||
navController.navigate(
|
||||
Screen.FileOrFolderOptions(userId, linkId, optionsFilter)
|
||||
Screen.FileOrFolderOptions(userId, linkId, albumId)
|
||||
)
|
||||
},
|
||||
navigateToProtonDocsInsertImageOptions = {
|
||||
@@ -2155,7 +2168,8 @@ fun NavGraphBuilder.addCreateNewAlbum(navController: NavHostController) = compos
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Log.USER_ID) { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
) { navBackStackEntry ->
|
||||
val userId = UserId(navBackStackEntry.require(Screen.PhotosAndAlbums.USER_ID))
|
||||
CreateNewAlbumScreen(
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
@@ -2167,7 +2181,10 @@ fun NavGraphBuilder.addCreateNewAlbum(navController: NavHostController) = compos
|
||||
navController.navigate(Screen.Album(albumId)) {
|
||||
popUpTo(route = Screen.PhotosAndAlbums.CreateNewAlbum.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
navigateToPicker = {
|
||||
navController.navigate(Screen.Picker.PhotosAndAlbums(userId = userId))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2189,6 +2206,27 @@ fun NavGraphBuilder.addAlbum(navController: NavHostController) = composable(
|
||||
navigateToAlbumOptions = { albumId ->
|
||||
navController.navigate(Screen.AlbumOptions(userId, albumId))
|
||||
},
|
||||
navigateToPhotosOptions = { linkId, albumId ->
|
||||
navController.navigate(
|
||||
Screen.FileOrFolderOptions(userId, linkId, albumId)
|
||||
)
|
||||
},
|
||||
navigateToMultiplePhotosOptions = { selectionId, albumId ->
|
||||
navController.navigate(
|
||||
Screen.MultipleFileOrFolderOptions(userId, selectionId, albumId)
|
||||
)
|
||||
},
|
||||
navigateToPreview = { fileId, albumId ->
|
||||
navController.navigate(Screen.PagerPreview(
|
||||
pagerType = PagerType.ALBUM,
|
||||
userId = userId,
|
||||
fileId = fileId,
|
||||
albumId = albumId,
|
||||
))
|
||||
},
|
||||
navigateToPicker = { albumId ->
|
||||
navController.navigate(Screen.Picker.PhotosAndAlbums(destinationAlbumId = albumId))
|
||||
},
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Album.route,
|
||||
@@ -2260,6 +2298,82 @@ fun NavGraphBuilder.addConfirmDeleteAlbumDialog(navController: NavHostController
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
fun NavGraphBuilder.addPickerPhotosAndAlbums(navController: NavHostController) = composable(
|
||||
route = Screen.Picker.PhotosAndAlbums.route,
|
||||
enterTransition = defaultEnterSlideTransition { true },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { EnterTransition.None },
|
||||
popExitTransition = defaultPopExitSlideTransition { true },
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Picker.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.Picker.IN_PICKER_MODE) { type = NavType.BoolType },
|
||||
navArgument(Screen.Picker.SHARE_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
navArgument(Screen.Picker.ALBUM_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
),
|
||||
) {
|
||||
PickerPhotosAndAlbumsScreen(
|
||||
navigateToAlbum = { albumId, destinationAlbumId ->
|
||||
val route = if (destinationAlbumId != null) {
|
||||
Screen.Picker.Album(albumId, destinationAlbumId)
|
||||
} else {
|
||||
Screen.Picker.Album(albumId)
|
||||
}
|
||||
navController.navigate(route)
|
||||
},
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Picker.PhotosAndAlbums.route,
|
||||
inclusive = true,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
fun NavGraphBuilder.addPickerAlbum(navController: NavHostController) = composable(
|
||||
route = Screen.Picker.Album.route,
|
||||
enterTransition = defaultEnterSlideTransition { true },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { EnterTransition.None },
|
||||
popExitTransition = defaultPopExitSlideTransition { true },
|
||||
arguments = listOf(
|
||||
navArgument(Screen.Picker.USER_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.Picker.IN_PICKER_MODE) { type = NavType.BoolType },
|
||||
navArgument(Screen.Picker.SHARE_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.Picker.ALBUM_ID) { type = NavType.StringType },
|
||||
navArgument(Screen.Picker.DESTINATION_SHARE_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
navArgument(Screen.Picker.DESTINATION_ALBUM_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
},
|
||||
),
|
||||
) {
|
||||
PickerAlbumScreen(
|
||||
navigateBack = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Picker.Album.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
onAddToAlbumDone = {
|
||||
navController.popBackStack(
|
||||
route = Screen.Picker.PhotosAndAlbums.route,
|
||||
inclusive = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun CoroutineScope.announceScreen(
|
||||
announceEvent: AnnounceEvent,
|
||||
primaryAccount: Flow<Account?>,
|
||||
|
||||
@@ -35,7 +35,6 @@ import me.proton.android.drive.ui.navigation.animation.defaultExitSlideTransitio
|
||||
import me.proton.android.drive.ui.navigation.animation.defaultPopEnterSlideTransition
|
||||
import me.proton.android.drive.ui.navigation.animation.defaultPopExitSlideTransition
|
||||
import me.proton.android.drive.ui.navigation.internal.DriveNavHost
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.screen.ComputersScreen
|
||||
import me.proton.android.drive.ui.screen.FilesScreen
|
||||
import me.proton.android.drive.ui.screen.PhotosAndAlbumsScreen
|
||||
@@ -62,10 +61,10 @@ fun HomeNavGraph(
|
||||
arguments: Bundle,
|
||||
startDestination: String,
|
||||
homeScaffoldState: HomeScaffoldState,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
|
||||
navigateToSorting: (sorting: Sorting) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
|
||||
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
|
||||
navigateToPhotosPermissionRationale: () -> Unit,
|
||||
navigateToSubscription: () -> Unit,
|
||||
@@ -86,10 +85,10 @@ fun HomeNavGraph(
|
||||
deepLinkBaseUrl,
|
||||
arguments,
|
||||
homeScaffoldState,
|
||||
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER, OptionsFilter.FILES) },
|
||||
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
|
||||
navigateToSorting,
|
||||
{ linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
|
||||
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES) },
|
||||
{ linkId -> navigateToFileOrFolderOptions(linkId) },
|
||||
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) },
|
||||
navigateToParentFolderOptions,
|
||||
)
|
||||
addPhotos(
|
||||
@@ -98,10 +97,10 @@ fun HomeNavGraph(
|
||||
arguments,
|
||||
homeScaffoldState,
|
||||
navigateToPhotosPermissionRationale,
|
||||
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO, OptionsFilter.PHOTOS) },
|
||||
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId, OptionsFilter.PHOTOS) },
|
||||
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO) },
|
||||
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId) },
|
||||
navigateToMultiplePhotosOptions = { selectionId ->
|
||||
navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.PHOTOS)
|
||||
navigateToMultipleFileOrFolderOptions(selectionId)
|
||||
},
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
navigateToPhotosIssues = navigateToPhotosIssues,
|
||||
@@ -115,10 +114,10 @@ fun HomeNavGraph(
|
||||
arguments,
|
||||
homeScaffoldState,
|
||||
navigateToPhotosPermissionRationale,
|
||||
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO, OptionsFilter.PHOTOS) },
|
||||
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId, OptionsFilter.PHOTOS) },
|
||||
navigateToPhotosPreview = { fileId -> navigateToPreview(fileId, PagerType.PHOTO) },
|
||||
navigateToPhotosOptions = { fileId -> navigateToFileOrFolderOptions(fileId) },
|
||||
navigateToMultiplePhotosOptions = { selectionId ->
|
||||
navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.PHOTOS)
|
||||
navigateToMultipleFileOrFolderOptions(selectionId)
|
||||
},
|
||||
navigateToSubscription = navigateToSubscription,
|
||||
navigateToPhotosIssues = navigateToPhotosIssues,
|
||||
@@ -133,10 +132,10 @@ fun HomeNavGraph(
|
||||
deepLinkBaseUrl,
|
||||
arguments,
|
||||
homeScaffoldState,
|
||||
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER, OptionsFilter.FILES) },
|
||||
{ fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
|
||||
navigateToSorting,
|
||||
{ linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
|
||||
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES) },
|
||||
{ linkId -> navigateToFileOrFolderOptions(linkId) },
|
||||
{ selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) },
|
||||
navigateToParentFolderOptions,
|
||||
navigateToComputerOptions,
|
||||
)
|
||||
@@ -145,12 +144,12 @@ fun HomeNavGraph(
|
||||
deepLinkBaseUrl = deepLinkBaseUrl,
|
||||
arguments = arguments,
|
||||
homeScaffoldState = homeScaffoldState,
|
||||
navigateToFolderPreview = { fileId -> navigateToPreview(fileId, PagerType.FOLDER, OptionsFilter.FILES) },
|
||||
navigateToSinglePreview = { fileId -> navigateToPreview(fileId, PagerType.SINGLE, OptionsFilter.FILES) },
|
||||
navigateToFolderPreview = { fileId -> navigateToPreview(fileId, PagerType.FOLDER) },
|
||||
navigateToSinglePreview = { fileId -> navigateToPreview(fileId, PagerType.SINGLE) },
|
||||
navigateToSorting = navigateToSorting,
|
||||
navigateToFileOrFolderOptions = { linkId -> navigateToFileOrFolderOptions(linkId, OptionsFilter.FILES) },
|
||||
navigateToFileOrFolderOptions = { linkId -> navigateToFileOrFolderOptions(linkId) },
|
||||
navigateToMultipleFileOrFolderOptions = { selectionId ->
|
||||
navigateToMultipleFileOrFolderOptions(selectionId, OptionsFilter.FILES)
|
||||
navigateToMultipleFileOrFolderOptions(selectionId)
|
||||
},
|
||||
navigateToParentFolderOptions = navigateToParentFolderOptions,
|
||||
navigateToUserInvitation = navigateToUserInvitation,
|
||||
|
||||
@@ -26,7 +26,6 @@ import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.viewmodel.AlbumOptionsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.AlbumViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.ComputerOptionsViewModel
|
||||
@@ -36,6 +35,8 @@ import me.proton.android.drive.ui.viewmodel.FileOrFolderOptionsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.MoveToFolderViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.MultipleFileOrFolderOptionsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.ParentFolderOptionsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.PhotosPickerAndSelectionViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.PickerPhotosAndAlbumsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.ShareInvitationOptionsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.ShareMemberOptionsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.UploadToViewModel
|
||||
@@ -113,30 +114,42 @@ sealed class Screen(val route: String) {
|
||||
}
|
||||
|
||||
data object FileOrFolderOptions : Screen(
|
||||
"options/link/{userId}/shares/{shareId}/linkId={linkId}?optionsFilter={optionsFilter}"
|
||||
"options/link/{userId}/shares/{shareId}/linkId={linkId}?albumShareId={albumShareId}&albumId={albumId}"
|
||||
) {
|
||||
operator fun invoke(
|
||||
userId: UserId,
|
||||
linkId: LinkId,
|
||||
optionsFilter: OptionsFilter = OptionsFilter.FILES,
|
||||
) = "options/link/${userId.id}/shares/${linkId.shareId.id}/linkId=${linkId.id}?optionsFilter=${optionsFilter.type}"
|
||||
albumId: AlbumId? = null
|
||||
) = buildString {
|
||||
append("options/link/${userId.id}/shares/${linkId.shareId.id}/linkId=${linkId.id}")
|
||||
if (albumId != null) {
|
||||
append("?albumShareId=${albumId.shareId.id}&albumId=${albumId.id}")
|
||||
}
|
||||
}
|
||||
|
||||
const val SHARE_ID = FileOrFolderOptionsViewModel.KEY_SHARE_ID
|
||||
const val LINK_ID = FileOrFolderOptionsViewModel.KEY_LINK_ID
|
||||
const val OPTIONS_FILTER = FileOrFolderOptionsViewModel.OPTIONS_FILTER
|
||||
const val ALBUM_ID = FileOrFolderOptionsViewModel.KEY_ALBUM_ID
|
||||
const val KEY_ALBUM_SHARE_ID = FileOrFolderOptionsViewModel.KEY_ALBUM_SHARE_ID
|
||||
}
|
||||
|
||||
data object MultipleFileOrFolderOptions : Screen(
|
||||
"options/multiple/{userId}/selectionId={selectionId}?optionsFilter={optionsFilter}"
|
||||
"options/multiple/{userId}/selectionId={selectionId}?albumShareId={albumShareId}&albumId={albumId}"
|
||||
) {
|
||||
operator fun invoke(
|
||||
userId: UserId,
|
||||
selectionId: SelectionId,
|
||||
optionsFilter: OptionsFilter = OptionsFilter.FILES,
|
||||
) = "options/multiple/${userId.id}/selectionId=${selectionId.id}?optionsFilter=${optionsFilter.type}"
|
||||
albumId: AlbumId? = null,
|
||||
) = buildString {
|
||||
append("options/multiple/${userId.id}/selectionId=${selectionId.id}")
|
||||
if (albumId != null) {
|
||||
append("?albumShareId=${albumId.shareId.id}&albumId=${albumId.id}")
|
||||
}
|
||||
}
|
||||
|
||||
const val SELECTION_ID = MultipleFileOrFolderOptionsViewModel.KEY_SELECTION_ID
|
||||
const val OPTIONS_FILTER = MultipleFileOrFolderOptionsViewModel.OPTIONS_FILTER
|
||||
const val ALBUM_SHARE_ID = MultipleFileOrFolderOptionsViewModel.KEY_ALBUM_SHARE_ID
|
||||
const val ALBUM_ID = MultipleFileOrFolderOptionsViewModel.KEY_ALBUM_ID
|
||||
}
|
||||
|
||||
data object ParentFolderOptions : Screen(
|
||||
@@ -211,8 +224,11 @@ sealed class Screen(val route: String) {
|
||||
const val SHARE_ID = "shareId"
|
||||
}
|
||||
|
||||
data object ConfirmEmptyTrash : Screen("delete/{userId}/trash") {
|
||||
operator fun invoke(userId: UserId) = "delete/${userId.id}/trash"
|
||||
data object ConfirmEmptyTrash : Screen("delete/{userId}/trash?volumeId={volumeId}") {
|
||||
operator fun invoke(
|
||||
userId: UserId,
|
||||
volumeId: VolumeId,
|
||||
) = "delete/${userId.id}/trash?volumeId=${volumeId.id}"
|
||||
}
|
||||
|
||||
data object Rename : Screen("rename/{userId}/shares/{shareId}/files?fileId={fileId}&folderId={folderId}&albumId={albumId}") {
|
||||
@@ -279,6 +295,7 @@ sealed class Screen(val route: String) {
|
||||
|
||||
const val USER_ID = Screen.USER_ID
|
||||
const val FOLDER_ID = "folderId"
|
||||
const val VOLUME_ID = "volumeId"
|
||||
const val SHARE_ID = "shareId"
|
||||
const val FOLDER_NAME = "folderName"
|
||||
}
|
||||
@@ -367,12 +384,35 @@ sealed class Screen(val route: String) {
|
||||
const val ALBUM_ID = AlbumViewModel.ALBUM_ID
|
||||
}
|
||||
|
||||
object Picker {
|
||||
data object PhotosAndAlbums : Screen("picker/{userId}/photos/destination?inPickerMode={inPickerMode}&destinationShareId={destinationShareId}&destinationAlbumId={destinationAlbumId}") {
|
||||
operator fun invoke(userId: UserId) = "picker/${userId.id}/photos/destination?inPickerMode=true"
|
||||
|
||||
operator fun invoke(destinationAlbumId: AlbumId) =
|
||||
"picker/${destinationAlbumId.userId.id}/photos/destination?inPickerMode=true&destinationShareId=${destinationAlbumId.shareId.id}&destinationAlbumId=${destinationAlbumId.id}"
|
||||
}
|
||||
|
||||
data object Album : Screen("picker/{userId}/shares/{shareId}/albums/{albumId}/destination?inPickerMode={inPickerMode}&destinationShareId={destinationShareId}&destinationAlbumId={destinationAlbumId}") {
|
||||
operator fun invoke(albumId: AlbumId) = "picker/${albumId.userId.id}/shares/${albumId.shareId.id}/albums/${albumId.id}/destination?inPickerMode=true"
|
||||
|
||||
operator fun invoke(albumId: AlbumId, destinationAlbumId: AlbumId) =
|
||||
"picker/${albumId.userId.id}/shares/${albumId.shareId.id}/albums/${albumId.id}/destination?inPickerMode=true&destinationShareId=${destinationAlbumId.shareId.id}&destinationAlbumId=${destinationAlbumId.id}"
|
||||
}
|
||||
|
||||
const val USER_ID = Screen.USER_ID
|
||||
const val IN_PICKER_MODE = PhotosPickerAndSelectionViewModel.IN_PICKER_MODE
|
||||
const val SHARE_ID = AlbumViewModel.SHARE_ID
|
||||
const val ALBUM_ID = AlbumViewModel.ALBUM_ID
|
||||
const val DESTINATION_SHARE_ID = PickerPhotosAndAlbumsViewModel.DESTINATION_SHARE_ID
|
||||
const val DESTINATION_ALBUM_ID = PickerPhotosAndAlbumsViewModel.DESTINATION_ALBUM_ID
|
||||
}
|
||||
|
||||
data object BackupIssues : Screen("backup/issues/{userId}/shares/{shareId}/folder/{folderId}") {
|
||||
|
||||
fun invoke(folderId: FolderId) = "backup/issues/${folderId.shareId.userId.id}/shares/${folderId.shareId.id}/folder/${folderId.id}"
|
||||
|
||||
object Dialogs {
|
||||
object ConfirmSkipIssues : Screen("backup/issues/{userId}/shares/{shareId}/folder/{folderId}/confirm_skip?confirmPopUpRoute={confirmPopUpRoute}&confirmPopUpRouteInclusive={confirmPopUpRouteInclusive}"){
|
||||
data object ConfirmSkipIssues : Screen("backup/issues/{userId}/shares/{shareId}/folder/{folderId}/confirm_skip?confirmPopUpRoute={confirmPopUpRoute}&confirmPopUpRouteInclusive={confirmPopUpRouteInclusive}"){
|
||||
operator fun invoke(
|
||||
folderId: FolderId,
|
||||
confirmPopUpRoute: String,
|
||||
@@ -422,20 +462,25 @@ sealed class Screen(val route: String) {
|
||||
filesBrowsableBuildRoute("offline", userId, folderId, folderName)
|
||||
}
|
||||
|
||||
data object PagerPreview : Screen("pager/{pagerType}/preview/{userId}/shares/{shareId}/files/{fileId}?optionsFilter={optionsFilter}") {
|
||||
data object PagerPreview : Screen("pager/{pagerType}/preview/{userId}/shares/{shareId}/files/{fileId}?albumShareId={albumShareId}&albumId={albumId}") {
|
||||
operator fun invoke(
|
||||
pagerType: PagerType,
|
||||
userId: UserId,
|
||||
fileId: FileId,
|
||||
optionsFilter: OptionsFilter = OptionsFilter.FILES
|
||||
) =
|
||||
"pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}?optionsFilter=${optionsFilter.type}"
|
||||
) = "pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}"
|
||||
operator fun invoke(
|
||||
pagerType: PagerType,
|
||||
userId: UserId,
|
||||
fileId: FileId,
|
||||
albumId: AlbumId,
|
||||
) = "pager/${pagerType.type}/preview/${userId.id}/shares/${fileId.shareId.id}/files/${fileId.id}?albumShareId=${albumId.shareId.id}&albumId=${albumId.id}"
|
||||
|
||||
const val USER_ID = Screen.USER_ID
|
||||
const val SHARE_ID = "shareId"
|
||||
const val FILE_ID = "fileId"
|
||||
const val ALBUM_SHARE_ID = "albumShareId"
|
||||
const val ALBUM_ID = "albumId"
|
||||
const val PAGER_TYPE = "pagerType"
|
||||
const val OPTIONS_FILTER = FileOrFolderOptions.OPTIONS_FILTER
|
||||
}
|
||||
|
||||
data object Settings : Screen("settings/{userId}") {
|
||||
@@ -752,7 +797,11 @@ fun NavHostController.navigate(screen: Screen, builder: NavOptionsBuilder.() ->
|
||||
}
|
||||
|
||||
enum class PagerType(val type: String) {
|
||||
FOLDER("folder"), OFFLINE("offline"), SINGLE("single"), PHOTO("photo")
|
||||
FOLDER("folder"),
|
||||
OFFLINE("offline"),
|
||||
SINGLE("single"),
|
||||
PHOTO("photo"),
|
||||
ALBUM("album"),
|
||||
}
|
||||
|
||||
interface HomeTab {
|
||||
|
||||
@@ -38,9 +38,12 @@ import me.proton.core.drive.files.presentation.entry.ManageAccessEntry
|
||||
import me.proton.core.drive.files.presentation.entry.MoveEntry
|
||||
import me.proton.core.drive.files.presentation.entry.MoveFileEntry
|
||||
import me.proton.core.drive.files.presentation.entry.OpenInBrowserProtonDocsEntry
|
||||
import me.proton.core.drive.files.presentation.entry.RemoveFromAlbumEntry
|
||||
import me.proton.core.drive.files.presentation.entry.RemoveFromAlbumFileEntry
|
||||
import me.proton.core.drive.files.presentation.entry.RemoveMeEntry
|
||||
import me.proton.core.drive.files.presentation.entry.RenameFileEntry
|
||||
import me.proton.core.drive.files.presentation.entry.SendFileEntry
|
||||
import me.proton.core.drive.files.presentation.entry.SetAsAlbumCoverEntry
|
||||
import me.proton.core.drive.files.presentation.entry.ShareViaInvitationsEntry
|
||||
import me.proton.core.drive.files.presentation.entry.ToggleOfflineEntry
|
||||
import me.proton.core.drive.files.presentation.entry.ToggleTrashEntry
|
||||
@@ -162,7 +165,7 @@ sealed class Option(
|
||||
runAction: RunAction,
|
||||
navigateToMove: (linkId: LinkId, parentId: FolderId?) -> Unit,
|
||||
) = MoveFileEntry { driveLink ->
|
||||
runAction { navigateToMove(driveLink.id, driveLink.parentId) }
|
||||
runAction { navigateToMove(driveLink.id, driveLink.parentId as? FolderId) }
|
||||
}
|
||||
|
||||
fun build(
|
||||
@@ -345,6 +348,43 @@ sealed class Option(
|
||||
runAction { createAlbum() }
|
||||
}
|
||||
}
|
||||
|
||||
data object SetAsAlbumCover : Option(
|
||||
ApplicableQuantity.Single,
|
||||
setOf(ApplicableTo.FILE_PHOTO),
|
||||
setOf(State.NOT_TRASHED) + State.ANY_SHARED
|
||||
) {
|
||||
fun build(
|
||||
runAction: RunAction,
|
||||
setAsAlbumCover: (DriveLink.File) -> Unit,
|
||||
) = SetAsAlbumCoverEntry { driveLink ->
|
||||
runAction { setAsAlbumCover(driveLink) }
|
||||
} as FileOptionEntry<DriveLink>
|
||||
}
|
||||
|
||||
data object RemoveFromAlbum : Option(
|
||||
ApplicableQuantity.All,
|
||||
setOf(ApplicableTo.FILE_PHOTO),
|
||||
setOf(State.NOT_TRASHED) + State.ANY_SHARED
|
||||
) {
|
||||
fun build(
|
||||
runAction: RunAction,
|
||||
removeFromAlbum: (DriveLink.File) -> Unit,
|
||||
) = RemoveFromAlbumFileEntry { driveLink ->
|
||||
runAction {
|
||||
removeFromAlbum(driveLink)
|
||||
}
|
||||
} as FileOptionEntry<DriveLink>
|
||||
|
||||
fun build(
|
||||
runAction: RunAction,
|
||||
removeSelectedFromAlbum: () -> Unit,
|
||||
) = RemoveFromAlbumEntry {
|
||||
runAction {
|
||||
removeSelectedFromAlbum()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ApplicableQuantity(open val quantity: Long) {
|
||||
@@ -403,23 +443,6 @@ fun Set<Option>.filter(driveLink: DriveLink) =
|
||||
option.applicableStates.containsAll(driveLink.toOptionState()) && driveLink.isApplicableTo(option.applicableTo)
|
||||
}
|
||||
|
||||
private val photosOptions = listOf(
|
||||
Option.OfflineToggle,
|
||||
Option.ShareViaInvitations,
|
||||
Option.ManageAccess,
|
||||
Option.SendFile,
|
||||
Option.Download,
|
||||
Option.Info,
|
||||
Option.Trash,
|
||||
Option.CreateAlbum,
|
||||
)
|
||||
|
||||
fun Iterable<Option>.filter(optionsFilter: OptionsFilter) =
|
||||
when (optionsFilter) {
|
||||
OptionsFilter.FILES -> this
|
||||
OptionsFilter.PHOTOS -> filter { option -> option in photosOptions }
|
||||
}
|
||||
|
||||
fun Iterable<Option>.filterRoot(driveLink: DriveLink, featureFlag: FeatureFlag) = filter { option ->
|
||||
if (driveLink.parentId == null) {
|
||||
when (option) {
|
||||
@@ -452,6 +475,7 @@ fun Iterable<Option>.filterPermissions(
|
||||
Option.CreateDocument -> permissions.canWrite
|
||||
Option.CreateAlbum -> permissions.canWrite
|
||||
Option.CreateFolder -> permissions.canWrite
|
||||
Option.DeleteAlbum -> permissions.isAdmin
|
||||
Option.DeletePermanently -> permissions.canWrite
|
||||
Option.Download -> permissions.canRead
|
||||
Option.Info -> permissions.canRead
|
||||
@@ -460,13 +484,14 @@ fun Iterable<Option>.filterPermissions(
|
||||
Option.OfflineToggle -> permissions.canRead
|
||||
Option.OpenInBrowser -> permissions.canRead
|
||||
Option.Rename -> permissions.canWrite
|
||||
Option.RemoveFromAlbum -> permissions.isAdmin
|
||||
Option.RemoveMe -> permissions.canRead
|
||||
Option.SendFile -> permissions.canRead
|
||||
Option.SetAsAlbumCover -> permissions.isAdmin
|
||||
Option.ShareViaInvitations -> permissions.isAdmin
|
||||
Option.TakeAPhoto -> permissions.canWrite
|
||||
Option.Trash -> permissions.isAdmin
|
||||
Option.UploadFile -> permissions.canWrite
|
||||
Option.RemoveMe -> permissions.canRead
|
||||
Option.DeleteAlbum -> permissions.isAdmin
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,9 +506,13 @@ fun Iterable<Option>.filterProtonDocs(killSwitch: FeatureFlag) = filter { option
|
||||
fun Iterable<Option>.filterAlbums(
|
||||
featureFlagOn: Boolean,
|
||||
killSwitch: FeatureFlag,
|
||||
albumId: AlbumId? = null,
|
||||
) = filter { option ->
|
||||
val featureEnabled = featureFlagOn && killSwitch.off
|
||||
when (option) {
|
||||
Option.CreateAlbum -> featureFlagOn && killSwitch.off
|
||||
Option.CreateAlbum -> featureEnabled
|
||||
Option.RemoveFromAlbum -> featureEnabled && albumId != null
|
||||
Option.SetAsAlbumCover -> featureEnabled && albumId != null
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
|
||||
package me.proton.android.drive.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -30,12 +32,19 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import me.proton.android.drive.photos.presentation.component.Album
|
||||
import me.proton.android.drive.ui.viewmodel.AlbumViewModel
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
import me.proton.core.drive.base.presentation.component.RepeatOnLifecycleLaunchedEffect
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
navigateToAlbumOptions: (AlbumId) -> Unit,
|
||||
navigateToPhotosOptions: (FileId, AlbumId?) -> Unit,
|
||||
navigateToMultiplePhotosOptions: (selectionId: SelectionId, AlbumId?) -> Unit,
|
||||
navigateToPreview: (FileId, AlbumId) -> Unit,
|
||||
navigateToPicker: (AlbumId) -> Unit,
|
||||
navigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -45,19 +54,32 @@ fun AlbumScreen(
|
||||
val viewEvent = remember(lifecycle) {
|
||||
viewModel.viewEvent(
|
||||
navigateToAlbumOptions = navigateToAlbumOptions,
|
||||
navigateToPhotosOptions = navigateToPhotosOptions,
|
||||
navigateToMultiplePhotosOptions = navigateToMultiplePhotosOptions,
|
||||
navigateToPreview = navigateToPreview,
|
||||
navigateToPicker = navigateToPicker,
|
||||
navigateBack = navigateBack,
|
||||
lifecycle = lifecycle,
|
||||
)
|
||||
}
|
||||
val photoItems = viewModel.driveLinks.collectAsLazyPagingItems()
|
||||
val listEffect = rememberFlowWithLifecycle(flow = viewModel.listEffect)
|
||||
|
||||
viewState.value?.let { viewState ->
|
||||
val selectedPhotos by viewState.selected.collectAsStateWithLifecycle(initialValue = emptySet())
|
||||
val inMultiselect = remember(selectedPhotos) { selectedPhotos.isNotEmpty() }
|
||||
|
||||
BackHandler(enabled = inMultiselect) { viewEvent.onBack() }
|
||||
RepeatOnLifecycleLaunchedEffect {
|
||||
viewModel.initializeSelectionInPickerMode()
|
||||
}
|
||||
Album(
|
||||
viewState = viewState,
|
||||
viewEvent = viewEvent,
|
||||
items = photoItems,
|
||||
listEffect = listEffect,
|
||||
driveLinksFlow = viewModel.driveLinksMap,
|
||||
selectedPhotos = selectedPhotos,
|
||||
isRefreshEnabled = viewState.isRefreshEnabled,
|
||||
isRefreshing = viewState.listContentState.isRefreshing,
|
||||
onRefresh = viewEvent.onRefresh,
|
||||
|
||||
@@ -62,6 +62,7 @@ import me.proton.core.presentation.R as CorePresentation
|
||||
fun CreateNewAlbumScreen(
|
||||
navigateBack: () -> Unit,
|
||||
navigateToAlbum: (AlbumId) -> Unit,
|
||||
navigateToPicker: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = hiltViewModel<CreateNewAlbumViewModel>()
|
||||
@@ -73,6 +74,7 @@ fun CreateNewAlbumScreen(
|
||||
viewModel.viewEvent(
|
||||
navigateBack = navigateBack,
|
||||
navigateToAlbum = navigateToAlbum,
|
||||
navigateToPicker = navigateToPicker,
|
||||
)
|
||||
}
|
||||
val items = viewModel.photos.collectAsLazyPagingItems()
|
||||
@@ -153,6 +155,7 @@ private fun CreateNewAlbumScreenPreview() {
|
||||
isCreationInProgress = true,
|
||||
isAlbumNameEnabled = true,
|
||||
isAddEnabled = true,
|
||||
isRemoveEnabled = true,
|
||||
name = emptyFlow(),
|
||||
hint = stringResource(I18N.string.albums_new_album_name_hint),
|
||||
),
|
||||
|
||||
@@ -48,7 +48,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import me.proton.android.drive.ui.navigation.HomeNavGraph
|
||||
import me.proton.android.drive.ui.navigation.PagerType
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.provider.setLocalSnackbarPadding
|
||||
import me.proton.android.drive.ui.viewevent.HomeViewEvent
|
||||
import me.proton.android.drive.ui.viewmodel.HomeViewModel
|
||||
@@ -71,7 +70,7 @@ import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawer
|
||||
import me.proton.core.drive.sorting.domain.entity.Sorting
|
||||
import me.proton.drive.android.settings.domain.entity.UserOverlay
|
||||
import me.proton.core.drive.volume.domain.entity.VolumeId
|
||||
import me.proton.drive.android.settings.domain.entity.WhatsNewKey
|
||||
|
||||
@Composable
|
||||
@@ -88,11 +87,11 @@ fun HomeScreen(
|
||||
navigateToSigningOut: () -> Unit,
|
||||
navigateToTrash: () -> Unit,
|
||||
navigateToOffline: () -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
|
||||
navigateToSorting: (sorting: Sorting) -> Unit,
|
||||
navigateToSettings: () -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
|
||||
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
|
||||
navigateToSubscription: () -> Unit,
|
||||
navigateToPhotosIssues: (FolderId) -> Unit,
|
||||
@@ -186,10 +185,10 @@ internal fun Home(
|
||||
deepLinkBaseUrl: String,
|
||||
startDestination: String,
|
||||
onDrawerStateChanged: (Boolean) -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToPreview: (fileId: FileId, pagerType: PagerType) -> Unit,
|
||||
navigateToSorting: (sorting: Sorting) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, optionsFilter: OptionsFilter) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
|
||||
navigateToParentFolderOptions: (folderId: FolderId) -> Unit,
|
||||
arguments: Bundle,
|
||||
viewState: HomeViewState,
|
||||
|
||||
@@ -77,6 +77,7 @@ import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.headlineSmallNorm
|
||||
import me.proton.core.compose.theme.headlineSmallUnspecified
|
||||
import me.proton.core.drive.base.presentation.component.RepeatOnLifecycleLaunchedEffect
|
||||
import me.proton.core.drive.base.presentation.component.TopAppBar
|
||||
import me.proton.core.drive.base.presentation.component.TopBarActions
|
||||
import me.proton.core.drive.base.presentation.effect.ListEffect
|
||||
@@ -137,7 +138,7 @@ fun PhotosAndAlbumsScreen(
|
||||
defaultTitle = defaultTitle,
|
||||
)
|
||||
|
||||
Tab.ALBUMS -> AlbumTab(
|
||||
Tab.ALBUMS -> AlbumsTab(
|
||||
homeScaffoldState = homeScaffoldState,
|
||||
navigateToCreateNewAlbum = navigateToCreateNewAlbum,
|
||||
navigateToAlbum = navigateToAlbum,
|
||||
@@ -148,7 +149,7 @@ fun PhotosAndAlbumsScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumTab(
|
||||
fun AlbumsTab(
|
||||
homeScaffoldState: HomeScaffoldState,
|
||||
navigateToCreateNewAlbum: () -> Unit,
|
||||
navigateToAlbum: (AlbumId) -> Unit,
|
||||
@@ -193,7 +194,7 @@ private fun AlbumTab(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhotosTab(
|
||||
fun PhotosTab(
|
||||
homeScaffoldState: HomeScaffoldState,
|
||||
modifier: Modifier = Modifier,
|
||||
navigateToPhotosPermissionRationale: () -> Unit,
|
||||
@@ -239,6 +240,9 @@ private fun PhotosTab(
|
||||
}.launchIn(this)
|
||||
}
|
||||
|
||||
RepeatOnLifecycleLaunchedEffect {
|
||||
viewModel.initializeSelectionInPickerMode()
|
||||
}
|
||||
PhotosTab(
|
||||
homeScaffoldState = homeScaffoldState,
|
||||
viewState = viewState,
|
||||
@@ -320,7 +324,7 @@ private fun PhotosTab(
|
||||
|
||||
|
||||
@Composable
|
||||
private fun Tabs(
|
||||
fun Tabs(
|
||||
viewState: PhotosAndAlbumsViewState,
|
||||
viewEvent: PhotosAndAlbumsViewEvent,
|
||||
modifier: Modifier = Modifier,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import me.proton.android.drive.ui.viewmodel.PickerPhotosAndAlbumsViewModel
|
||||
import me.proton.core.drive.base.presentation.extension.shadow
|
||||
|
||||
@Composable
|
||||
fun PickerAlbumScreen(
|
||||
navigateBack: () -> Unit,
|
||||
onAddToAlbumDone: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = hiltViewModel<PickerPhotosAndAlbumsViewModel>()
|
||||
val viewState by viewModel.viewState.collectAsStateWithLifecycle(initialValue = null)
|
||||
val lifecycle = LocalLifecycleOwner.current
|
||||
val viewEvent = remember(lifecycle) {
|
||||
viewModel.viewEvent(
|
||||
navigateBack = navigateBack,
|
||||
onAddToAlbumDone = onAddToAlbumDone,
|
||||
)
|
||||
}
|
||||
viewState?.let { viewState ->
|
||||
PickerAlbumScreen(
|
||||
addToAlbumTitle = viewState.addToAlbumButtonTitle,
|
||||
isAddToAlbumButtonEnabled = viewState.isAddToAlbumButtonEnabled,
|
||||
isAddToAlbumInProgress = viewState.isAddingInProgress,
|
||||
isResetButtonEnabled = viewState.isResetButtonEnabled,
|
||||
onBack = viewEvent.onBackPressed,
|
||||
onReset = viewEvent.onReset,
|
||||
onAddToAlbum = viewEvent.onAddToAlbum,
|
||||
modifier = modifier.navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PickerAlbumScreen(
|
||||
addToAlbumTitle: String,
|
||||
isAddToAlbumButtonEnabled: Boolean,
|
||||
isAddToAlbumInProgress: Boolean,
|
||||
isResetButtonEnabled: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onAddToAlbum: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
AlbumScreen(
|
||||
navigateToAlbumOptions = {},
|
||||
navigateToPhotosOptions = { _, _ -> },
|
||||
navigateToMultiplePhotosOptions = { _, _ -> },
|
||||
navigateToPreview = { _, _ -> },
|
||||
navigateToPicker = { _ -> },
|
||||
navigateBack = onBack,
|
||||
)
|
||||
BottomActions(
|
||||
addToAlbumTitle = addToAlbumTitle,
|
||||
onReset = onReset,
|
||||
onAddToAlbum = onAddToAlbum,
|
||||
isAddToAlbumButtonEnabled = isAddToAlbumButtonEnabled,
|
||||
isAddToAlbumInProgress = isAddToAlbumInProgress,
|
||||
isResetButtonEnabled = isResetButtonEnabled,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.shadow(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import me.proton.android.drive.photos.presentation.component.AddToAlbumButton
|
||||
import me.proton.android.drive.ui.viewmodel.PhotosAndAlbumsViewModel
|
||||
import me.proton.android.drive.ui.viewmodel.PickerPhotosAndAlbumsViewModel
|
||||
import me.proton.android.drive.ui.viewstate.PhotosAndAlbumsViewState.Tab
|
||||
import me.proton.android.drive.ui.viewstate.rememberHomeScaffoldState
|
||||
import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.drive.base.presentation.extension.shadow
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.base.presentation.component.TopAppBar as BaseTopAppBar
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
|
||||
@Composable
|
||||
fun PickerPhotosAndAlbumsScreen(
|
||||
navigateToAlbum: (AlbumId, AlbumId?) -> Unit,
|
||||
navigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = hiltViewModel<PickerPhotosAndAlbumsViewModel>()
|
||||
val viewState by viewModel.viewState.collectAsStateWithLifecycle(initialValue = null)
|
||||
val lifecycle = LocalLifecycleOwner.current
|
||||
val viewEvent = remember(lifecycle) {
|
||||
viewModel.viewEvent(
|
||||
navigateBack = navigateBack,
|
||||
onAddToAlbumDone = navigateBack,
|
||||
)
|
||||
}
|
||||
viewState?.let { viewState ->
|
||||
PickerPhotosAndAlbumsScreen(
|
||||
addToAlbumTitle = viewState.addToAlbumButtonTitle,
|
||||
isAddToAlbumButtonEnabled = viewState.isAddToAlbumButtonEnabled,
|
||||
isAddToAlbumInProgress = viewState.isAddingInProgress,
|
||||
isResetButtonEnabled = viewState.isResetButtonEnabled,
|
||||
onTopAppBarNavigationIcon = viewEvent.onBackPressed,
|
||||
navigateToAlbum = { albumId: AlbumId ->
|
||||
navigateToAlbum(albumId, viewModel.destinationAlbumId)
|
||||
},
|
||||
onReset = viewEvent.onReset,
|
||||
onAddToAlbum = viewEvent.onAddToAlbum,
|
||||
modifier = modifier.navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PickerPhotosAndAlbumsScreen(
|
||||
addToAlbumTitle: String,
|
||||
isAddToAlbumButtonEnabled: Boolean,
|
||||
isAddToAlbumInProgress: Boolean,
|
||||
isResetButtonEnabled: Boolean,
|
||||
onTopAppBarNavigationIcon: () -> Unit,
|
||||
navigateToAlbum: (AlbumId) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onAddToAlbum: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val viewModel = hiltViewModel<PhotosAndAlbumsViewModel>()
|
||||
val viewState by viewModel.viewState.collectAsStateWithLifecycle(
|
||||
initialValue = viewModel.initialViewState
|
||||
)
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
val viewEvent = remember(lifecycle) {
|
||||
viewModel.viewEvent()
|
||||
}
|
||||
PickerPhotosAndAlbums(
|
||||
title = { titleModifier ->
|
||||
Tabs(
|
||||
viewState = viewState,
|
||||
viewEvent = viewEvent,
|
||||
modifier = titleModifier,
|
||||
)
|
||||
},
|
||||
selectedTab = viewState.selectedTab,
|
||||
onTopAppBarNavigationIcon = onTopAppBarNavigationIcon,
|
||||
navigateToAlbum = navigateToAlbum,
|
||||
onReset = onReset,
|
||||
onAddToAlbum = onAddToAlbum,
|
||||
addToAlbumTitle = addToAlbumTitle,
|
||||
isAddToAlbumButtonEnabled = isAddToAlbumButtonEnabled,
|
||||
isAddToAlbumInProgress = isAddToAlbumInProgress,
|
||||
isResetButtonEnabled = isResetButtonEnabled,
|
||||
modifier = modifier
|
||||
.testTag(PickerPhotosAndAlbumsScreenTestTag.screen),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PickerPhotosAndAlbums(
|
||||
title: @Composable (Modifier) -> Unit,
|
||||
addToAlbumTitle: String,
|
||||
isAddToAlbumButtonEnabled: Boolean,
|
||||
isAddToAlbumInProgress: Boolean,
|
||||
isResetButtonEnabled: Boolean,
|
||||
selectedTab: Tab,
|
||||
onTopAppBarNavigationIcon: () -> Unit,
|
||||
navigateToAlbum: (AlbumId) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onAddToAlbum: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
TopAppBar(
|
||||
title = title,
|
||||
onNavigationIcon = onTopAppBarNavigationIcon,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
val homeScaffoldState = rememberHomeScaffoldState().apply {
|
||||
drawerGesturesEnabled.value = false
|
||||
bottomNavigationEnabled.value = false
|
||||
}
|
||||
when (selectedTab) {
|
||||
Tab.PHOTOS -> PhotosTab(
|
||||
homeScaffoldState = homeScaffoldState,
|
||||
navigateToPhotosPermissionRationale = {},
|
||||
navigateToPhotosPreview = {},
|
||||
navigateToPhotosOptions = {},
|
||||
navigateToMultiplePhotosOptions = {},
|
||||
navigateToSubscription = {},
|
||||
navigateToPhotosIssues = {},
|
||||
navigateToPhotosUpsell = {},
|
||||
navigateToBackupSettings = {},
|
||||
navigateToNotificationPermissionRationale = {},
|
||||
defaultTitle = {},
|
||||
)
|
||||
|
||||
Tab.ALBUMS -> AlbumsTab(
|
||||
homeScaffoldState = homeScaffoldState,
|
||||
navigateToCreateNewAlbum = {},
|
||||
navigateToAlbum = navigateToAlbum,
|
||||
defaultTitle = {},
|
||||
)
|
||||
}
|
||||
BottomActions(
|
||||
addToAlbumTitle = addToAlbumTitle,
|
||||
isAddToAlbumButtonEnabled = isAddToAlbumButtonEnabled,
|
||||
isAddToAlbumInProgress = isAddToAlbumInProgress,
|
||||
isResetButtonEnabled = isResetButtonEnabled,
|
||||
onReset = onReset,
|
||||
onAddToAlbum = onAddToAlbum,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.shadow(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopAppBar(
|
||||
title: @Composable (Modifier) -> Unit,
|
||||
onNavigationIcon: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BaseTopAppBar(
|
||||
navigationIcon = painterResource(CorePresentation.drawable.ic_proton_close),
|
||||
onNavigationIcon = onNavigationIcon,
|
||||
title = title,
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomActions(
|
||||
addToAlbumTitle: String,
|
||||
isAddToAlbumButtonEnabled: Boolean,
|
||||
isAddToAlbumInProgress: Boolean,
|
||||
isResetButtonEnabled: Boolean,
|
||||
onReset: () -> Unit,
|
||||
onAddToAlbum: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(all = ProtonDimens.DefaultSpacing)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.testTag(PickerPhotosAndAlbumsScreenTestTag.resetButton)
|
||||
.border(1.dp, ProtonTheme.colors.separatorNorm, CircleShape)
|
||||
.background(ProtonTheme.colors.backgroundNorm, CircleShape),
|
||||
onClick = onReset,
|
||||
enabled = isResetButtonEnabled,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = CorePresentation.drawable.ic_proton_close),
|
||||
contentDescription = null,
|
||||
modifier = modifier.padding(4.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(32.dp))
|
||||
AddToAlbumButton(
|
||||
addToAlbumTitle = addToAlbumTitle,
|
||||
modifier = Modifier
|
||||
.testTag(PickerPhotosAndAlbumsScreenTestTag.addToAlbumButton)
|
||||
.widthIn(min = 200.dp),
|
||||
enabled = isAddToAlbumButtonEnabled,
|
||||
loading = isAddToAlbumInProgress,
|
||||
onClick = onAddToAlbum,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object PickerPhotosAndAlbumsScreenTestTag {
|
||||
const val screen = "picker photos and albums screen"
|
||||
const val resetButton = "reset button"
|
||||
const val addToAlbumButton = "add to album button"
|
||||
}
|
||||
@@ -46,13 +46,14 @@ import me.proton.core.drive.base.presentation.component.ModalBottomSheet
|
||||
import me.proton.core.drive.files.preview.presentation.component.Preview
|
||||
import me.proton.core.drive.files.preview.presentation.component.PreviewEmpty
|
||||
import me.proton.core.drive.files.preview.presentation.component.state.PreviewContentState
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun PreviewScreen(
|
||||
navigateBack: () -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToFileOrFolderOptions: (LinkId, AlbumId?) -> Unit,
|
||||
navigateToProtonDocsInsertImageOptions: () -> Unit,
|
||||
) = PreviewScreen(
|
||||
viewModel = hiltViewModel(),
|
||||
@@ -67,7 +68,7 @@ fun PreviewScreen(
|
||||
fun PreviewScreen(
|
||||
viewModel: PreviewViewModel,
|
||||
navigateBack: () -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToFileOrFolderOptions: (LinkId, AlbumId?) -> Unit,
|
||||
navigateToProtonDocsInsertImageOptions: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
@@ -23,26 +23,18 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -52,16 +44,12 @@ import me.proton.android.drive.ui.viewmodel.SharedTabsViewModel
|
||||
import me.proton.android.drive.ui.viewstate.HomeScaffoldState
|
||||
import me.proton.android.drive.ui.viewstate.SharedTab
|
||||
import me.proton.android.drive.ui.viewstate.SharedTabsViewState
|
||||
import me.proton.core.compose.component.ProtonButton
|
||||
import me.proton.core.compose.component.protonTextButtonColors
|
||||
import me.proton.core.compose.theme.ProtonDimens.DefaultButtonMinHeight
|
||||
import me.proton.core.compose.theme.ProtonDimens.SmallSpacing
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.drive.base.presentation.component.ProtonTab
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.base.presentation.component.TopAppBar as BaseTopAppBar
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
@Composable
|
||||
fun SharedTabsScreen(
|
||||
@@ -116,8 +104,8 @@ fun SharedTabs(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
viewState.tabs.forEach { sharedTab ->
|
||||
SharedTab(
|
||||
sharedTab = sharedTab,
|
||||
ProtonTab(
|
||||
titleResId = sharedTab.titleResId,
|
||||
isSelected = viewState.selectedTab == sharedTab,
|
||||
onTab = { viewEvent.onTab(sharedTab) }
|
||||
)
|
||||
@@ -163,78 +151,3 @@ private fun TopAppBar(
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedTab(
|
||||
sharedTab: SharedTab,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onTab: (SharedTab) -> Unit,
|
||||
) {
|
||||
val brandColor = ProtonTheme.colors.brandNorm
|
||||
val dividerColor = remember(isSelected) { if (isSelected) brandColor else Color.Transparent }
|
||||
ProtonButton(
|
||||
modifier = modifier,
|
||||
onClick = { onTab(sharedTab) },
|
||||
contentPadding = PaddingValues(horizontal = SmallSpacing),
|
||||
colors = ButtonDefaults.protonTextButtonColors(false),
|
||||
shape = RoundedCornerShape(0.dp),
|
||||
border = null,
|
||||
elevation = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(DefaultButtonMinHeight),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = sharedTab.titleResId),
|
||||
style = ProtonTheme.typography.body2Regular.copy(
|
||||
color = if (isSelected) brandColor else ProtonTheme.colors.textWeak
|
||||
),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Divider(
|
||||
color = dividerColor,
|
||||
thickness = 4.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(100.dp, 100.dp, 0.dp, 0.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewSharedTab() {
|
||||
ProtonTheme {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
SharedTab(
|
||||
sharedTab = SharedTab(
|
||||
SharedTab.Type.SHARED_WITH_ME,
|
||||
I18N.string.shared_with_me_title,
|
||||
),
|
||||
isSelected = true,
|
||||
onTab = { _ -> }
|
||||
)
|
||||
SharedTab(
|
||||
sharedTab = SharedTab(
|
||||
SharedTab.Type.SHARED_BY_ME,
|
||||
I18N.string.shared_by_me_title,
|
||||
),
|
||||
isSelected = false,
|
||||
onTab = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,15 @@
|
||||
package me.proton.android.drive.ui.screen
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -34,6 +38,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -54,22 +59,23 @@ import me.proton.core.compose.theme.defaultSmallStrong
|
||||
import me.proton.core.drive.base.presentation.component.ActionButton
|
||||
import me.proton.core.drive.base.presentation.component.ModalBottomSheet
|
||||
import me.proton.core.drive.base.presentation.component.ProtonPullToRefresh
|
||||
import me.proton.core.drive.base.presentation.component.ProtonTab
|
||||
import me.proton.core.drive.base.presentation.component.TopAppBarHeight
|
||||
import me.proton.core.drive.files.presentation.component.DriveLinksFlow
|
||||
import me.proton.core.drive.files.presentation.component.Files
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.sorting.domain.entity.Sorting
|
||||
import me.proton.core.drive.volume.domain.entity.VolumeId
|
||||
import me.proton.core.util.kotlin.exhaustive
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
|
||||
@Composable
|
||||
@ExperimentalCoroutinesApi
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
fun TrashScreen(
|
||||
navigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
navigateToEmptyTrash: () -> Unit,
|
||||
navigateToEmptyTrash: (VolumeId) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToSortingDialog: (Sorting) -> Unit,
|
||||
) {
|
||||
@@ -95,10 +101,10 @@ fun TrashScreen(
|
||||
type = ProtonSnackbarType.ERROR,
|
||||
message = effect.message
|
||||
)
|
||||
TrashEffect.MoreOptions -> {
|
||||
is TrashEffect.MoreOptions -> {
|
||||
modalBottomSheetContentState.sheetContent.value = { runAction ->
|
||||
TrashMoreOptions {
|
||||
runAction { navigateToEmptyTrash() }
|
||||
runAction { navigateToEmptyTrash(effect.volumeId) }
|
||||
}
|
||||
}
|
||||
modalBottomSheetContentState.sheetState.show()
|
||||
@@ -112,7 +118,7 @@ fun TrashScreen(
|
||||
sheetContent = modalBottomSheetContentState.sheetContent.value,
|
||||
viewState = remember { ModalBottomSheetViewState() },
|
||||
) {
|
||||
Box(
|
||||
Column(
|
||||
modifier = modifier.systemBarsPadding()
|
||||
) {
|
||||
ProtonPullToRefresh(
|
||||
@@ -125,6 +131,28 @@ fun TrashScreen(
|
||||
driveLinks = DriveLinksFlow.PagingList(viewModel.driveLinks, viewModel.listEffect),
|
||||
viewState = viewState,
|
||||
viewEvent = viewEvent,
|
||||
tabs = {
|
||||
if (viewState.volumesEntries.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
viewState.volumesEntries.forEach { entry ->
|
||||
ProtonTab(
|
||||
titleResId = entry.title,
|
||||
isSelected = entry.isSelected,
|
||||
onTab = { viewEvent.onTab(entry) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Divider(
|
||||
color = ProtonTheme.colors.separatorNorm,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Crossfade(trashIconState) { trashIconState ->
|
||||
when (trashIconState) {
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewevent
|
||||
|
||||
interface PickerPhotosAndAlbumsViewEvent {
|
||||
val onBackPressed: () -> Unit
|
||||
val onReset: () -> Unit
|
||||
val onAddToAlbum: () -> Unit
|
||||
}
|
||||
@@ -19,8 +19,9 @@
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.CombinedLoadStates
|
||||
import androidx.paging.PagingData
|
||||
@@ -44,12 +45,18 @@ import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
|
||||
import me.proton.android.drive.photos.domain.usecase.GetAddToAlbumPhotoListings
|
||||
import me.proton.android.drive.photos.domain.usecase.GetPhotoListingCount
|
||||
import me.proton.android.drive.photos.domain.usecase.RemoveFromAlbumInfo
|
||||
import me.proton.android.drive.photos.presentation.extension.details
|
||||
import me.proton.android.drive.photos.presentation.state.PhotosItem
|
||||
import me.proton.android.drive.photos.presentation.viewevent.AlbumViewEvent
|
||||
import me.proton.android.drive.photos.presentation.viewstate.AlbumViewState
|
||||
import me.proton.android.drive.ui.common.onClick
|
||||
import me.proton.android.drive.usecase.OnFilesDriveLinkError
|
||||
import me.proton.core.domain.arch.onSuccess
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
@@ -59,18 +66,26 @@ import me.proton.core.drive.base.domain.extension.onFailure
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.log.logId
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.common.Action
|
||||
import me.proton.core.drive.base.presentation.effect.ListEffect
|
||||
import me.proton.core.drive.base.presentation.extension.quantityString
|
||||
import me.proton.core.drive.base.presentation.state.ListContentAppendingState
|
||||
import me.proton.core.drive.base.presentation.state.ListContentState
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.base.presentation.viewmodel.onLoadState
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.photo.domain.paging.PhotoDriveLinks
|
||||
import me.proton.core.drive.drivelink.photo.domain.usecase.GetPagedAlbumPhotoListingsList
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
|
||||
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.photo.domain.entity.PhotoListing
|
||||
import me.proton.core.drive.share.domain.entity.Share
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
@@ -87,12 +102,32 @@ import me.proton.core.presentation.R as CorePresentation
|
||||
class AlbumViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
getDecryptedDriveLink: GetDecryptedDriveLink,
|
||||
selectLinks: SelectLinks,
|
||||
deselectLinks: DeselectLinks,
|
||||
selectAll: SelectAll,
|
||||
getSelectedDriveLinks: GetSelectedDriveLinks,
|
||||
getPhotoListingCount: GetPhotoListingCount,
|
||||
addToAlbumInfo: AddToAlbumInfo,
|
||||
removeFromAlbumInfo: RemoveFromAlbumInfo,
|
||||
getAddToAlbumPhotoListings: GetAddToAlbumPhotoListings,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val onFilesDriveLinkError: OnFilesDriveLinkError,
|
||||
private val photoDriveLinks: PhotoDriveLinks,
|
||||
private val getPagedAlbumPhotoListingsList: GetPagedAlbumPhotoListingsList,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
) : PhotosPickerAndSelectionViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
selectLinks = selectLinks,
|
||||
deselectLinks = deselectLinks,
|
||||
selectAll = selectAll,
|
||||
getSelectedDriveLinks = getSelectedDriveLinks,
|
||||
getPhotoListingCount = getPhotoListingCount,
|
||||
addToAlbumInfo = addToAlbumInfo,
|
||||
removeFromAlbumInfo = removeFromAlbumInfo,
|
||||
getAddToAlbumPhotoListings = getAddToAlbumPhotoListings,
|
||||
) {
|
||||
override val filterByParentId: Boolean = false
|
||||
private val shareId = ShareId(userId, requireNotNull(savedStateHandle.get<String>(SHARE_ID)))
|
||||
private val albumId = AlbumId(shareId, requireNotNull(savedStateHandle[ALBUM_ID]))
|
||||
private var fetchingJob: Job? = null
|
||||
@@ -123,6 +158,7 @@ class AlbumViewModel @Inject constructor(
|
||||
driveLink.coverLinkId?.let { coverLinkId ->
|
||||
photoDriveLinks.load(setOf(coverLinkId))
|
||||
}
|
||||
parentId.value = driveLink.id
|
||||
return@mapWithPrevious driveLink
|
||||
}
|
||||
.onFailure { error ->
|
||||
@@ -151,7 +187,17 @@ class AlbumViewModel @Inject constructor(
|
||||
val viewState: Flow<AlbumViewState> = combine(
|
||||
driveLink.filterNotNull(),
|
||||
listContentState,
|
||||
) { album, contentState ->
|
||||
selected,
|
||||
) { album, contentState, selected ->
|
||||
topBarActions.value = when {
|
||||
inPickerMode -> emptySet()
|
||||
selected.isNotEmpty() -> setOf(
|
||||
selectedOptionsAction {
|
||||
viewEvent?.onSelectedOptions?.invoke()
|
||||
}
|
||||
)
|
||||
else -> setOf(albumOptionsAction)
|
||||
}
|
||||
AlbumViewState(
|
||||
name = album.name,
|
||||
details = album.details(appContext),
|
||||
@@ -159,6 +205,21 @@ class AlbumViewModel @Inject constructor(
|
||||
listContentState = contentState,
|
||||
isRefreshEnabled = contentState !is ListContentState.Loading,
|
||||
topBarActions = topBarActions,
|
||||
selected = this@AlbumViewModel.selected,
|
||||
inMultiselect = selected.isNotEmpty() || inPickerMode,
|
||||
showActions = !inPickerMode,
|
||||
navigationIconResId = if (selected.isEmpty() || inPickerMode) {
|
||||
CorePresentation.drawable.ic_arrow_back
|
||||
} else {
|
||||
CorePresentation.drawable.ic_proton_close
|
||||
},
|
||||
title = takeIf { selected.isNotEmpty() && !inPickerMode }
|
||||
?.let {
|
||||
appContext.quantityString(
|
||||
I18N.plurals.common_selected,
|
||||
selected.size
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,8 +250,37 @@ class AlbumViewModel @Inject constructor(
|
||||
|
||||
fun viewEvent(
|
||||
navigateToAlbumOptions: (AlbumId) -> Unit,
|
||||
navigateToPhotosOptions: (FileId, AlbumId?) -> Unit,
|
||||
navigateToMultiplePhotosOptions: (selectionId: SelectionId, AlbumId?) -> Unit,
|
||||
navigateToPreview: (FileId, AlbumId) -> Unit,
|
||||
navigateToPicker: (AlbumId) -> Unit,
|
||||
navigateBack: () -> Unit,
|
||||
lifecycle: Lifecycle,
|
||||
) : AlbumViewEvent = object : AlbumViewEvent {
|
||||
private val driveLinkShareFlow =
|
||||
MutableSharedFlow<DriveLink>(extraBufferCapacity = 1).also { flow ->
|
||||
viewModelScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
flow.take(1).collect { driveLink ->
|
||||
driveLink.onClick(
|
||||
navigateToFolder = { _, _ -> error("Photos should not have folders") },
|
||||
navigateToPreview = { linkId ->
|
||||
viewModelScope.launch {
|
||||
navigateToPreview(
|
||||
linkId,
|
||||
this@AlbumViewModel.driveLink.filterNotNull().first().id,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val onTopAppBarNavigation = onTopAppBarNavigation {
|
||||
onBackPressed()
|
||||
}
|
||||
override val onBackPressed = { navigateBack() }
|
||||
override val onLoadState: (CombinedLoadStates, Int) -> Unit = onLoadState(
|
||||
appContext = appContext,
|
||||
@@ -200,14 +290,17 @@ class AlbumViewModel @Inject constructor(
|
||||
coroutineScope = viewModelScope,
|
||||
emptyState = MutableStateFlow(
|
||||
ListContentState.Empty(
|
||||
imageResId = BasePresentation.drawable.empty_folder_dark, //TODO: replace with proper illustration once available
|
||||
titleId = I18N.string.albums_new_album_name_hint, //TODO: replace with proper text once available
|
||||
imageResId = BasePresentation.drawable.ic_proton_images_50,
|
||||
titleId = I18N.string.albums_empty_album_screen_title,
|
||||
descriptionResId = I18N.string.albums_empty_album_screen_description,
|
||||
)
|
||||
),
|
||||
) { message ->
|
||||
viewModelScope.launch {
|
||||
//TODO: we need a snackbar
|
||||
}
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = message,
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
override val onScroll = this@AlbumViewModel::onScroll
|
||||
override val onErrorAction = { retry() }
|
||||
@@ -218,10 +311,37 @@ class AlbumViewModel @Inject constructor(
|
||||
}
|
||||
Unit
|
||||
}
|
||||
override val onDriveLink = { driveLink: DriveLink ->
|
||||
onDriveLink(driveLink) {
|
||||
driveLinkShareFlow.tryEmit(driveLink)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
override val onSelectDriveLink = { driveLink: DriveLink ->
|
||||
onSelectDriveLink(driveLink)
|
||||
}
|
||||
override val onAddToAlbum = { onAddToAlbum(navigateToPicker) }
|
||||
override val onSelectedOptions =
|
||||
{ onSelectedOptions(navigateToPhotosOptions, navigateToMultiplePhotosOptions, albumId) }
|
||||
override val onBack = { onBack() }
|
||||
}.also { viewEvent ->
|
||||
this.viewEvent = viewEvent
|
||||
}
|
||||
|
||||
override fun onTopAppBarNavigation(nonSelectedBlock: () -> Unit): () -> Unit = {
|
||||
if (inPickerMode) {
|
||||
viewEvent?.onBackPressed?.invoke()
|
||||
} else {
|
||||
super.onTopAppBarNavigation(nonSelectedBlock).invoke()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAddToAlbum(navigateToPicker: (AlbumId) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
navigateToPicker(this@AlbumViewModel.driveLink.filterNotNull().first().id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScroll(driveLinkIds: Set<LinkId>) {
|
||||
if (driveLinkIds.isNotEmpty()) {
|
||||
fetchingJob?.cancel()
|
||||
|
||||
@@ -77,6 +77,7 @@ import me.proton.core.util.kotlin.CoreLogger
|
||||
import javax.inject.Inject
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
import me.proton.core.drive.base.presentation.R as BasePresentation
|
||||
|
||||
@HiltViewModel
|
||||
class AlbumsViewModel @Inject constructor(
|
||||
@@ -198,7 +199,11 @@ class AlbumsViewModel @Inject constructor(
|
||||
|
||||
is DataResult.Success -> emit(result.value).also {
|
||||
if (result.value.isEmpty()) {
|
||||
listContentState.value = ListContentState.Empty(0, 0)
|
||||
listContentState.value = ListContentState.Empty(
|
||||
imageResId = BasePresentation.drawable.empty_albums,
|
||||
titleId = I18N.string.albums_empty_albums_list_screen_title,
|
||||
descriptionResId = I18N.string.albums_empty_albums_list_screen_description,
|
||||
)
|
||||
} else {
|
||||
listContentState.value = ListContentState.Content()
|
||||
}
|
||||
|
||||
+15
-1
@@ -25,8 +25,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.viewevent.ConfirmEmptyTrashViewEvent
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.log.LogTag.TRASH
|
||||
import me.proton.core.drive.base.domain.log.logId
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.trash.domain.usecase.EmptyTrash
|
||||
import me.proton.core.drive.volume.domain.entity.VolumeId
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -36,13 +41,22 @@ class ConfirmEmptyTrashDialogViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
private val volumeId: VolumeId = VolumeId(savedStateHandle.require(VOLUME_ID))
|
||||
|
||||
fun viewEvent(dismiss: () -> Unit) = object : ConfirmEmptyTrashViewEvent {
|
||||
override val onConfirm = {
|
||||
viewModelScope.launch {
|
||||
emptyTrash(userId)
|
||||
emptyTrash(userId, volumeId).onFailure { error ->
|
||||
error.log(TRASH, "Cannot empty trash volumeId: ${volumeId.id.logId()}")
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val VOLUME_ID = "volumeId"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-7
@@ -26,9 +26,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.viewevent.ConfirmStopSharingViewEvent
|
||||
@@ -38,7 +36,7 @@ import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.drivelink.shared.domain.usecase.DeleteShareUrl
|
||||
import me.proton.core.drive.drivelink.shared.domain.usecase.DeleteShareUrlAndShare
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.extension.userId
|
||||
@@ -52,7 +50,7 @@ import me.proton.core.drive.i18n.R as I18N
|
||||
@Suppress("StaticFieldLeak")
|
||||
class ConfirmStopLinkSharingDialogViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val deleteShareUrl: DeleteShareUrl,
|
||||
private val deleteShareUrlAndShare: DeleteShareUrlAndShare,
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
@@ -78,16 +76,16 @@ class ConfirmStopLinkSharingDialogViewModel @Inject constructor(
|
||||
fun viewEvent(confirm: () -> Unit) = object : ConfirmStopSharingViewEvent {
|
||||
override val onConfirm = {
|
||||
viewModelScope.launch {
|
||||
deleteShareUrl(confirm)
|
||||
stopSharing(confirm)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteShareUrl(confirm: () -> Unit) {
|
||||
private suspend fun stopSharing(confirm: () -> Unit) {
|
||||
isLoading.emit(true)
|
||||
errorMessage.emit(null)
|
||||
val result = deleteShareUrl(linkId)
|
||||
val result = deleteShareUrlAndShare(linkId)
|
||||
isLoading.emit(false)
|
||||
result.onSuccess {
|
||||
broadcastMessages(
|
||||
|
||||
@@ -84,6 +84,7 @@ class CreateNewAlbumViewModel @Inject constructor(
|
||||
private val currentAlbumName = MutableStateFlow<String?>(null)
|
||||
private val initialAlbumName = flowOf {
|
||||
getNewAlbumName(userId)
|
||||
.onSuccess { albumName -> currentAlbumName.value = albumName }
|
||||
.getOrNull(VIEW_MODEL, "Get new album info failed")
|
||||
?: ""
|
||||
}
|
||||
@@ -97,6 +98,7 @@ class CreateNewAlbumViewModel @Inject constructor(
|
||||
isDoneEnabled = true,
|
||||
isAlbumNameEnabled = true,
|
||||
isAddEnabled = true,
|
||||
isRemoveEnabled = true,
|
||||
isCreationInProgress = isCreationInProgress.value,
|
||||
name = initialAlbumName,
|
||||
hint = appContext.getString(I18N.string.albums_new_album_name_hint),
|
||||
@@ -107,10 +109,11 @@ class CreateNewAlbumViewModel @Inject constructor(
|
||||
isCreationInProgress,
|
||||
) { isEnabled, isInProgress ->
|
||||
initialViewState.copy(
|
||||
isDoneEnabled = isEnabled,
|
||||
isDoneEnabled = isEnabled && !isInProgress,
|
||||
isCreationInProgress = isInProgress,
|
||||
isAlbumNameEnabled = !isInProgress,
|
||||
isAddEnabled = !isInProgress,
|
||||
isRemoveEnabled = !isInProgress,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,6 +132,7 @@ class CreateNewAlbumViewModel @Inject constructor(
|
||||
fun viewEvent(
|
||||
navigateBack: () -> Unit,
|
||||
navigateToAlbum: (AlbumId) -> Unit,
|
||||
navigateToPicker: () -> Unit,
|
||||
): CreateNewAlbumViewEvent = object : CreateNewAlbumViewEvent {
|
||||
override val onBackPressed = { navigateBack() }
|
||||
override val onDone = { onCreateAlbum(navigateToAlbum) }
|
||||
@@ -136,6 +140,7 @@ class CreateNewAlbumViewModel @Inject constructor(
|
||||
override val onLoadState = { _: CombinedLoadStates, _: Int -> }
|
||||
override val onScroll = this@CreateNewAlbumViewModel::onScroll
|
||||
override val onRemove = this@CreateNewAlbumViewModel::onRemove
|
||||
override val onAdd = { navigateToPicker() }
|
||||
}
|
||||
|
||||
private fun onCreateAlbum(navigateToAlbum: (AlbumId) -> Unit) {
|
||||
|
||||
+95
-5
@@ -32,9 +32,12 @@ import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.extension.getDefaultMessage
|
||||
import me.proton.android.drive.photos.domain.usecase.RemovePhotosFromAlbum
|
||||
import me.proton.android.drive.photos.presentation.extension.processRemove
|
||||
import me.proton.android.drive.ui.options.Option
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.options.filter
|
||||
import me.proton.android.drive.ui.options.filterAlbums
|
||||
import me.proton.android.drive.ui.options.filterPermissions
|
||||
import me.proton.android.drive.ui.options.filterProtonDocs
|
||||
import me.proton.android.drive.ui.options.filterRoot
|
||||
@@ -59,14 +62,18 @@ import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLin
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.domain.extension.isShareMember
|
||||
import me.proton.core.drive.drivelink.offline.domain.usecase.ToggleOffline
|
||||
import me.proton.core.drive.drivelink.photo.domain.usecase.UpdateAlbumCover
|
||||
import me.proton.core.drive.drivelink.shared.domain.extension.sharingDetails
|
||||
import me.proton.core.drive.drivelink.trash.domain.usecase.ToggleTrashState
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.NOT_FOUND
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbumsDisabled
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveDocsDisabled
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveSharingDevelopment
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
|
||||
import me.proton.core.drive.files.presentation.entry.FileOptionEntry
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
@@ -84,6 +91,7 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
getDriveLink: GetDecryptedDriveLink,
|
||||
getFeatureFlagFlow: GetFeatureFlagFlow,
|
||||
albumsFeatureFlag: AlbumsFeatureFlag,
|
||||
private val toggleOffline: ToggleOffline,
|
||||
private val toggleTrashState: ToggleTrashState,
|
||||
private val exportTo: ExportTo,
|
||||
@@ -92,12 +100,20 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
private val openProtonDocumentInBrowser: OpenProtonDocumentInBrowser,
|
||||
private val updateAlbumCover: UpdateAlbumCover,
|
||||
private val removePhotosFromAlbum: RemovePhotosFromAlbum,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
private var dismiss: (() -> Unit)? = null
|
||||
private val linkId: LinkId = FileId(
|
||||
ShareId(userId, savedStateHandle.require(KEY_SHARE_ID)),
|
||||
savedStateHandle.require(KEY_LINK_ID)
|
||||
)
|
||||
private val albumId: AlbumId? = savedStateHandle.get<String>(KEY_ALBUM_ID)?.let { albumId ->
|
||||
AlbumId(
|
||||
ShareId(userId, savedStateHandle.require(KEY_ALBUM_SHARE_ID)),
|
||||
albumId
|
||||
)
|
||||
}
|
||||
val driveLink: Flow<DriveLink?> = getDriveLink(
|
||||
linkId = linkId,
|
||||
failOnDecryptionError = false,
|
||||
@@ -110,7 +126,6 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
new
|
||||
}
|
||||
.stateIn(viewModelScope, Eagerly, null)
|
||||
private val optionsFilter = savedStateHandle.require<OptionsFilter>(OPTIONS_FILTER)
|
||||
|
||||
private val sharingDevelopment = getFeatureFlagFlow(driveSharingDevelopment(userId))
|
||||
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveSharingDevelopment(userId), NOT_FOUND))
|
||||
@@ -118,6 +133,11 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
private val docsKillSwitch = getFeatureFlagFlow(driveDocsDisabled(userId))
|
||||
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveDocsDisabled(userId), NOT_FOUND))
|
||||
|
||||
private val albumsFeature = albumsFeatureFlag(userId)
|
||||
.stateIn(viewModelScope, Eagerly, configurationProvider.albumsFeatureFlag)
|
||||
private val albumsKillSwitch = getFeatureFlagFlow(driveAlbumsDisabled(userId))
|
||||
.stateIn(viewModelScope, Eagerly, FeatureFlag(driveAlbumsDisabled(userId), NOT_FOUND))
|
||||
|
||||
fun <T : DriveLink> entries(
|
||||
runAction: (suspend () -> Unit) -> Unit,
|
||||
navigateToInfo: (linkId: LinkId) -> Unit,
|
||||
@@ -133,10 +153,12 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
this.driveLink.filterNotNull(),
|
||||
sharingDevelopment,
|
||||
docsKillSwitch,
|
||||
) { driveLink, sharingDevelopment, protonDocsKillSwitch ->
|
||||
albumsFeature,
|
||||
albumsKillSwitch,
|
||||
) { driveLink, sharingDevelopment, protonDocsKillSwitch, albumsFeatureFlagOn, albumsKillSwitch ->
|
||||
options
|
||||
.filter(driveLink)
|
||||
.filter(optionsFilter)
|
||||
.filterAlbums(albumsFeatureFlagOn, albumsKillSwitch, albumId)
|
||||
.filterRoot(driveLink, sharingDevelopment)
|
||||
.filterShareMember(driveLink.isShareMember)
|
||||
.filterPermissions(driveLink.sharePermissions ?: Permissions.owner)
|
||||
@@ -176,6 +198,16 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
openProtonDocumentInBrowser(driveLink)
|
||||
}
|
||||
}
|
||||
is Option.SetAsAlbumCover -> option.build(runAction) { driveLink ->
|
||||
viewModelScope.launch {
|
||||
setAsAlbumCover(driveLink)
|
||||
}
|
||||
}
|
||||
is Option.RemoveFromAlbum -> option.build(runAction) { driveLink ->
|
||||
viewModelScope.launch {
|
||||
removePhotosFromAlbum(driveLink)
|
||||
}
|
||||
}
|
||||
else -> throw IllegalStateException(
|
||||
"Option ${option.javaClass.simpleName} is not found. Did you forget to add it?"
|
||||
)
|
||||
@@ -185,6 +217,61 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun removePhotosFromAlbum(
|
||||
driveLink: DriveLink.File,
|
||||
) {
|
||||
removePhotosFromAlbum(
|
||||
albumId = requireNotNull(albumId),
|
||||
fileIds = listOf(driveLink.id),
|
||||
)
|
||||
.onFailure { error ->
|
||||
error.log(VIEW_MODEL, "Failed to remove file from album")
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage,
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
.onSuccess { result ->
|
||||
result.processRemove(appContext) { message, type ->
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = message,
|
||||
type = type,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setAsAlbumCover(
|
||||
driveLink: DriveLink.File
|
||||
) {
|
||||
updateAlbumCover(
|
||||
volumeId = driveLink.volumeId,
|
||||
albumId = requireNotNull(albumId),
|
||||
newCoverFileId = driveLink.id
|
||||
).onFailure { error ->
|
||||
error.log(LogTag.ALBUM, "Cannot update album cover: ${driveLink.id.id.logId()}")
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR
|
||||
)
|
||||
}.onSuccess {
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = appContext.getString(I18N.string.albums_set_album_as_cover_success),
|
||||
type = BroadcastMessage.Type.INFO,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun leaveShare(driveLink: DriveLink) {
|
||||
val shareId = driveLink.sharingDetails?.shareId
|
||||
val memberId = driveLink.shareUser?.id
|
||||
@@ -224,9 +311,12 @@ class FileOrFolderOptionsViewModel @Inject constructor(
|
||||
companion object {
|
||||
const val KEY_SHARE_ID = "shareId"
|
||||
const val KEY_LINK_ID = "linkId"
|
||||
const val OPTIONS_FILTER = "optionsFilter"
|
||||
const val KEY_ALBUM_ID = "albumId"
|
||||
const val KEY_ALBUM_SHARE_ID = "albumShareId"
|
||||
|
||||
private val options = setOf(
|
||||
Option.SetAsAlbumCover,
|
||||
Option.RemoveFromAlbum,
|
||||
Option.OfflineToggle,
|
||||
Option.ShareViaInvitations,
|
||||
Option.ManageAccess,
|
||||
|
||||
@@ -146,7 +146,7 @@ class FilesViewModel @Inject constructor(
|
||||
result
|
||||
.onSuccess { driveLink ->
|
||||
CoreLogger.d(VIEW_MODEL, "drive link onSuccess")
|
||||
parentFolderId.value = driveLink.id
|
||||
parentId.value = driveLink.id
|
||||
return@mapWithPrevious driveLink
|
||||
}
|
||||
.onFailure { error ->
|
||||
@@ -363,7 +363,10 @@ class FilesViewModel @Inject constructor(
|
||||
override val onAppendErrorAction = { retry() }
|
||||
override val onMoreOptions = { driveLink: DriveLink -> navigateToFileOrFolderOptions(driveLink.id) }
|
||||
override val onSelectedOptions = {
|
||||
onSelectedOptions(navigateToFileOrFolderOptions, navigateToMultipleFileOrFolderOptions)
|
||||
onSelectedOptions(
|
||||
{ linkId: LinkId, _ -> navigateToFileOrFolderOptions(linkId) },
|
||||
{ selectionId: SelectionId, _ -> navigateToMultipleFileOrFolderOptions(selectionId) },
|
||||
)
|
||||
}
|
||||
override val onParentFolderOptions = { onParentFolderOptions(navigateToParentFolderOptions) }
|
||||
override val onCancelUpload = { uploadFileLink: UploadFileLink -> onCancelUpload(uploadFileLink) }
|
||||
|
||||
@@ -61,8 +61,8 @@ import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawerViewEvent
|
||||
import me.proton.core.drive.navigationdrawer.presentation.NavigationDrawerViewState
|
||||
import me.proton.core.drive.share.user.domain.usecase.GetUserInvitationCountFlow
|
||||
import me.proton.core.drive.share.user.domain.usecase.HasUserInvitationFlow
|
||||
import me.proton.core.drive.volume.domain.entity.VolumeId
|
||||
import me.proton.core.user.domain.UserManager
|
||||
import me.proton.core.user.domain.entity.User
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
|
||||
@@ -39,16 +39,19 @@ import me.proton.core.compose.component.ProtonSnackbarType
|
||||
import me.proton.core.domain.arch.mapSuccessValueOrNull
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.presentation.common.getThemeDrawableId
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
import me.proton.core.drive.drivelink.list.domain.usecase.GetPagedDriveLinksList
|
||||
import me.proton.core.drive.files.presentation.state.FilesViewState
|
||||
import me.proton.core.drive.base.presentation.effect.ListEffect
|
||||
import me.proton.core.drive.base.presentation.state.ListContentAppendingState
|
||||
import me.proton.core.drive.base.presentation.state.ListContentState
|
||||
import me.proton.core.drive.base.presentation.effect.ListEffect
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.base.presentation.viewmodel.onLoadState
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.list.domain.usecase.GetPagedDriveLinksList
|
||||
import me.proton.core.drive.files.presentation.state.FilesViewState
|
||||
import me.proton.core.drive.link.domain.entity.Folder
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.domain.extension.requireFolderId
|
||||
import me.proton.core.drive.sorting.domain.entity.Sorting
|
||||
import me.proton.core.drive.base.presentation.R as BasePresentation
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
@@ -85,18 +88,18 @@ abstract class HostFilesViewModel(
|
||||
isClickEnabled = { driveLink -> driveLink is Folder },
|
||||
isTextEnabled = { driveLink -> FilesViewState.defaultIsTextEnabled(driveLink) && driveLink is Folder }
|
||||
)
|
||||
protected val trigger = MutableSharedFlow<FolderId?>(replay = 1).apply { tryEmit(parentId) }
|
||||
protected val trigger = MutableSharedFlow<ParentId?>(replay = 1).apply { tryEmit(parentId) }
|
||||
protected val parentLink = trigger
|
||||
.transformLatest { folderId ->
|
||||
.transformLatest { parentId ->
|
||||
listContentState.value = ListContentState.Loading
|
||||
emitAll(getDriveLink(userId, folderId = folderId))
|
||||
emitAll(getDriveLink(userId = userId, parentId = parentId))
|
||||
}
|
||||
.mapSuccessValueOrNull()
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, null)
|
||||
val driveLinks = parentLink
|
||||
.transformLatest { driveLink ->
|
||||
emit(PagingData.empty())
|
||||
if (driveLink != null) {
|
||||
if (driveLink != null && driveLink is Folder) {
|
||||
emitAll(getPagedDriveLinks(folderId = driveLink.id))
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
@@ -155,7 +158,11 @@ abstract class HostFilesViewModel(
|
||||
open fun onCreateFolder(
|
||||
navigateToCreateFolder: (FolderId) -> Unit,
|
||||
) {
|
||||
parentLink.value?.let { folder -> navigateToCreateFolder(folder.id) }
|
||||
parentLink.value?.let { parent ->
|
||||
(parent as? DriveLink.Folder)?.let { folder ->
|
||||
navigateToCreateFolder(folder.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun retry() {
|
||||
|
||||
@@ -188,7 +188,7 @@ class MoveToFolderViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun confirmMove(navigateBack: () -> Unit) = viewModelScope.launch {
|
||||
val folder = parentLink.value
|
||||
val folder = parentLink.value as? DriveLink.Folder
|
||||
if (folder != null) {
|
||||
if (folder.id != parentId) {
|
||||
moveFile(userId, driveLinksToMove.value.map { driveLink -> driveLink.id }, folder.id)
|
||||
|
||||
+68
-8
@@ -18,25 +18,31 @@
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.extension.getDefaultMessage
|
||||
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
|
||||
import me.proton.android.drive.photos.domain.usecase.RemovePhotosFromAlbum
|
||||
import me.proton.android.drive.photos.presentation.extension.processRemove
|
||||
import me.proton.android.drive.ui.options.Option
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.ui.options.filter
|
||||
import me.proton.android.drive.ui.options.filterAlbums
|
||||
import me.proton.android.drive.ui.options.filterAll
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.extension.getOrNull
|
||||
import me.proton.core.drive.base.domain.log.LogTag
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.extension.require
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.documentsprovider.domain.usecase.ExportToDownload
|
||||
@@ -50,9 +56,13 @@ import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.d
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
|
||||
import me.proton.core.drive.files.presentation.entry.OptionEntry
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.extension.requireFolderId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.trash.domain.usecase.SendToTrash
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -62,17 +72,25 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
getSelectedDriveLinks: GetSelectedDriveLinks,
|
||||
getFeatureFlagFlow: GetFeatureFlagFlow,
|
||||
albumsFeatureFlag: AlbumsFeatureFlag,
|
||||
configurationProvider: ConfigurationProvider,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val sendToTrash: SendToTrash,
|
||||
private val exportToDownload: ExportToDownload,
|
||||
private val deselectLinks: DeselectLinks,
|
||||
private val addToAlbumInfo: AddToAlbumInfo,
|
||||
private val removePhotosFromAlbum: RemovePhotosFromAlbum,
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
private val selectionId = SelectionId(requireNotNull(savedStateHandle.get(KEY_SELECTION_ID)))
|
||||
val selectedDriveLinks: Flow<List<DriveLink>> = getSelectedDriveLinks(selectionId)
|
||||
// Send -> ACTION_SEND_MULTIPLE (mime type aggregation) - we need to update sendfiledialog with multiple file download
|
||||
// Mime type aggregation - all the same use that one, all same prefix use prefix/* else use */*
|
||||
private val optionsFilter = savedStateHandle.require<OptionsFilter>(OPTIONS_FILTER)
|
||||
private val albumId: AlbumId? = savedStateHandle.get<String>(KEY_ALBUM_ID)?.let { albumId ->
|
||||
AlbumId(
|
||||
ShareId(userId, savedStateHandle.require(KEY_ALBUM_SHARE_ID)),
|
||||
albumId
|
||||
)
|
||||
}
|
||||
private val albumsFeature = albumsFeatureFlag(userId)
|
||||
.stateIn(viewModelScope, Eagerly, configurationProvider.albumsFeatureFlag)
|
||||
private val albumsKillSwitch = getFeatureFlagFlow(driveAlbumsDisabled(userId))
|
||||
@@ -90,8 +108,7 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
) { albumsFeatureFlagOn, albumsKillSwitch ->
|
||||
options
|
||||
.filterAll(driveLinks)
|
||||
.filter(optionsFilter)
|
||||
.filterAlbums(albumsFeatureFlagOn, albumsKillSwitch)
|
||||
.filterAlbums(albumsFeatureFlagOn, albumsKillSwitch, albumId)
|
||||
.map { option ->
|
||||
when (option) {
|
||||
is Option.Trash -> option.build(
|
||||
@@ -106,7 +123,7 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
is Option.Move -> option.build(
|
||||
runAction = runAction,
|
||||
navigateToMoveAll = {
|
||||
navigateToMove(selectionId, driveLinks.first().parentId)
|
||||
navigateToMove(selectionId, driveLinks.first().requireFolderId())
|
||||
}
|
||||
)
|
||||
is Option.Download -> option.build(
|
||||
@@ -136,6 +153,18 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
}
|
||||
},
|
||||
)
|
||||
is Option.RemoveFromAlbum -> option.build(
|
||||
runAction = runAction,
|
||||
removeSelectedFromAlbum = {
|
||||
viewModelScope.launch {
|
||||
removePhotosFromAlbum(
|
||||
driveLinks
|
||||
.filterIsInstance<DriveLink.File>()
|
||||
)
|
||||
deselectLinks(selectionId)
|
||||
}
|
||||
},
|
||||
)
|
||||
else -> throw IllegalStateException(
|
||||
"Option ${option.javaClass.simpleName} is not found. Did you forget to add it?"
|
||||
)
|
||||
@@ -146,11 +175,42 @@ class MultipleFileOrFolderOptionsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun removePhotosFromAlbum(
|
||||
driveLinks: List<DriveLink.File>,
|
||||
) {
|
||||
removePhotosFromAlbum(
|
||||
albumId = requireNotNull(albumId),
|
||||
fileIds = driveLinks.map { driveLink -> driveLink.id },
|
||||
)
|
||||
.onFailure { error ->
|
||||
error.log(VIEW_MODEL, "Failed to remove file from album")
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage,
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
.onSuccess { result ->
|
||||
result.processRemove(appContext) { message, type ->
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = message,
|
||||
type = type,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_SELECTION_ID = "selectionId"
|
||||
const val OPTIONS_FILTER = "optionsFilter"
|
||||
const val KEY_ALBUM_ID = "albumId"
|
||||
const val KEY_ALBUM_SHARE_ID = "albumShareId"
|
||||
|
||||
private val options = setOfNotNull(
|
||||
Option.RemoveFromAlbum,
|
||||
Option.CreateAlbum,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Option.Download else null,
|
||||
Option.Move,
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
|
||||
import me.proton.android.drive.photos.domain.usecase.GetAddToAlbumPhotoListings
|
||||
import me.proton.android.drive.photos.domain.usecase.GetPhotoListingCount
|
||||
import me.proton.android.drive.photos.domain.usecase.RemoveFromAlbumInfo
|
||||
import me.proton.core.drive.base.domain.extension.getOrNull
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.domain.extension.toVolumePhotoListing
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
|
||||
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
|
||||
open class PhotosPickerAndSelectionViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
selectLinks: SelectLinks,
|
||||
deselectLinks: DeselectLinks,
|
||||
selectAll: SelectAll,
|
||||
getSelectedDriveLinks: GetSelectedDriveLinks,
|
||||
private val getPhotoListingCount: GetPhotoListingCount,
|
||||
private val addToAlbumInfo: AddToAlbumInfo,
|
||||
private val removeFromAlbumInfo: RemoveFromAlbumInfo,
|
||||
private val getAddToAlbumPhotoListings: GetAddToAlbumPhotoListings,
|
||||
) : SelectionViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
selectLinks = selectLinks,
|
||||
deselectLinks = deselectLinks,
|
||||
selectAll = selectAll,
|
||||
getSelectedDriveLinks = getSelectedDriveLinks,
|
||||
) {
|
||||
private val destinationAlbumId: AlbumId? = savedStateHandle.get<String?>(DESTINATION_SHARE_ID)
|
||||
?.let { destinationShareId ->
|
||||
savedStateHandle.get<String?>(DESTINATION_ALBUM_ID)?.let { destinationAlbumId ->
|
||||
AlbumId(ShareId(userId, destinationShareId), destinationAlbumId)
|
||||
}
|
||||
}
|
||||
protected val inPickerMode: Boolean = savedStateHandle[IN_PICKER_MODE] ?: false
|
||||
|
||||
override fun onDriveLink(driveLink: DriveLink, nonSelectedBlock: () -> Unit) {
|
||||
if (inPickerMode && driveLink is DriveLink.File) {
|
||||
if (selected.value.contains(driveLink.id)) {
|
||||
removeFromAlbum(driveLink)
|
||||
removeSelected(listOf(driveLink.id))
|
||||
} else {
|
||||
addToAlbum(driveLink)
|
||||
addSelected(listOf(driveLink.id))
|
||||
}
|
||||
} else {
|
||||
super.onDriveLink(driveLink, nonSelectedBlock)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun initializeSelectionInPickerMode() {
|
||||
if (inPickerMode) {
|
||||
getAddToAlbumPhotoListings(userId, destinationAlbumId)
|
||||
.getOrNull(VIEW_MODEL, "Failed to get add to album photo listings")
|
||||
?.map { photoListing ->
|
||||
photoListing.linkId
|
||||
}
|
||||
?.let { photoIds ->
|
||||
if (photoIds.isNotEmpty()) {
|
||||
removeAllSelected()
|
||||
addSelected(photoIds)
|
||||
}
|
||||
}
|
||||
getPhotoListingCount(userId, destinationAlbumId)
|
||||
.onEach { count ->
|
||||
if (count == 0) {
|
||||
removeAllSelected()
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addToAlbum(driveLink: DriveLink.File) = viewModelScope.launch {
|
||||
val photoListings = setOf(driveLink.toVolumePhotoListing())
|
||||
if (destinationAlbumId == null) {
|
||||
addToAlbumInfo(photoListings)
|
||||
} else {
|
||||
addToAlbumInfo(destinationAlbumId, photoListings)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeFromAlbum(driveLink: DriveLink.File) = viewModelScope.launch {
|
||||
val photoListings = setOf(driveLink.toVolumePhotoListing())
|
||||
if (destinationAlbumId == null) {
|
||||
removeFromAlbumInfo(photoListings)
|
||||
} else {
|
||||
removeFromAlbumInfo(destinationAlbumId, photoListings)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val IN_PICKER_MODE = "inPickerMode"
|
||||
const val DESTINATION_SHARE_ID = "destinationShareId"
|
||||
const val DESTINATION_ALBUM_ID = "destinationAlbumId"
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,12 @@ import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.photos.domain.entity.PhotoBackupState
|
||||
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
|
||||
import me.proton.android.drive.photos.domain.usecase.EnablePhotosBackup
|
||||
import me.proton.android.drive.photos.domain.usecase.GetAddToAlbumPhotoListings
|
||||
import me.proton.android.drive.photos.domain.usecase.GetPhotoListingCount
|
||||
import me.proton.android.drive.photos.domain.usecase.GetPhotosDriveLink
|
||||
import me.proton.android.drive.photos.domain.usecase.RemoveFromAlbumInfo
|
||||
import me.proton.android.drive.photos.domain.usecase.ShowUpsell
|
||||
import me.proton.android.drive.photos.presentation.R
|
||||
import me.proton.android.drive.photos.presentation.state.PhotosItem
|
||||
@@ -144,6 +148,10 @@ class PhotosViewModel @Inject constructor(
|
||||
selectAll: SelectAll,
|
||||
selectLinks: SelectLinks,
|
||||
deselectLinks: DeselectLinks,
|
||||
addToAlbumInfo: AddToAlbumInfo,
|
||||
removeFromAlbumInfo: RemoveFromAlbumInfo,
|
||||
getAddToAlbumPhotoListings: GetAddToAlbumPhotoListings,
|
||||
getPhotoListingCount: GetPhotoListingCount,
|
||||
val backupPermissionsViewModel: BackupPermissionsViewModel,
|
||||
private val photoDriveLinks: PhotoDriveLinks,
|
||||
private val onFilesDriveLinkError: OnFilesDriveLinkError,
|
||||
@@ -151,7 +159,17 @@ class PhotosViewModel @Inject constructor(
|
||||
private val checkMissingFolders: CheckMissingFolders,
|
||||
private val cancelUserMessage: CancelUserMessage,
|
||||
shouldUpgradeStorage: ShouldUpgradeStorage,
|
||||
) : SelectionViewModel(savedStateHandle, selectLinks, deselectLinks, selectAll, getSelectedDriveLinks),
|
||||
) : PhotosPickerAndSelectionViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
selectLinks = selectLinks,
|
||||
deselectLinks = deselectLinks,
|
||||
selectAll = selectAll,
|
||||
getSelectedDriveLinks = getSelectedDriveLinks,
|
||||
addToAlbumInfo = addToAlbumInfo,
|
||||
removeFromAlbumInfo = removeFromAlbumInfo,
|
||||
getAddToAlbumPhotoListings = getAddToAlbumPhotoListings,
|
||||
getPhotoListingCount = getPhotoListingCount,
|
||||
),
|
||||
HomeTabViewModel,
|
||||
NotificationDotViewModel by NotificationDotViewModel(shouldUpgradeStorage) {
|
||||
|
||||
@@ -203,7 +221,7 @@ class PhotosViewModel @Inject constructor(
|
||||
result
|
||||
.onSuccess { driveLink ->
|
||||
CoreLogger.d(VIEW_MODEL, "drive link onSuccess")
|
||||
parentFolderId.value = driveLink.id
|
||||
parentId.value = driveLink.id
|
||||
return@mapWithPrevious driveLink
|
||||
}
|
||||
.onFailure { error ->
|
||||
@@ -224,10 +242,10 @@ class PhotosViewModel @Inject constructor(
|
||||
val driveLinksMap: Flow<Map<LinkId, DriveLink>> = photoDriveLinks.getDriveLinksMapFlow(userId)
|
||||
|
||||
val driveLinks: Flow<PagingData<PhotosItem>> =
|
||||
parentFolderId
|
||||
parentId
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.transformLatest { folderId ->
|
||||
.transformLatest { _ ->
|
||||
emitAll(
|
||||
getPagedPhotoListingsList(userId)
|
||||
.map { pagingData ->
|
||||
@@ -295,11 +313,11 @@ class PhotosViewModel @Inject constructor(
|
||||
dayNight = R.drawable.empty_photos_daynight,
|
||||
)
|
||||
|
||||
private val backupState = parentFolderId.flatMapLatest { folderId ->
|
||||
if (folderId == null) {
|
||||
private val backupState = parentId.flatMapLatest { parentId ->
|
||||
if (parentId == null || parentId !is FolderId) {
|
||||
getDisabledBackupState()
|
||||
} else {
|
||||
getBackupState(folderId = folderId)
|
||||
getBackupState(folderId = parentId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,11 +363,11 @@ class PhotosViewModel @Inject constructor(
|
||||
CorePresentation.drawable.ic_proton_cross
|
||||
},
|
||||
notificationDotVisible = showHamburgerMenuIcon && notificationDotRequested,
|
||||
inMultiselect = selected.isNotEmpty(),
|
||||
inMultiselect = selected.isNotEmpty() || inPickerMode,
|
||||
listContentState = listContentState,
|
||||
showEmptyList = backupState.isBackupEnabled || backupState.hasDefaultFolder == false ,
|
||||
showPhotosStateIndicator = showPhotosStateIndicator,
|
||||
showPhotosStateBanner = showPhotosStateBanner,
|
||||
showPhotosStateIndicator = showPhotosStateIndicator && !inPickerMode,
|
||||
showPhotosStateBanner = showPhotosStateBanner && !inPickerMode,
|
||||
backupStatusViewState = backupStatusFormatter.toViewState(
|
||||
backupState = backupState,
|
||||
count = count.takeIf { configurationProvider.photosSavedCounter },
|
||||
@@ -407,8 +425,12 @@ class PhotosViewModel @Inject constructor(
|
||||
}
|
||||
override val onRefresh = this@PhotosViewModel::onRefresh
|
||||
override val onErrorAction = this@PhotosViewModel::onErrorAction
|
||||
override val onSelectedOptions =
|
||||
{ onSelectedOptions(navigateToPhotosOptions, navigateToMultiplePhotosOptions) }
|
||||
override val onSelectedOptions = {
|
||||
onSelectedOptions(
|
||||
{ linkId: FileId, _ -> navigateToPhotosOptions(linkId) },
|
||||
{ selectionId: SelectionId, _ -> navigateToMultiplePhotosOptions(selectionId) },
|
||||
)
|
||||
}
|
||||
override val onSelectDriveLink = { driveLink: DriveLink -> onSelectDriveLink(driveLink) }
|
||||
override val onDeselectDriveLink =
|
||||
{ driveLink: DriveLink -> onDeselectDriveLink(driveLink) }
|
||||
@@ -429,7 +451,7 @@ class PhotosViewModel @Inject constructor(
|
||||
dismissBackgroundRestrictions()
|
||||
}
|
||||
override val onResolve: () -> Unit = {
|
||||
parentFolderId.value?.let { folderId ->
|
||||
(parentId.value as? FolderId)?.let { folderId ->
|
||||
navigateToPhotosIssues(folderId)
|
||||
}
|
||||
}
|
||||
@@ -451,11 +473,10 @@ class PhotosViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun onEnable() {
|
||||
parentFolderId.value?.let { folderId ->
|
||||
(parentId.value as? FolderId)?.let { folderId ->
|
||||
backupPermissionsViewModel.toggleBackup(folderId) { state ->
|
||||
onPhotoBackupState(state)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +496,7 @@ class PhotosViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun enablePhotosBackup() {
|
||||
parentFolderId.value?.let { folderId ->
|
||||
(parentId.value as? FolderId)?.let { folderId ->
|
||||
enablePhotosBackup(folderId).onSuccess { state ->
|
||||
onPhotoBackupState(state)
|
||||
}.onFailure { error ->
|
||||
@@ -523,7 +544,7 @@ class PhotosViewModel @Inject constructor(
|
||||
|
||||
private fun onRetry() {
|
||||
viewModelScope.launch {
|
||||
parentFolderId.value?.let { folderId ->
|
||||
(parentId.value as? FolderId)?.let { folderId ->
|
||||
retryBackup(folderId).onFailure { error ->
|
||||
error.log(BACKUP, "Cannot retry on backup")
|
||||
broadcastMessages(
|
||||
@@ -573,7 +594,7 @@ class PhotosViewModel @Inject constructor(
|
||||
|
||||
private fun onRefresh() {
|
||||
viewModelScope.launch {
|
||||
parentFolderId.value?.let { folderId ->
|
||||
(parentId.value as? FolderId)?.let { folderId ->
|
||||
checkMissingFolders(folderId).onFailure { error ->
|
||||
error.log(VIEW_MODEL, "Failed check missing folders")
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.extension.getDefaultMessage
|
||||
import me.proton.android.drive.extension.log
|
||||
import me.proton.android.drive.photos.domain.usecase.AddPhotosToAlbum
|
||||
import me.proton.android.drive.photos.domain.usecase.GetPhotoListingCount
|
||||
import me.proton.android.drive.photos.domain.usecase.RemoveFromAlbumInfo
|
||||
import me.proton.android.drive.photos.presentation.extension.processAdd
|
||||
import me.proton.android.drive.ui.viewevent.PickerPhotosAndAlbumsViewEvent
|
||||
import me.proton.android.drive.ui.viewstate.PickerPhotosAndAlbumsViewState
|
||||
import me.proton.core.drive.base.domain.log.LogTag.VIEW_MODEL
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.base.presentation.extension.quantityString
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import javax.inject.Inject
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
@HiltViewModel
|
||||
class PickerPhotosAndAlbumsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
getPhotoListingCount: GetPhotoListingCount,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val removeFromAlbumInfo: RemoveFromAlbumInfo,
|
||||
private val broadcastMessages: BroadcastMessages,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
private val addPhotosToAlbum: AddPhotosToAlbum,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
val destinationAlbumId = savedStateHandle.get<String?>(DESTINATION_SHARE_ID)?.let { destinationShareId ->
|
||||
savedStateHandle.get<String?>(DESTINATION_ALBUM_ID)?.let { destinationAlbumId ->
|
||||
AlbumId(ShareId(userId, destinationShareId), destinationAlbumId)
|
||||
}
|
||||
}
|
||||
private val addingInProgress = MutableStateFlow(false)
|
||||
private val photoListingsCount: StateFlow<Int?> = getPhotoListingCount(userId, destinationAlbumId)
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
val viewState = combine(
|
||||
addingInProgress,
|
||||
photoListingsCount.filterNotNull(),
|
||||
) { inProgress, count ->
|
||||
PickerPhotosAndAlbumsViewState(
|
||||
addToAlbumButtonTitle = if (count == 0) {
|
||||
appContext.getString(I18N.string.albums_add_zero_to_album_button)
|
||||
} else {
|
||||
appContext.quantityString(
|
||||
pluralRes = I18N.plurals.albums_add_non_zero_to_album_button,
|
||||
quantity = count,
|
||||
)
|
||||
},
|
||||
isAddToAlbumButtonEnabled = (count > 0) && !inProgress,
|
||||
isResetButtonEnabled = (count > 0) && !inProgress,
|
||||
isAddingInProgress = inProgress,
|
||||
)
|
||||
}
|
||||
|
||||
fun viewEvent(
|
||||
navigateBack: () -> Unit,
|
||||
onAddToAlbumDone: () -> Unit,
|
||||
): PickerPhotosAndAlbumsViewEvent = object : PickerPhotosAndAlbumsViewEvent {
|
||||
override val onBackPressed = { navigateBack() }
|
||||
override val onReset = this@PickerPhotosAndAlbumsViewModel::onReset
|
||||
override val onAddToAlbum = { onAddToAlbum(onAddToAlbumDone) }
|
||||
}
|
||||
|
||||
private fun onAddToAlbum(onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
if (destinationAlbumId == null) {
|
||||
onDone()
|
||||
} else {
|
||||
// add photos from add to album info into destination album
|
||||
addingInProgress.value = true
|
||||
addPhotosToAlbum(destinationAlbumId)
|
||||
.onFailure { error ->
|
||||
addingInProgress.value = false
|
||||
error.log(VIEW_MODEL, "Failed adding photos to album")
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
context = appContext,
|
||||
useExceptionMessage = configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.ERROR,
|
||||
)
|
||||
}
|
||||
.onSuccess { result ->
|
||||
addingInProgress.value = false
|
||||
result
|
||||
.processAdd(appContext) { message, type ->
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = message,
|
||||
type = type,
|
||||
)
|
||||
}
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onReset() {
|
||||
viewModelScope.launch {
|
||||
removeFromAlbumInfo(userId, destinationAlbumId)
|
||||
.onFailure { error ->
|
||||
error.log(VIEW_MODEL, "Failed removing all photo listings from add to album")
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
context = appContext,
|
||||
useExceptionMessage = configurationProvider.useExceptionMessage
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DESTINATION_SHARE_ID = "destinationShareId"
|
||||
const val DESTINATION_ALBUM_ID = "destinationAlbumId"
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,23 @@ import me.proton.core.drive.files.preview.presentation.component.state.ContentSt
|
||||
import me.proton.core.drive.files.preview.presentation.component.state.PreviewContentState
|
||||
import me.proton.core.drive.files.preview.presentation.component.state.PreviewViewState
|
||||
import me.proton.core.drive.files.preview.presentation.component.toComposable
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.Link
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.extension.isProtonDocument
|
||||
import me.proton.core.drive.link.domain.extension.requireFolderId
|
||||
import me.proton.core.drive.link.domain.extension.rootFolderId
|
||||
import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.photo.domain.entity.PhotoListing
|
||||
import me.proton.core.drive.photo.domain.repository.AlbumRepository
|
||||
import me.proton.core.drive.photo.domain.repository.PhotoRepository
|
||||
import me.proton.core.drive.share.crypto.domain.usecase.GetPhotoShare
|
||||
import me.proton.core.drive.share.domain.entity.Share
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.share.domain.usecase.GetShare
|
||||
import me.proton.core.drive.sorting.domain.entity.Direction
|
||||
import me.proton.core.drive.sorting.domain.usecase.GetSorting
|
||||
import me.proton.core.drive.thumbnail.presentation.entity.ThumbnailVO
|
||||
import me.proton.core.drive.thumbnail.presentation.extension.thumbnailVO
|
||||
@@ -140,11 +145,20 @@ class PreviewViewModel @Inject constructor(
|
||||
getDriveLinksCount: GetDriveLinksCount,
|
||||
getSorting: GetSorting,
|
||||
getPhotoShare: GetPhotoShare,
|
||||
getShare: GetShare,
|
||||
photoRepository: PhotoRepository,
|
||||
albumRepository: AlbumRepository,
|
||||
sortDriveLinks: SortDriveLinks,
|
||||
val savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
private val albumId =
|
||||
savedStateHandle.get<String>(Screen.PagerPreview.ALBUM_ID)?.let { albumIdString ->
|
||||
AlbumId(
|
||||
shareId = ShareId(userId, savedStateHandle.require(Screen.PagerPreview.ALBUM_SHARE_ID)),
|
||||
id = albumIdString,
|
||||
)
|
||||
}
|
||||
private val trigger = MutableSharedFlow<Trigger>(1).apply {
|
||||
val shareId = savedStateHandle.require<String>(Screen.PagerPreview.SHARE_ID)
|
||||
val fileId = savedStateHandle.require<String>(Screen.PagerPreview.FILE_ID)
|
||||
@@ -198,6 +212,15 @@ class PreviewViewModel @Inject constructor(
|
||||
configurationProvider = configurationProvider,
|
||||
coroutineScope = viewModelScope,
|
||||
)
|
||||
PagerType.ALBUM -> AlbumContentProvider(
|
||||
userId = userId,
|
||||
albumId = requireNotNull(albumId) {"Missing albumId"} ,
|
||||
getDecryptedDriveLink = getDecryptedDriveLink,
|
||||
getShare = getShare,
|
||||
albumRepository = albumRepository,
|
||||
configurationProvider = configurationProvider,
|
||||
coroutineScope = viewModelScope,
|
||||
)
|
||||
}
|
||||
|
||||
private val contentStatesCache = mutableMapOf<FileId, Flow<ContentState>>()
|
||||
@@ -261,11 +284,11 @@ class PreviewViewModel @Inject constructor(
|
||||
|
||||
fun viewEvent(
|
||||
navigateBack: () -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: LinkId) -> Unit,
|
||||
navigateToFileOrFolderOptions: (LinkId, AlbumId?) -> Unit,
|
||||
navigateToProtonDocsInsertImageOptions: () -> Unit,
|
||||
): PreviewViewEvent = object : PreviewViewEvent {
|
||||
override val onTopAppBarNavigation = { navigateBack() }
|
||||
override val onMoreOptions = { navigateToFileOrFolderOptions(fileId) }
|
||||
override val onMoreOptions = { navigateToFileOrFolderOptions(fileId, albumId) }
|
||||
override val onSingleTap = { toggleFullscreen() }
|
||||
override val onRenderFailed = { throwable: Throwable, source: Any -> renderFailed.value = throwable to source }
|
||||
override val mediaControllerVisibility = { visible: Boolean ->
|
||||
@@ -559,7 +582,7 @@ class FolderContentProvider(
|
||||
getDriveLink(fileId)
|
||||
.transformSuccess { (_, driveLink) ->
|
||||
emitAll(
|
||||
getDriveLink(userId, folderId = driveLink.parentId)
|
||||
getDriveLink(userId, folderId = driveLink.requireFolderId())
|
||||
)
|
||||
}
|
||||
.mapSuccessValueOrNull()
|
||||
@@ -734,55 +757,113 @@ class PhotoContentProvider(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PhotoListing.placeholderDriveLink(photoShare: Share): DriveLink.File =
|
||||
DriveLink.File(
|
||||
link = Link.File(
|
||||
id = linkId as FileId,
|
||||
parentId = photoShare.rootFolderId,
|
||||
name = "",
|
||||
size = 0.bytes,
|
||||
lastModified = TimestampS(),
|
||||
mimeType = "",
|
||||
isShared = false,
|
||||
key = "",
|
||||
passphrase = "",
|
||||
passphraseSignature = "",
|
||||
numberOfAccesses = 0,
|
||||
shareUrlExpirationTime = null,
|
||||
uploadedBy = "",
|
||||
isFavorite = false,
|
||||
attributes = Attributes(0),
|
||||
permissions = Permissions(),
|
||||
state = Link.State.ACTIVE,
|
||||
nameSignatureEmail = null,
|
||||
hash = nameHash.orEmpty(),
|
||||
expirationTime = null,
|
||||
nodeKey = "",
|
||||
nodePassphrase = "",
|
||||
nodePassphraseSignature = "",
|
||||
signatureEmail = "",
|
||||
creationTime = TimestampS(),
|
||||
trashedTime = null,
|
||||
hasThumbnail = false,
|
||||
activeRevisionId = "",
|
||||
xAttr = null,
|
||||
sharingDetails = null,
|
||||
contentKeyPacket = "",
|
||||
contentKeyPacketSignature = null,
|
||||
photoCaptureTime = captureTime,
|
||||
photoContentHash = contentHash,
|
||||
mainPhotoLinkId = null,
|
||||
),
|
||||
volumeId = photoShare.volumeId,
|
||||
isMarkedAsOffline = false,
|
||||
isAnyAncestorMarkedAsOffline = false,
|
||||
downloadState = null,
|
||||
trashState = null,
|
||||
cryptoName = CryptoProperty.Encrypted(""),
|
||||
cryptoXAttr = CryptoProperty.Encrypted(""),
|
||||
shareInvitationCount = null,
|
||||
shareMemberCount = null,
|
||||
shareUser = null,
|
||||
)
|
||||
}
|
||||
|
||||
class AlbumContentProvider(
|
||||
private val getDecryptedDriveLink: GetDecryptedDriveLink,
|
||||
getShare: GetShare,
|
||||
albumRepository: AlbumRepository,
|
||||
userId: UserId,
|
||||
configurationProvider: ConfigurationProvider,
|
||||
coroutineScope: CoroutineScope,
|
||||
albumId: AlbumId,
|
||||
) : PreviewContentProvider {
|
||||
|
||||
private val photoShare: StateFlow<Share?> = getShare(albumId.shareId)
|
||||
.filterSuccessOrError()
|
||||
.mapSuccessValueOrNull()
|
||||
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val albumPhotoListings: StateFlow<List<PhotoListing>> = photoShare
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.transform { photoShare ->
|
||||
emitAll(
|
||||
albumRepository.getAlbumPhotoListingCount(userId, photoShare.volumeId, albumId)
|
||||
.distinctUntilChanged()
|
||||
.transformLatest {
|
||||
emit(
|
||||
pagedList(
|
||||
pageSize = configurationProvider.dbPageSize,
|
||||
) { fromIndex, count ->
|
||||
albumRepository.getAlbumPhotoListings(
|
||||
userId = userId,
|
||||
volumeId = photoShare.volumeId,
|
||||
albumId = albumId,
|
||||
fromIndex = fromIndex,
|
||||
count = count,
|
||||
sortingBy = PhotoListing.Album.SortBy.CAPTURED,
|
||||
sortingDirection = Direction.DESCENDING
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override fun getDriveLinks(fileId: FileId): Flow<List<DriveLink.File>> = combine(
|
||||
photoShare.filterNotNull(),
|
||||
getDecryptedDriveLink(fileId).filterSuccessOrError().mapSuccessValueOrNull(),
|
||||
albumPhotoListings,
|
||||
) { photoShare, driveLink, albumPhotoListings ->
|
||||
albumPhotoListings.map { albumPhotoListing ->
|
||||
if (albumPhotoListing.linkId == driveLink?.id) {
|
||||
driveLink
|
||||
} else {
|
||||
albumPhotoListing.placeholderDriveLink(photoShare)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PhotoListing.placeholderDriveLink(
|
||||
photoShare: Share,
|
||||
): DriveLink.File = DriveLink.File(
|
||||
link = Link.File(
|
||||
id = linkId as FileId,
|
||||
parentId = photoShare.rootFolderId,
|
||||
name = "",
|
||||
size = 0.bytes,
|
||||
lastModified = TimestampS(),
|
||||
mimeType = "",
|
||||
isShared = false,
|
||||
key = "",
|
||||
passphrase = "",
|
||||
passphraseSignature = "",
|
||||
numberOfAccesses = 0,
|
||||
shareUrlExpirationTime = null,
|
||||
uploadedBy = "",
|
||||
attributes = Attributes(0),
|
||||
permissions = Permissions(),
|
||||
state = Link.State.ACTIVE,
|
||||
nameSignatureEmail = null,
|
||||
hash = nameHash.orEmpty(),
|
||||
expirationTime = null,
|
||||
nodeKey = "",
|
||||
nodePassphrase = "",
|
||||
nodePassphraseSignature = "",
|
||||
signatureEmail = "",
|
||||
creationTime = TimestampS(),
|
||||
trashedTime = null,
|
||||
hasThumbnail = false,
|
||||
activeRevisionId = "",
|
||||
xAttr = null,
|
||||
sharingDetails = null,
|
||||
contentKeyPacket = "",
|
||||
contentKeyPacketSignature = null,
|
||||
photoCaptureTime = captureTime,
|
||||
photoContentHash = contentHash,
|
||||
mainPhotoLinkId = null,
|
||||
),
|
||||
volumeId = photoShare.volumeId,
|
||||
isMarkedAsOffline = false,
|
||||
isAnyAncestorMarkedAsOffline = false,
|
||||
downloadState = null,
|
||||
trashState = null,
|
||||
cryptoName = CryptoProperty.Encrypted(""),
|
||||
cryptoXAttr = CryptoProperty.Encrypted(""),
|
||||
shareInvitationCount = null,
|
||||
shareMemberCount = null,
|
||||
shareUser = null,
|
||||
)
|
||||
|
||||
@@ -40,13 +40,14 @@ import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.SelectAll
|
||||
import me.proton.core.drive.i18n.R
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
|
||||
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -59,17 +60,23 @@ open class SelectionViewModel(
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
protected open val driveLinkFilter: (DriveLink) -> Boolean = { true }
|
||||
protected open val filterByParentId: Boolean = true
|
||||
|
||||
protected val selectionId = MutableStateFlow(
|
||||
savedStateHandle.get<String?>(KEY_SELECTION_ID)?.let { SelectionId(it) }
|
||||
)
|
||||
protected val parentFolderId = MutableStateFlow<FolderId?>(null)
|
||||
protected val parentId = MutableStateFlow<ParentId?>(null)
|
||||
protected val selected: StateFlow<Set<LinkId>> = selectionId
|
||||
.filterNotNull()
|
||||
.transformLatest { id ->
|
||||
val parentId = parentFolderId.filterNotNull().first()
|
||||
val selectedDriveLinks = if (filterByParentId) {
|
||||
val parentId = parentId.filterNotNull().first()
|
||||
getSelectedDriveLinks(id, parentId)
|
||||
} else {
|
||||
getSelectedDriveLinks(id)
|
||||
}
|
||||
emitAll(
|
||||
getSelectedDriveLinks(id, parentId).map { driveLinks ->
|
||||
selectedDriveLinks.map { driveLinks ->
|
||||
driveLinks.map { driveLink -> driveLink.id }.toSet()
|
||||
}
|
||||
)
|
||||
@@ -80,7 +87,7 @@ open class SelectionViewModel(
|
||||
onAction = {
|
||||
viewModelScope.launch {
|
||||
selectAll(
|
||||
parentId = parentFolderId.filterNotNull().first(),
|
||||
parentId = parentId.filterNotNull().first(),
|
||||
selectionId = selectionId.value,
|
||||
driveLinkFilter = driveLinkFilter,
|
||||
)
|
||||
@@ -103,7 +110,7 @@ open class SelectionViewModel(
|
||||
onAction = { onAction?.invoke() }
|
||||
)
|
||||
|
||||
protected fun onTopAppBarNavigation(nonSelectedBlock: () -> Unit): () -> Unit = {
|
||||
protected open fun onTopAppBarNavigation(nonSelectedBlock: () -> Unit): () -> Unit = {
|
||||
Unit.also {
|
||||
if (selected.value.isNotEmpty()) {
|
||||
selectionId.value?.let { viewModelScope.launch { deselectLinks(it) } }
|
||||
@@ -113,7 +120,7 @@ open class SelectionViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
protected fun onDriveLink(driveLink: DriveLink, nonSelectedBlock: () -> Unit) {
|
||||
protected open fun onDriveLink(driveLink: DriveLink, nonSelectedBlock: () -> Unit) {
|
||||
if (selected.value.isNotEmpty()) {
|
||||
if (selected.value.contains(driveLink.id)) {
|
||||
removeSelected(listOf(driveLink.id))
|
||||
@@ -126,13 +133,14 @@ open class SelectionViewModel(
|
||||
}
|
||||
|
||||
protected inline fun <reified T : LinkId> onSelectedOptions(
|
||||
navigateToFileOrFolderOptions: (linkId: T) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId) -> Unit,
|
||||
navigateToFileOrFolderOptions: (linkId: T, albumId: AlbumId?) -> Unit,
|
||||
navigateToMultipleFileOrFolderOptions: (selectionId: SelectionId, albumId: AlbumId?) -> Unit,
|
||||
albumId: AlbumId? = null,
|
||||
) {
|
||||
if (selected.value.size == 1) {
|
||||
navigateToFileOrFolderOptions(selected.value.first() as T)
|
||||
navigateToFileOrFolderOptions(selected.value.first() as T, albumId)
|
||||
} else {
|
||||
selectionId.value?.let { selectionId -> navigateToMultipleFileOrFolderOptions(selectionId) }
|
||||
selectionId.value?.let { selectionId -> navigateToMultipleFileOrFolderOptions(selectionId, albumId) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +150,7 @@ open class SelectionViewModel(
|
||||
|
||||
protected fun onBack() { removeAllSelected() }
|
||||
|
||||
private fun addSelected(linkIds: List<LinkId>) {
|
||||
protected open fun addSelected(linkIds: List<LinkId>) {
|
||||
viewModelScope.launch {
|
||||
selectionId.value?.let { selectionId ->
|
||||
selectLinks(selectionId, linkIds)
|
||||
@@ -150,7 +158,7 @@ open class SelectionViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeSelected(linkIds: List<LinkId>) {
|
||||
protected open fun removeSelected(linkIds: List<LinkId>) {
|
||||
viewModelScope.launch {
|
||||
selectionId.value?.let { selectionId ->
|
||||
deselectLinks(selectionId, linkIds)
|
||||
@@ -158,7 +166,7 @@ open class SelectionViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAllSelected() {
|
||||
protected fun removeAllSelected() {
|
||||
if (selected.value.isNotEmpty()) {
|
||||
viewModelScope.launch {
|
||||
selectionId.value?.let { selectionId -> deselectLinks(selectionId) }
|
||||
|
||||
@@ -33,28 +33,37 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.drive.ui.effect.TrashEffect
|
||||
import me.proton.android.drive.ui.navigation.Screen
|
||||
import me.proton.android.drive.ui.screen.EmptyTrashIconState
|
||||
import me.proton.core.domain.arch.mapSuccessValueOrNull
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.presentation.common.getThemeDrawableId
|
||||
import me.proton.core.drive.base.presentation.effect.ListEffect
|
||||
import me.proton.core.drive.base.presentation.state.ListContentAppendingState
|
||||
import me.proton.core.drive.base.presentation.state.ListContentState
|
||||
import me.proton.core.drive.base.presentation.viewmodel.UserViewModel
|
||||
import me.proton.core.drive.base.presentation.viewmodel.onLoadState
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.trash.domain.usecase.GetPagedTrashedDriveLinks
|
||||
import me.proton.core.drive.files.presentation.event.FilesViewEvent
|
||||
import me.proton.core.drive.files.presentation.state.FilesViewState
|
||||
import me.proton.core.drive.base.presentation.state.ListContentAppendingState
|
||||
import me.proton.core.drive.base.presentation.state.ListContentState
|
||||
import me.proton.core.drive.base.presentation.effect.ListEffect
|
||||
import me.proton.core.drive.base.presentation.viewmodel.onLoadState
|
||||
import me.proton.core.drive.files.presentation.state.VolumeEntry
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.sorting.domain.entity.Sorting
|
||||
import me.proton.core.drive.sorting.domain.usecase.GetSorting
|
||||
import me.proton.core.drive.trash.domain.TrashManager
|
||||
import me.proton.core.drive.trash.domain.usecase.GetEmptyTrashState
|
||||
import me.proton.core.drive.volume.domain.entity.Volume
|
||||
import me.proton.core.drive.volume.domain.entity.VolumeId
|
||||
import me.proton.core.drive.volume.domain.usecase.GetVolumes
|
||||
import me.proton.drive.android.settings.domain.entity.LayoutType
|
||||
import me.proton.drive.android.settings.domain.usecase.GetLayoutType
|
||||
import me.proton.drive.android.settings.domain.usecase.ToggleLayoutType
|
||||
@@ -75,8 +84,11 @@ class TrashViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val toggleLayoutType: ToggleLayoutType,
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
getVolumes: GetVolumes,
|
||||
) : ViewModel(), UserViewModel by UserViewModel(savedStateHandle) {
|
||||
|
||||
private val volumeIdFlow = MutableStateFlow<VolumeId?>(null)
|
||||
|
||||
private val listContentState = MutableStateFlow<ListContentState>(ListContentState.Loading)
|
||||
private val listContentAppendingState = MutableStateFlow<ListContentAppendingState>(ListContentAppendingState.Idle)
|
||||
private val _listEffect = MutableSharedFlow<ListEffect>()
|
||||
@@ -96,24 +108,49 @@ class TrashViewModel @Inject constructor(
|
||||
listContentState,
|
||||
listContentAppendingState,
|
||||
layoutType,
|
||||
) { sorting, contentState, appendingState, layoutType ->
|
||||
getVolumes(userId).mapSuccessValueOrNull(),
|
||||
) { sorting, contentState, appendingState, layoutType, volumes ->
|
||||
val listContentState = when (contentState) {
|
||||
is ListContentState.Empty -> contentState.copy(
|
||||
imageResId = emptyStateImageResId,
|
||||
)
|
||||
else -> contentState
|
||||
}
|
||||
if (volumeIdFlow.value == null) {
|
||||
volumeIdFlow.emit(
|
||||
volumes?.firstOrNull { volume -> volume.type == Volume.Type.REGULAR }?.id
|
||||
?: volumes?.firstOrNull { volume -> volume.type == Volume.Type.PHOTO }?.id
|
||||
)
|
||||
}
|
||||
initialViewState.copy(
|
||||
sorting = sorting,
|
||||
listContentState = listContentState,
|
||||
listContentAppendingState = appendingState,
|
||||
isGrid = layoutType == LayoutType.GRID,
|
||||
volumesEntries = volumes?.takeIf { it.size > 1 }
|
||||
.orEmpty()
|
||||
.sortedBy { volume -> volume.type }
|
||||
.map { volume ->
|
||||
VolumeEntry(
|
||||
id = volume.id,
|
||||
title = when (volume.type) {
|
||||
Volume.Type.UNKNOWN -> I18N.string.common_unknown
|
||||
Volume.Type.REGULAR -> I18N.string.title_files
|
||||
Volume.Type.PHOTO -> I18N.string.photos_title
|
||||
},
|
||||
isSelected = volume.id == volumeIdFlow.value
|
||||
)
|
||||
},
|
||||
)
|
||||
}.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
val driveLinks: Flow<PagingData<DriveLink>> = getTrashedDriveLinks(userId).cachedIn(viewModelScope)
|
||||
val driveLinks: Flow<PagingData<DriveLink>> = volumeIdFlow.filterNotNull().transformLatest { volumeId ->
|
||||
emitAll(getTrashedDriveLinks(userId, volumeId))
|
||||
}.cachedIn(viewModelScope)
|
||||
val listEffect: Flow<ListEffect> = _listEffect.asSharedFlow()
|
||||
val trashEffect: Flow<TrashEffect> = _trashEffect.asSharedFlow()
|
||||
val emptyTrashState = combine(listContentState, getEmptyTrashState(userId)) { content, state ->
|
||||
val emptyTrashState = combine(listContentState, volumeIdFlow.filterNotNull().transformLatest { volumeId ->
|
||||
emitAll(getEmptyTrashState(userId, volumeId))
|
||||
}) { content, state ->
|
||||
if (content !is ListContentState.Content) {
|
||||
EmptyTrashIconState.HIDDEN
|
||||
} else when (state) {
|
||||
@@ -158,6 +195,10 @@ class TrashViewModel @Inject constructor(
|
||||
override val onAppendErrorAction = { retry() }
|
||||
override val onMoreOptions = { driveLink: DriveLink -> navigateToFileOrFolderOptions(driveLink.id) }
|
||||
override val onToggleLayout = this@TrashViewModel::onToggleLayout
|
||||
override val onTab = { volumeEntry: VolumeEntry ->
|
||||
volumeIdFlow.tryEmit(volumeEntry.id)
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun retry() {
|
||||
@@ -174,7 +215,9 @@ class TrashViewModel @Inject constructor(
|
||||
|
||||
fun onMoreOptionsClicked() {
|
||||
viewModelScope.launch {
|
||||
_trashEffect.emit(TrashEffect.MoreOptions)
|
||||
volumeIdFlow.firstOrNull()?.let { volumeId ->
|
||||
_trashEffect.emit(TrashEffect.MoreOptions(volumeId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ class UploadToViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun upload(navigateToStorageFull: () -> Unit, exitApp: () -> Unit) = viewModelScope.launch {
|
||||
parentLink.value?.let { folder ->
|
||||
(parentLink.value as? DriveLink.Folder)?.let { folder ->
|
||||
val copiedUris = mutableListOf<String>()
|
||||
coRunCatching {
|
||||
val job = launch {
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewstate
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class PickerPhotosAndAlbumsViewState(
|
||||
val addToAlbumButtonTitle: String,
|
||||
val isAddToAlbumButtonEnabled: Boolean = true,
|
||||
val isAddingInProgress: Boolean = false,
|
||||
val isResetButtonEnabled: Boolean = true,
|
||||
)
|
||||
+56
-15
@@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import me.proton.android.drive.ui.options.OptionsFilter
|
||||
import me.proton.android.drive.photos.domain.usecase.RemovePhotosFromAlbum
|
||||
import me.proton.android.drive.usecase.NotifyActivityNotFound
|
||||
import me.proton.android.drive.usecase.OpenProtonDocumentInBrowser
|
||||
import me.proton.core.crypto.common.pgp.VerificationStatus
|
||||
@@ -43,19 +43,23 @@ import me.proton.core.drive.documentsprovider.domain.usecase.ExportTo
|
||||
import me.proton.core.drive.drivelink.crypto.domain.usecase.GetDecryptedDriveLink
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.offline.domain.usecase.ToggleOffline
|
||||
import me.proton.core.drive.drivelink.photo.domain.usecase.UpdateAlbumCover
|
||||
import me.proton.core.drive.drivelink.trash.domain.usecase.ToggleTrashState
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
|
||||
import me.proton.core.drive.files.presentation.entry.DownloadFileEntry
|
||||
import me.proton.core.drive.files.presentation.entry.FileInfoEntry
|
||||
import me.proton.core.drive.files.presentation.entry.ManageAccessEntry
|
||||
import me.proton.core.drive.files.presentation.entry.MoveFileEntry
|
||||
import me.proton.core.drive.files.presentation.entry.OpenInBrowserProtonDocsEntry
|
||||
import me.proton.core.drive.files.presentation.entry.RemoveFromAlbumFileEntry
|
||||
import me.proton.core.drive.files.presentation.entry.RemoveMeEntry
|
||||
import me.proton.core.drive.files.presentation.entry.RenameFileEntry
|
||||
import me.proton.core.drive.files.presentation.entry.SendFileEntry
|
||||
import me.proton.core.drive.files.presentation.entry.SetAsAlbumCoverEntry
|
||||
import me.proton.core.drive.files.presentation.entry.ShareViaInvitationsEntry
|
||||
import me.proton.core.drive.files.presentation.entry.ToggleOfflineEntry
|
||||
import me.proton.core.drive.files.presentation.entry.ToggleTrashEntry
|
||||
@@ -70,7 +74,6 @@ import me.proton.core.drive.share.user.domain.entity.ShareUser
|
||||
import me.proton.core.drive.share.user.domain.usecase.LeaveShare
|
||||
import me.proton.core.drive.shareurl.base.domain.entity.ShareUrlId
|
||||
import me.proton.core.drive.volume.domain.entity.VolumeId
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -90,15 +93,20 @@ class FileOrFolderOptionsViewModelTest {
|
||||
private val notifyActivityNotFound = mockk<NotifyActivityNotFound>()
|
||||
private val getFeatureFlagFlow = mockk<GetFeatureFlagFlow>()
|
||||
private val openProtonDocumentInBrowser = mockk<OpenProtonDocumentInBrowser>()
|
||||
private val updateAlbumCover = mockk<UpdateAlbumCover>()
|
||||
private val removePhotosFromAlbum = mockk<RemovePhotosFromAlbum>()
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
coEvery { savedStateHandle.get<String>(any()) } returns "value"
|
||||
coEvery { savedStateHandle.get<OptionsFilter>("optionsFilter") } returns OptionsFilter.FILES
|
||||
coEvery { getFeatureFlagFlow.invoke(any())} answers {
|
||||
coEvery { getFeatureFlagFlow.invoke(any(), any(), any())} answers {
|
||||
val id: FeatureFlagId = arg(0)
|
||||
flowOf(FeatureFlag(id, State.NOT_FOUND))
|
||||
}
|
||||
coEvery { getFeatureFlagFlow.refreshAfterDuration} answers {
|
||||
{ false }
|
||||
}
|
||||
coEvery { configurationProvider.albumsFeatureFlag} returns true
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -275,7 +283,7 @@ class FileOrFolderOptionsViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `file options on photo share`() = runTest {
|
||||
fun `file options on photo share without album feature flag`() = runTest {
|
||||
// Given
|
||||
coEvery { getDriveLink.invoke(any<LinkId>(), any()) } returns flowOf(photoDriveLink.asSuccess)
|
||||
|
||||
@@ -283,15 +291,46 @@ class FileOrFolderOptionsViewModelTest {
|
||||
val entries = fileOptionEntries()
|
||||
|
||||
// Then
|
||||
Assert.assertTrue(entries.any { it is ToggleOfflineEntry })
|
||||
Assert.assertTrue(entries.any { it is ShareViaInvitationsEntry })
|
||||
Assert.assertTrue(entries.any { it is ManageAccessEntry })
|
||||
Assert.assertTrue(entries.any { it is SendFileEntry })
|
||||
Assert.assertTrue(entries.any { it is DownloadFileEntry })
|
||||
Assert.assertFalse(entries.any { it is MoveFileEntry })
|
||||
Assert.assertFalse(entries.any { it is RenameFileEntry })
|
||||
Assert.assertTrue(entries.any { it is FileInfoEntry })
|
||||
Assert.assertTrue(entries.any { it is ToggleTrashEntry })
|
||||
assertEquals(
|
||||
listOf(
|
||||
ToggleOfflineEntry::class,
|
||||
ShareViaInvitationsEntry::class,
|
||||
ManageAccessEntry::class,
|
||||
SendFileEntry::class,
|
||||
DownloadFileEntry::class,
|
||||
FileInfoEntry::class,
|
||||
ToggleTrashEntry::class,
|
||||
),
|
||||
entries.map { it.javaClass.kotlin }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `file options on photo share with album feature flag`() = runTest {
|
||||
// Given
|
||||
coEvery { getDriveLink.invoke(any<LinkId>(), any()) } returns flowOf(photoDriveLink.asSuccess)
|
||||
val featureFlagId = FeatureFlagId.driveAlbums(UserId("value"))
|
||||
coEvery { getFeatureFlagFlow(featureFlagId, any(), any()) } returns
|
||||
flowOf( FeatureFlag(featureFlagId, State.ENABLED))
|
||||
|
||||
// When
|
||||
val entries = fileOptionEntries()
|
||||
|
||||
// Then
|
||||
assertEquals(
|
||||
listOf(
|
||||
SetAsAlbumCoverEntry::class,
|
||||
RemoveFromAlbumFileEntry::class,
|
||||
ToggleOfflineEntry::class,
|
||||
ShareViaInvitationsEntry::class,
|
||||
ManageAccessEntry::class,
|
||||
SendFileEntry::class,
|
||||
DownloadFileEntry::class,
|
||||
FileInfoEntry::class,
|
||||
ToggleTrashEntry::class,
|
||||
),
|
||||
entries.map { it.javaClass.kotlin }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -345,10 +384,13 @@ class FileOrFolderOptionsViewModelTest {
|
||||
exportTo = exportTo,
|
||||
notifyActivityNotFound = notifyActivityNotFound,
|
||||
getFeatureFlagFlow = getFeatureFlagFlow,
|
||||
albumsFeatureFlag = AlbumsFeatureFlag(getFeatureFlagFlow, configurationProvider),
|
||||
leaveShare = leaveShare,
|
||||
configurationProvider = configurationProvider,
|
||||
broadcastMessages = broadcastMessages,
|
||||
openProtonDocumentInBrowser = openProtonDocumentInBrowser,
|
||||
updateAlbumCover = updateAlbumCover,
|
||||
removePhotosFromAlbum = removePhotosFromAlbum,
|
||||
)
|
||||
|
||||
private val fileLink = Link.File(
|
||||
@@ -368,7 +410,6 @@ class FileOrFolderOptionsViewModelTest {
|
||||
passphraseSignature = "signature",
|
||||
contentKeyPacket = "contentKeyPacket",
|
||||
contentKeyPacketSignature = null,
|
||||
isFavorite = false,
|
||||
attributes = Attributes(0),
|
||||
permissions = Permissions(0),
|
||||
state = Link.State.ACTIVE,
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Copyright (c) 2024 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import me.proton.android.drive.photos.domain.usecase.AddToAlbumInfo
|
||||
import me.proton.android.drive.photos.domain.usecase.RemovePhotosFromAlbum
|
||||
import me.proton.core.crypto.common.pgp.VerificationStatus
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.domain.entity.Attributes
|
||||
import me.proton.core.drive.base.domain.entity.Bytes
|
||||
import me.proton.core.drive.base.domain.entity.CryptoProperty
|
||||
import me.proton.core.drive.base.domain.entity.Permissions
|
||||
import me.proton.core.drive.base.domain.entity.TimestampS
|
||||
import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
import me.proton.core.drive.documentsprovider.domain.usecase.ExportToDownload
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.driveAlbums
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.AlbumsFeatureFlag
|
||||
import me.proton.core.drive.feature.flag.domain.usecase.GetFeatureFlagFlow
|
||||
import me.proton.core.drive.files.presentation.entry.CreateAlbumEntry
|
||||
import me.proton.core.drive.files.presentation.entry.DownloadEntry
|
||||
import me.proton.core.drive.files.presentation.entry.MoveEntry
|
||||
import me.proton.core.drive.files.presentation.entry.RemoveFromAlbumEntry
|
||||
import me.proton.core.drive.files.presentation.entry.TrashEntry
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.Link
|
||||
import me.proton.core.drive.link.domain.entity.SharingDetails
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
|
||||
import me.proton.core.drive.linkdownload.domain.entity.DownloadState
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.shareurl.base.domain.entity.ShareUrlId
|
||||
import me.proton.core.drive.trash.domain.usecase.SendToTrash
|
||||
import me.proton.core.drive.volume.domain.entity.VolumeId
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MultipleFileOrFolderOptionsViewModelTest {
|
||||
private val savedStateHandle = mockk<SavedStateHandle>()
|
||||
private val getSelectedDriveLinks = mockk<GetSelectedDriveLinks>()
|
||||
private val sendToTrash = mockk<SendToTrash>()
|
||||
private val exportToDownload = mockk<ExportToDownload>()
|
||||
private val deselectLinks = mockk<DeselectLinks>()
|
||||
private val addToAlbumInfo = mockk<AddToAlbumInfo>()
|
||||
private val getFeatureFlagFlow = mockk<GetFeatureFlagFlow>()
|
||||
private val configurationProvider = mockk<ConfigurationProvider>()
|
||||
private val removePhotosFromAlbum = mockk<RemovePhotosFromAlbum>()
|
||||
private val broadcastMessages = mockk<BroadcastMessages>()
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
coEvery { savedStateHandle.get<String>(any()) } returns "value"
|
||||
coEvery { getSelectedDriveLinks.invoke(any()) } returns flowOf()
|
||||
coEvery { getFeatureFlagFlow.invoke(any(), any(), any())} answers {
|
||||
val id: FeatureFlagId = arg(0)
|
||||
flowOf(FeatureFlag(id, State.NOT_FOUND))
|
||||
}
|
||||
coEvery { getFeatureFlagFlow.refreshAfterDuration} answers {
|
||||
{ false }
|
||||
}
|
||||
coEvery { configurationProvider.albumsFeatureFlag} returns true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `files options`() = runTest {
|
||||
// Given
|
||||
val files = listOf(fileDriveLink)
|
||||
|
||||
// When
|
||||
val entries = files.fileOptionEntries()
|
||||
|
||||
// Then
|
||||
assertEquals(
|
||||
listOf(
|
||||
DownloadEntry::class,
|
||||
MoveEntry::class,
|
||||
TrashEntry::class,
|
||||
),
|
||||
entries.map { it.javaClass.kotlin }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `photos options without album feature flag`() = runTest {
|
||||
// Given
|
||||
val files = listOf(photoDriveLink)
|
||||
|
||||
// When
|
||||
val entries = files.fileOptionEntries()
|
||||
|
||||
// Then
|
||||
assertEquals(
|
||||
listOf(
|
||||
DownloadEntry::class,
|
||||
TrashEntry::class,
|
||||
),
|
||||
entries.map { it.javaClass.kotlin }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `photos options with album feature flag `() = runTest {
|
||||
// Given
|
||||
val featureFlagId = driveAlbums(UserId("value"))
|
||||
coEvery { getFeatureFlagFlow(featureFlagId, any(), any()) } returns
|
||||
flowOf( FeatureFlag(featureFlagId, State.ENABLED))
|
||||
val files = listOf(photoDriveLink)
|
||||
|
||||
// When
|
||||
val entries = files.fileOptionEntries()
|
||||
|
||||
// Then
|
||||
assertEquals(
|
||||
listOf(
|
||||
RemoveFromAlbumEntry::class,
|
||||
CreateAlbumEntry::class,
|
||||
DownloadEntry::class,
|
||||
TrashEntry::class,
|
||||
),
|
||||
entries.map { it.javaClass.kotlin }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun List<DriveLink>.fileOptionEntries() =
|
||||
fileOrFolderOptionsViewModel().entries(
|
||||
driveLinks = this,
|
||||
runAction = {},
|
||||
navigateToMove = { _: SelectionId, _: FolderId? -> },
|
||||
navigateToCreateNewAlbum = { },
|
||||
dismiss = {},
|
||||
).filterNotNull().first()
|
||||
|
||||
private fun fileOrFolderOptionsViewModel() = MultipleFileOrFolderOptionsViewModel(
|
||||
appContext = ApplicationProvider.getApplicationContext(),
|
||||
savedStateHandle = savedStateHandle,
|
||||
getSelectedDriveLinks = getSelectedDriveLinks,
|
||||
sendToTrash = sendToTrash,
|
||||
exportToDownload = exportToDownload,
|
||||
deselectLinks = deselectLinks,
|
||||
addToAlbumInfo = addToAlbumInfo,
|
||||
removePhotosFromAlbum = removePhotosFromAlbum,
|
||||
getFeatureFlagFlow = getFeatureFlagFlow,
|
||||
albumsFeatureFlag = AlbumsFeatureFlag(getFeatureFlagFlow, configurationProvider),
|
||||
broadcastMessages = broadcastMessages,
|
||||
configurationProvider = configurationProvider,
|
||||
)
|
||||
|
||||
private val fileLink = Link.File(
|
||||
id = FileId(ShareId(UserId("USER_ID"), "SHARE_ID"), "ID"),
|
||||
parentId = FolderId(ShareId(UserId("USER_ID"), "SHARE_ID"), "PARENT_ID"),
|
||||
activeRevisionId = "revision",
|
||||
size = Bytes(123),
|
||||
lastModified = TimestampS(System.currentTimeMillis() / 1000),
|
||||
mimeType = "video/mp4",
|
||||
numberOfAccesses = 2,
|
||||
isShared = true,
|
||||
uploadedBy = "m4@proton.black",
|
||||
hasThumbnail = false,
|
||||
name = "Link name",
|
||||
key = "key",
|
||||
passphrase = "passphrase",
|
||||
passphraseSignature = "signature",
|
||||
contentKeyPacket = "contentKeyPacket",
|
||||
contentKeyPacketSignature = null,
|
||||
attributes = Attributes(0),
|
||||
permissions = Permissions(0),
|
||||
state = Link.State.ACTIVE,
|
||||
nameSignatureEmail = "",
|
||||
hash = "",
|
||||
expirationTime = null,
|
||||
nodeKey = "",
|
||||
nodePassphrase = "",
|
||||
nodePassphraseSignature = "",
|
||||
signatureEmail = "",
|
||||
creationTime = TimestampS(0),
|
||||
trashedTime = null,
|
||||
shareUrlExpirationTime = null,
|
||||
xAttr = null,
|
||||
sharingDetails = SharingDetails(
|
||||
shareId = ShareId(UserId("USER_ID"), "SHARING_ID"),
|
||||
shareUrlId = ShareUrlId(ShareId(UserId("USER_ID"), "SHARING_ID"), "")
|
||||
),
|
||||
photoCaptureTime = null,
|
||||
photoContentHash = null,
|
||||
mainPhotoLinkId = null,
|
||||
)
|
||||
|
||||
private val fileDriveLink = DriveLink.File(
|
||||
link = fileLink,
|
||||
volumeId = VolumeId("VOLUME_ID"),
|
||||
isMarkedAsOffline = true,
|
||||
isAnyAncestorMarkedAsOffline = false,
|
||||
downloadState = DownloadState.Downloaded(),
|
||||
trashState = null,
|
||||
cryptoName = CryptoProperty.Decrypted("Link name", VerificationStatus.Success),
|
||||
cryptoXAttr = CryptoProperty.Decrypted(
|
||||
"""{"Common":{"ModificationTime":"2023-07-27T13:52:23.636Z"},"Media":{"Duration":46}}""",
|
||||
VerificationStatus.Success,
|
||||
),
|
||||
shareInvitationCount = null,
|
||||
shareMemberCount = null,
|
||||
shareUser = null,
|
||||
sharePermissions = Permissions.admin
|
||||
)
|
||||
|
||||
private val photoDriveLink = fileDriveLink.copy(
|
||||
link = fileLink.copy(
|
||||
photoCaptureTime = TimestampS(0),
|
||||
photoContentHash = "",
|
||||
mainPhotoLinkId = "MAIN_ID"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -18,17 +18,32 @@
|
||||
|
||||
package me.proton.android.drive.ui.robot
|
||||
|
||||
import me.proton.android.drive.ui.extension.withItemType
|
||||
import me.proton.android.drive.ui.extension.withLayoutType
|
||||
import me.proton.android.drive.ui.extension.withLinkName
|
||||
import me.proton.android.drive.ui.screen.AlbumScreenTestTag
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
import me.proton.core.drive.files.presentation.extension.ItemType
|
||||
import me.proton.core.drive.files.presentation.extension.LayoutType
|
||||
import me.proton.test.fusion.Fusion.node
|
||||
import me.proton.test.fusion.FusionConfig.targetContext
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
object AlbumRobot : LinksRobot, NavigationBarRobot {
|
||||
private val albumScreen get() = node.withTag(AlbumScreenTestTag.screen)
|
||||
private val moreButton get() = node.withContentDescription(I18N.string.common_more)
|
||||
private val addButton get() = node.withText(I18N.string.common_add_action)
|
||||
|
||||
fun clickOnMoreButton() = moreButton.clickTo(AlbumOptionsRobot)
|
||||
|
||||
fun clickOnAdd() = addButton.clickTo(PickerPhotosAndAlbumsRobot)
|
||||
|
||||
fun clickOnPhoto(name: String) =
|
||||
photoWithName(name).clickTo(PreviewRobot)
|
||||
|
||||
private fun photoWithName(name: String) = linkWithName(name)
|
||||
.withItemType(ItemType.File)
|
||||
.withLayoutType(LayoutType.Grid)
|
||||
|
||||
fun assertAlbumNameIsDisplayed(name: String) = node.withText(name)
|
||||
.await { assertIsDisplayed() }
|
||||
|
||||
@@ -39,6 +54,11 @@ object AlbumRobot : LinksRobot, NavigationBarRobot {
|
||||
).format(count)
|
||||
).await { assertIsDisplayed() }
|
||||
|
||||
fun assertCoverAlbum(name: String) = node.withLinkName(name)
|
||||
.withItemType(ItemType.File)
|
||||
.withLayoutType(LayoutType.Cover)
|
||||
.await { assertIsDisplayed() }
|
||||
|
||||
override fun robotDisplayed() {
|
||||
albumScreen.await { assertIsDisplayed() }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
|
||||
package me.proton.android.drive.ui.robot
|
||||
|
||||
import me.proton.android.drive.photos.presentation.extension.albumDetails
|
||||
import me.proton.android.drive.ui.test.AbstractBaseTest.Companion.targetContext
|
||||
import me.proton.core.drive.base.domain.entity.TimestampS
|
||||
import me.proton.test.fusion.Fusion.node
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
@@ -35,9 +38,8 @@ object AlbumsTabRobot :
|
||||
private val filterSharedByMe get() = node.withText(I18N.string.albums_filter_shared_by_me)
|
||||
private val filterSharedWithMe get() = node.withText(I18N.string.albums_filter_shared_with_me)
|
||||
|
||||
private val emptyTitle get() = node.withText(I18N.string.photos_empty_title)
|
||||
private val emptyDescription get() = node.withText(I18N.string.photos_empty_description)
|
||||
|
||||
private val emptyTitle get() = node.withText(I18N.string.albums_empty_albums_list_screen_title)
|
||||
private val emptyDescription get() = node.withText(I18N.string.albums_empty_albums_list_screen_description)
|
||||
|
||||
private val plusButton get() = node.withContentDescription(I18N.string.content_description_albums_new)
|
||||
|
||||
@@ -64,9 +66,23 @@ object AlbumsTabRobot :
|
||||
node.withText(name).await { assertIsDisplayed() }
|
||||
}
|
||||
|
||||
fun assertAlbumIsDisplayed(name: String, size:Long) {
|
||||
fun assertAlbumIsDisplayed(
|
||||
name: String,
|
||||
photoCount: Long,
|
||||
isShared: Boolean = false,
|
||||
creationTime: TimestampS? = null,
|
||||
) {
|
||||
node.withText(name)
|
||||
.hasSibling(node.withText(size.toString()))
|
||||
.hasSibling(
|
||||
node.withText(
|
||||
albumDetails(
|
||||
appContext = targetContext,
|
||||
photoCount = photoCount,
|
||||
isShared = isShared,
|
||||
creationTime = creationTime,
|
||||
)
|
||||
)
|
||||
)
|
||||
.await { assertIsDisplayed() }
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ object CreateAlbumTabRobot : Robot {
|
||||
.hasDescendant(node.withText(I18N.string.albums_new_album_name_hint))
|
||||
|
||||
private val doneButton get() = node.withText(I18N.string.common_done_action)
|
||||
private val addButton get() = node.withText(I18N.string.common_add_action)
|
||||
|
||||
fun typeName(text: String) = apply { newAlbumHint.typeText(text) }
|
||||
|
||||
@@ -41,6 +42,8 @@ object CreateAlbumTabRobot : Robot {
|
||||
|
||||
fun <T : Robot> clickOnDone(goesTo: T) = doneButton.clickTo(goesTo)
|
||||
|
||||
fun clickOnAdd() = addButton.clickTo(PickerPhotosAndAlbumsRobot)
|
||||
|
||||
override fun robotDisplayed() {
|
||||
newAlbumHint.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ object FileFolderOptionsRobot : Robot {
|
||||
private val removeAvailableOfflineButton get() = node
|
||||
.withText(I18N.string.common_remove_from_offline_available_action)
|
||||
private val manageLinkButton get() = node.withText(I18N.string.common_manage_link_action)
|
||||
private val setAsAlbumCoverButton get() = node.withText(I18N.string.common_set_as_album_cover_action)
|
||||
private val shareButton get() = node.withText(I18N.string.common_share)
|
||||
private val manageAccessButton get() = node.withText(I18N.string.common_manage_access_action)
|
||||
private val removeMeButton get() = node.withText(I18N.string.files_remove_me_action)
|
||||
@@ -42,6 +43,7 @@ object FileFolderOptionsRobot : Robot {
|
||||
private val stopSharingButton get() = node.withText(I18N.string.common_stop_sharing_action)
|
||||
private val deletePermanentlyButton get() = node.withText(I18N.string.common_delete_permanently_action)
|
||||
private val deleteConfirm get() = node.withText(I18N.string.title_files_confirm_deletion)
|
||||
private val removeFromAlbum get() = node.withText(I18N.string.common_remove_from_album_action)
|
||||
|
||||
fun clickMove() = MoveToFolderRobot.apply {
|
||||
moveButton.scrollTo().click()
|
||||
@@ -52,6 +54,12 @@ object FileFolderOptionsRobot : Robot {
|
||||
fun clickManageLink() = ShareRobot.apply {
|
||||
manageLinkButton.scrollTo().click()
|
||||
}
|
||||
fun clickSetAsAlbumCover() = ShareRobot.apply {
|
||||
setAsAlbumCoverButton.scrollTo().click()
|
||||
}
|
||||
fun clickRemoveFromAlbum() = AlbumRobot.apply {
|
||||
removeFromAlbum.scrollTo().click()
|
||||
}
|
||||
fun clickShare() = ShareUserRobot.apply {
|
||||
shareButton.scrollTo().click()
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@ interface LinksRobot : PullToRefreshRobot, Robot {
|
||||
fun clickOnAlbum(name: String, layoutType: LayoutType = LayoutType.Grid) =
|
||||
clickOnItem(name, layoutType, ItemType.Album, AlbumRobot)
|
||||
|
||||
fun <T : Robot> clickOnAlbum(name: String, goesTo: T, layoutType: LayoutType = LayoutType.Grid) =
|
||||
clickOnItem(name, layoutType, ItemType.Album, goesTo)
|
||||
|
||||
fun <T : Robot> clickOnUndo(after: Duration = 0.seconds, goesTo: T): T = runBlocking {
|
||||
delay(after)
|
||||
node.withText(I18N.string.common_undo_action).clickTo(goesTo)
|
||||
|
||||
+6
-2
@@ -19,18 +19,22 @@
|
||||
package me.proton.android.drive.ui.robot
|
||||
|
||||
import me.proton.android.drive.ui.dialog.MultipleFileFolderOptionsDialogTestTag
|
||||
import me.proton.core.drive.i18n.R
|
||||
import me.proton.test.fusion.Fusion.node
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
object MultipleFileFolderOptionsRobot : Robot {
|
||||
private val multipleFileFolderOptionsScreen get() = node.withTag(
|
||||
MultipleFileFolderOptionsDialogTestTag.fileOrFolderOptions
|
||||
)
|
||||
private val createAlbumButton get() = node.withText(R.string.common_create_album_action)
|
||||
private val createAlbumButton get() = node.withText(I18N.string.common_create_album_action)
|
||||
private val removeFromAlbum get() = node.withText(I18N.string.common_remove_from_album_action)
|
||||
|
||||
fun clickOnCreateAlbum() = CreateAlbumTabRobot.apply {
|
||||
createAlbumButton.scrollTo().click()
|
||||
}
|
||||
fun clickRemoveFromAlbum() = AlbumRobot.apply {
|
||||
removeFromAlbum.scrollTo().click()
|
||||
}
|
||||
|
||||
override fun robotDisplayed() {
|
||||
multipleFileFolderOptionsScreen.await { assertIsDisplayed() }
|
||||
|
||||
@@ -48,6 +48,8 @@ object PhotosTabRobot :
|
||||
HomeRobot,
|
||||
LinksRobot,
|
||||
NavigationBarRobot {
|
||||
|
||||
private val photosContent get() = node.withTag(PhotosTestTag.content)
|
||||
private val enableBackupButton
|
||||
get() = allNodes.withText(I18N.string.photos_permissions_action).onFirst()
|
||||
private val enableErrorBackupButton
|
||||
@@ -92,6 +94,10 @@ object PhotosTabRobot :
|
||||
|
||||
fun dismissQuotaBanner() = apply { closeQuotaBannerButton.click() }
|
||||
|
||||
fun scrollToPhoto(imageName: ImageName): PhotosTabRobot = apply {
|
||||
photosContent.scrollTo(photoWithName(imageName.fileName))
|
||||
}
|
||||
|
||||
fun longClickOnPhoto(fileName: String) =
|
||||
photoWithName(fileName).longClickTo(this)
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.robot
|
||||
|
||||
import me.proton.android.drive.ui.extension.withItemType
|
||||
import me.proton.android.drive.ui.extension.withLayoutType
|
||||
import me.proton.android.drive.ui.screen.AlbumScreenTestTag
|
||||
import me.proton.core.drive.files.presentation.extension.ItemType
|
||||
import me.proton.core.drive.files.presentation.extension.LayoutType
|
||||
import me.proton.test.fusion.Fusion.node
|
||||
|
||||
object PickerAlbumRobot : PickerPhotosRobot, LinksRobot, NavigationBarRobot {
|
||||
private val albumScreen get() = node.withTag(AlbumScreenTestTag.screen)
|
||||
|
||||
fun clickOnPhoto(name: String) = photoWithName(name).clickTo(PickerAlbumRobot)
|
||||
|
||||
private fun photoWithName(name: String) = linkWithName(name)
|
||||
.withItemType(ItemType.File)
|
||||
.withLayoutType(LayoutType.Grid)
|
||||
|
||||
override fun robotDisplayed() {
|
||||
albumScreen.await { assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.robot
|
||||
|
||||
import me.proton.android.drive.ui.extension.withItemType
|
||||
import me.proton.android.drive.ui.extension.withLayoutType
|
||||
import me.proton.android.drive.ui.screen.PickerPhotosAndAlbumsScreenTestTag
|
||||
import me.proton.core.drive.files.presentation.extension.ItemType
|
||||
import me.proton.core.drive.files.presentation.extension.LayoutType
|
||||
import me.proton.test.fusion.Fusion.node
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
object PickerPhotosAndAlbumsRobot : PickerPhotosRobot, LinksRobot, NavigationBarRobot {
|
||||
private val screen get() = node.withTag(PickerPhotosAndAlbumsScreenTestTag.screen)
|
||||
private val photosTab get() = node.withText(I18N.string.photos_title)
|
||||
private val albumsTab get() = node.withText(I18N.string.albums_title)
|
||||
|
||||
fun clickOnPhotosTab() = photosTab.clickTo(PickerPhotosAndAlbumsRobot)
|
||||
fun clickOnAlbumsTab() = albumsTab.clickTo(PickerPhotosAndAlbumsRobot)
|
||||
fun clickOnPhoto(name: String) = photoWithName(name).clickTo(PickerPhotosAndAlbumsRobot)
|
||||
|
||||
private fun photoWithName(name: String) = linkWithName(name)
|
||||
.withItemType(ItemType.File)
|
||||
.withLayoutType(LayoutType.Grid)
|
||||
|
||||
override fun robotDisplayed() {
|
||||
screen.await { assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.robot
|
||||
|
||||
import me.proton.android.drive.ui.screen.PickerPhotosAndAlbumsScreenTestTag
|
||||
import me.proton.core.drive.i18n.R
|
||||
import me.proton.test.fusion.Fusion.node
|
||||
|
||||
interface PickerPhotosRobot : Robot {
|
||||
|
||||
private val resetButton get() = node.withTag(PickerPhotosAndAlbumsScreenTestTag.resetButton)
|
||||
private val addToAlbumButton get() = node.withTag(PickerPhotosAndAlbumsScreenTestTag.addToAlbumButton)
|
||||
|
||||
fun <T : Robot> clickOnReset(goesTo: T) = resetButton.clickTo(goesTo)
|
||||
fun <T : Robot> clickOnAddToAlbum(goesTo: T) = addToAlbumButton.clickTo(goesTo)
|
||||
|
||||
fun assertTotalPhotosToAddToAlbum(count: Int) =
|
||||
if (count == 0) {
|
||||
node.withText(R.string.albums_add_zero_to_album_button)
|
||||
} else {
|
||||
node.withPluralTextResource(R.plurals.albums_add_non_zero_to_album_button, count)
|
||||
}.await { assertIsDisplayed() }
|
||||
}
|
||||
@@ -25,13 +25,17 @@ import me.proton.android.drive.ui.robot.AlbumRobot
|
||||
import me.proton.android.drive.ui.robot.AlbumsTabRobot
|
||||
import me.proton.android.drive.ui.robot.ConfirmDeleteAlbumRobot
|
||||
import me.proton.android.drive.ui.robot.PhotosTabRobot
|
||||
import me.proton.android.drive.ui.robot.PickerAlbumRobot
|
||||
import me.proton.android.drive.ui.test.BaseTest
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_ALBUMS
|
||||
import me.proton.core.drive.files.presentation.extension.ItemType
|
||||
import me.proton.core.drive.files.presentation.extension.LayoutType
|
||||
import me.proton.core.test.rule.annotation.PrepareUser
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
|
||||
@HiltAndroidTest
|
||||
class AlbumFlowTest : BaseTest() {
|
||||
@@ -122,4 +126,83 @@ class AlbumFlowTest : BaseTest() {
|
||||
assertAlbumIsNotDisplayed(albumName)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@PrepareUser(withTag = "main", loginBefore = true)
|
||||
@PrepareUser(withTag = "sharingUser")
|
||||
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
|
||||
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
|
||||
fun setAsAlbumCover() {
|
||||
val albumName = "album-for-photos"
|
||||
val image = "activeTaggedFileInAlbum-3.jpg"
|
||||
AlbumsTabRobot
|
||||
.clickOnAlbum(albumName)
|
||||
.clickOnPhoto(image)
|
||||
.clickOnContextualButton()
|
||||
.clickSetAsAlbumCover()
|
||||
.verify {
|
||||
nodeWithTextDisplayed(I18N.string.albums_set_album_as_cover_success)
|
||||
}
|
||||
.clickBack(AlbumRobot)
|
||||
.verify {
|
||||
assertCoverAlbum(image)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@PrepareUser(withTag = "main", loginBefore = true)
|
||||
@PrepareUser(withTag = "sharingUser")
|
||||
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
|
||||
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
|
||||
fun addPhotosIntoAlbum() {
|
||||
val albumName = "album-for-photos-uploaded-by-other-user"
|
||||
val pickedAlbumName = "album-for-photos-in-stream"
|
||||
val photoInStream = "activeTaggedFileInStream-2.jpg"
|
||||
val photoInAlbum = "activeFileInStreamAndAlbum.jpg"
|
||||
AlbumsTabRobot
|
||||
.clickOnAlbum(albumName)
|
||||
.clickOnAdd()
|
||||
.clickOnPhoto(photoInStream)
|
||||
.verify {
|
||||
assertTotalPhotosToAddToAlbum(1)
|
||||
}
|
||||
.clickOnAlbumsTab()
|
||||
.clickOnAlbum(pickedAlbumName, PickerAlbumRobot)
|
||||
.clickOnPhoto(photoInAlbum)
|
||||
.verify {
|
||||
assertTotalPhotosToAddToAlbum(2)
|
||||
}
|
||||
.clickOnAddToAlbum(AlbumRobot)
|
||||
.verify {
|
||||
assertAlbumNameIsDisplayed(albumName)
|
||||
assertItemsInAlbum(3)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@PrepareUser(withTag = "main", loginBefore = true)
|
||||
@PrepareUser(withTag = "sharingUser")
|
||||
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
|
||||
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
|
||||
fun removePhotosFromAlbum() {
|
||||
val albumName = "album-for-photos"
|
||||
val photo1 = "activeFileInAlbum.jpg"
|
||||
val photo2 = "activeTaggedFileInAlbum-3.jpg"
|
||||
val photo3 = "activeTaggedFileInAlbum-2.jpg"
|
||||
AlbumsTabRobot
|
||||
.clickOnAlbum(albumName)
|
||||
.longClickOnItem(photo1)
|
||||
.clickOptions()
|
||||
.clickRemoveFromAlbum()
|
||||
.verify {
|
||||
assertItemsInAlbum(4)
|
||||
}
|
||||
.longClickOnItem(photo2)
|
||||
.clickOnItem(photo3, LayoutType.Grid, ItemType.File, AlbumRobot)
|
||||
.clickMultipleOptions()
|
||||
.clickRemoveFromAlbum()
|
||||
.verify {
|
||||
assertItemsInAlbum(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -22,7 +22,10 @@ import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import me.proton.android.drive.ui.annotation.FeatureFlag
|
||||
import me.proton.android.drive.ui.annotation.Scenario
|
||||
import me.proton.android.drive.ui.robot.AlbumRobot
|
||||
import me.proton.android.drive.ui.robot.CreateAlbumTabRobot
|
||||
import me.proton.android.drive.ui.robot.PhotosTabRobot
|
||||
import me.proton.android.drive.ui.robot.PickerAlbumRobot
|
||||
import me.proton.android.drive.ui.robot.PickerPhotosAndAlbumsRobot
|
||||
import me.proton.android.drive.ui.test.BaseTest
|
||||
import me.proton.android.drive.utils.getRandomString
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
|
||||
@@ -100,4 +103,45 @@ class CreatingAlbumFlowTest : BaseTest() {
|
||||
assertAlbumIsDisplayed(albumName, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@PrepareUser(withTag = "main", loginBefore = true)
|
||||
@PrepareUser(withTag = "sharingUser")
|
||||
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
|
||||
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
|
||||
fun inEmptyAlbumAddPhotosThenCreate() {
|
||||
val albumName = "new-album"
|
||||
val album = "album-for-photos-in-stream"
|
||||
val photo1 = "activeTaggedFileInStream-2.jpg"
|
||||
val photo2 = "activeFileInStreamAndAlbum.jpg"
|
||||
PhotosTabRobot
|
||||
.clickOnAlbumsTab()
|
||||
.clickPlusButton()
|
||||
.typeName(albumName)
|
||||
.clickOnAdd()
|
||||
.verify {
|
||||
assertTotalPhotosToAddToAlbum(0)
|
||||
}
|
||||
.clickOnPhoto(photo1)
|
||||
.clickOnAlbumsTab()
|
||||
.clickOnAlbum(album, PickerAlbumRobot)
|
||||
.clickOnPhoto(photo2)
|
||||
.verify {
|
||||
assertTotalPhotosToAddToAlbum(2)
|
||||
}
|
||||
.clickOnReset(PickerAlbumRobot)
|
||||
.verify {
|
||||
assertTotalPhotosToAddToAlbum(0)
|
||||
}
|
||||
.clickOnPhoto(photo2)
|
||||
.clickBack(PickerPhotosAndAlbumsRobot)
|
||||
.clickOnPhotosTab()
|
||||
.clickOnPhoto(photo1)
|
||||
.clickOnAddToAlbum(CreateAlbumTabRobot)
|
||||
.clickOnDone(AlbumRobot)
|
||||
.verify {
|
||||
assertAlbumNameIsDisplayed(albumName)
|
||||
assertItemsInAlbum(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2024 Proton AG.
|
||||
* This file is part of Proton Drive.
|
||||
*
|
||||
* Proton Drive 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.
|
||||
*
|
||||
* Proton Drive 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 Proton Drive. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.drive.ui.test.flow.preview
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import me.proton.android.drive.ui.annotation.FeatureFlag
|
||||
import me.proton.android.drive.ui.annotation.Scenario
|
||||
import me.proton.android.drive.ui.data.ImageName
|
||||
import me.proton.android.drive.ui.robot.PhotosTabRobot
|
||||
import me.proton.android.drive.ui.test.BaseTest
|
||||
import me.proton.android.drive.ui.annotation.SmokeTest
|
||||
import me.proton.android.drive.ui.robot.AlbumRobot
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_ALBUMS
|
||||
import me.proton.core.test.rule.annotation.PrepareUser
|
||||
import me.proton.test.fusion.FusionConfig
|
||||
import me.proton.test.fusion.ui.common.enums.SwipeDirection
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@HiltAndroidTest
|
||||
class PreviewAlbumFlowTest : BaseTest() {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
PhotosTabRobot.waitUntilLoaded()
|
||||
FusionConfig.Compose.waitTimeout.set(30.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
@PrepareUser(withTag = "main", loginBefore = true)
|
||||
@PrepareUser(withTag = "sharingUser")
|
||||
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
|
||||
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
|
||||
fun previewAlbumTest() {
|
||||
|
||||
val firstImage = "activeTaggedFileInAlbum-3.jpg"
|
||||
val secondImage = "activeTaggedFileInAlbum-2.jpg"
|
||||
val thirdImage = "activeTaggedFileInAlbum-1.jpg"
|
||||
|
||||
PhotosTabRobot
|
||||
.clickOnAlbumsTab()
|
||||
.clickOnAlbum("album-for-photos")
|
||||
.clickOnPhoto(firstImage)
|
||||
.verify {
|
||||
assertPreviewIsDisplayed(firstImage)
|
||||
}
|
||||
.swipePage(SwipeDirection.Left)
|
||||
.verify {
|
||||
assertPreviewIsDisplayed(secondImage)
|
||||
}
|
||||
.swipePage(SwipeDirection.Left)
|
||||
.verify {
|
||||
assertPreviewIsDisplayed(thirdImage)
|
||||
}
|
||||
.clickBack(AlbumRobot)
|
||||
.clickOnPhoto(thirdImage)
|
||||
.verify {
|
||||
assertPreviewIsDisplayed(thirdImage)
|
||||
}
|
||||
.swipePage(SwipeDirection.Right)
|
||||
.verify {
|
||||
assertPreviewIsDisplayed(secondImage)
|
||||
}
|
||||
.swipePage(SwipeDirection.Right)
|
||||
.verify {
|
||||
assertPreviewIsDisplayed(firstImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -46,9 +46,9 @@ class PreviewPhotosFlowTest : BaseTest() {
|
||||
@SmokeTest
|
||||
fun previewPhotoTest() {
|
||||
|
||||
val firstImage = ImageName.Main
|
||||
val secondImage = ImageName.Now
|
||||
val thirdImage = ImageName.Yesterday
|
||||
val firstImage = ImageName.Yesterday
|
||||
val secondImage = ImageName.LastWeek
|
||||
val thirdImage = ImageName.LastMonth
|
||||
|
||||
PhotosTabRobot
|
||||
.clickOnPhoto(firstImage)
|
||||
@@ -64,6 +64,7 @@ class PreviewPhotosFlowTest : BaseTest() {
|
||||
assertPreviewIsDisplayed(thirdImage.fileName)
|
||||
}
|
||||
.clickBack(PhotosTabRobot)
|
||||
.scrollToPhoto(thirdImage)
|
||||
.clickOnPhoto(thirdImage)
|
||||
.verify {
|
||||
assertPreviewIsDisplayed(thirdImage.fileName)
|
||||
|
||||
+1
@@ -54,6 +54,7 @@ class DeleteSharedLinkManageAccessFlowTest : BaseTest() {
|
||||
robotDisplayed()
|
||||
}
|
||||
.clickBack(FilesTabRobot)
|
||||
.scrollToItemWithName(file)
|
||||
.verify { itemIsDisplayed(file, isSharedByLink = false) }
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -19,12 +19,15 @@
|
||||
package me.proton.android.drive.ui.test.flow.trash
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import me.proton.android.drive.ui.annotation.FeatureFlag
|
||||
import me.proton.android.drive.ui.annotation.Scenario
|
||||
import me.proton.android.drive.ui.data.ImageName
|
||||
import me.proton.android.drive.ui.robot.FilesTabRobot
|
||||
import me.proton.android.drive.ui.robot.PhotosTabRobot
|
||||
import me.proton.android.drive.ui.robot.TrashRobot
|
||||
import me.proton.android.drive.ui.test.BaseTest
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlag.State.ENABLED
|
||||
import me.proton.core.drive.feature.flag.domain.entity.FeatureFlagId.Companion.DRIVE_ALBUMS
|
||||
import me.proton.core.test.rule.annotation.PrepareUser
|
||||
import org.junit.Test
|
||||
|
||||
@@ -108,6 +111,32 @@ class DeletePermanentlyTests : BaseTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@PrepareUser(withTag = "main", loginBefore = true)
|
||||
@PrepareUser(withTag = "sharingUser")
|
||||
@Scenario(forTag = "main", value = 10, sharedWithUserTag = "sharingUser")
|
||||
@FeatureFlag(DRIVE_ALBUMS, ENABLED)
|
||||
fun emptyTrashPhotoDeleteAllItems() {
|
||||
val itemsInTrash = arrayOf(
|
||||
"trashedFileInAlbumByOtherUser.jpg",
|
||||
"trashedFileInAlbum.jpg",
|
||||
"trashedFileInStreamAndInAlbum.jpg",
|
||||
)
|
||||
|
||||
PhotosTabRobot
|
||||
.openSidebarBySwipe()
|
||||
.clickTrash()
|
||||
.verify {
|
||||
robotDisplayed()
|
||||
}
|
||||
.openMoreOptions()
|
||||
.clickEmptyTrash()
|
||||
.confirmEmptyTrash()
|
||||
.verify {
|
||||
itemIsNotDisplayed(*itemsInTrash)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@PrepareUser(loginBefore = true)
|
||||
@Scenario(forTag = "main", value = 2, isPhotos = true)
|
||||
|
||||
@@ -22,7 +22,7 @@ object Config {
|
||||
const val minSdk = 26
|
||||
const val targetSdk = 34
|
||||
const val testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
const val versionName = "2.18.0"
|
||||
const val versionName = "2.19.0"
|
||||
const val archivesBaseName = "ProtonDrive-$versionName"
|
||||
val supportedResourceConfigurations = listOf(
|
||||
"b+es+419",
|
||||
|
||||
+4
-4
@@ -58,12 +58,12 @@ class BackupPermissionsManagerImpl @Inject constructor(
|
||||
private val _permissions = MutableStateFlow(checkForBackupPermissions())
|
||||
override val backupPermissions: Flow<BackupPermissions> = _permissions
|
||||
|
||||
override fun getBackupPermissions(refresh: Boolean): BackupPermissions =
|
||||
override fun getBackupPermissions(refresh: Boolean): BackupPermissions {
|
||||
if (refresh) {
|
||||
checkForBackupPermissions()
|
||||
} else {
|
||||
_permissions.value
|
||||
_permissions.value = checkForBackupPermissions()
|
||||
}
|
||||
return _permissions.value
|
||||
}
|
||||
|
||||
override fun onPermissionChanged(permissions: BackupPermissions) {
|
||||
_permissions.value = permissions
|
||||
|
||||
@@ -150,6 +150,8 @@ object Dto {
|
||||
const val PENDING_HASHES = "PendingHashes"
|
||||
const val PERMISSIONS = "Permissions"
|
||||
const val PHOTO = "Photo"
|
||||
const val PHOTO_DATA = "PhotoData"
|
||||
const val PHOTO_PROPERTIES = "PhotoProperties"
|
||||
const val PHOTOS = "Photos"
|
||||
const val PHOTO_COUNT = "PhotoCount"
|
||||
const val PUBLIC_URL = "PublicUrl"
|
||||
|
||||
@@ -21,6 +21,7 @@ object Column {
|
||||
const val ADDED_TIME = "added_time"
|
||||
const val ADDRESS_ID = "address_id"
|
||||
const val ALBUM_ID = "album_id"
|
||||
const val ALBUM_SHARE_ID = "album_share_id"
|
||||
const val ANCHOR_ID = "anchor_id"
|
||||
const val ATTEMPTS = "attempts"
|
||||
const val ATTRIBUTES = "attributes"
|
||||
@@ -141,6 +142,7 @@ object Column {
|
||||
const val SYNC_STATE = "sync_state"
|
||||
const val SYNC_TIME = "sync_time"
|
||||
const val TAG = "tag"
|
||||
const val TAGS_DATA = "tags_data"
|
||||
const val THEME_STYLE = "theme_style"
|
||||
const val THUMBNAIL = "thumbnail"
|
||||
const val THUMBNAIL_ID_DEFAULT = "thumbnail_id_default"
|
||||
|
||||
@@ -35,6 +35,7 @@ object LogTag {
|
||||
const val SHARING = "$DEFAULT.sharing"
|
||||
const val PAGING = "$DEFAULT.paging"
|
||||
const val BACKUP = "$DEFAULT.backup"
|
||||
const val ALBUM = "$DEFAULT.album"
|
||||
const val PHOTO = "$DEFAULT.photo"
|
||||
const val TELEMETRY = "$DEFAULT.telemetry"
|
||||
const val UPLOAD = "$DEFAULT.upload"
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ interface ConfigurationProvider {
|
||||
val cacheInternalStorageLimit: Bytes get() = 512.MiB
|
||||
val albumsFeatureFlag: Boolean get() = false
|
||||
val minimumAlbumListingFetchInterval: Duration get() = 5.days
|
||||
val addToAlbumMaxApiDataSize: Int get() = 10
|
||||
val addToRemoveFromAlbumMaxApiDataSize: Int get() = 10
|
||||
|
||||
data class Thumbnail(
|
||||
val maxWidth: Int,
|
||||
|
||||
+18
-1
@@ -47,6 +47,23 @@ fun IllustratedMessage(
|
||||
@StringRes titleResId: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes descriptionResId: Int? = null,
|
||||
) {
|
||||
IllustratedMessage(
|
||||
imageContent = {
|
||||
Image(painter = painterResource(id = imageResId), contentDescription = null)
|
||||
},
|
||||
titleResId = titleResId,
|
||||
modifier = modifier,
|
||||
descriptionResId = descriptionResId,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IllustratedMessage(
|
||||
imageContent: @Composable () -> Unit,
|
||||
@StringRes titleResId: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes descriptionResId: Int? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
@@ -55,7 +72,7 @@ fun IllustratedMessage(
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(painter = painterResource(id = imageResId), contentDescription = null)
|
||||
imageContent()
|
||||
Text(
|
||||
text = stringResource(id = titleResId),
|
||||
style = ProtonTheme.typography.headlineNorm.copy(textAlign = TextAlign.Center),
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Core.
|
||||
*
|
||||
* Proton Core 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.
|
||||
*
|
||||
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.drive.base.presentation.component
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ButtonColors
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.proton.core.compose.component.ProtonButton
|
||||
import me.proton.core.compose.component.protonButtonColors
|
||||
import me.proton.core.compose.component.protonElevation
|
||||
import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.defaultStrongNorm
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
import me.proton.core.presentation.R as CorePresentation
|
||||
|
||||
@Composable
|
||||
fun ProtonIconTextButton(
|
||||
iconPainter: Painter,
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
contained: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
colors: ButtonColors = ButtonDefaults.protonButtonColors(
|
||||
backgroundColor = ProtonTheme.colors.interactionWeakNorm,
|
||||
contentColor = ProtonTheme.colors.textNorm,
|
||||
disabledBackgroundColor = ProtonTheme.colors.interactionWeakDisabled,
|
||||
disabledContentColor = ProtonTheme.colors.textDisabled,
|
||||
loading = loading,
|
||||
),
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ProtonButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = ProtonDimens.DefaultButtonMinHeight),
|
||||
enabled = enabled,
|
||||
loading = loading,
|
||||
contained = contained,
|
||||
interactionSource = interactionSource,
|
||||
elevation = ButtonDefaults.protonElevation(),
|
||||
shape = RoundedCornerShape(ProtonDimens.DefaultSpacing),
|
||||
border = null,
|
||||
colors = colors,
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
content = {
|
||||
IconTextContent(
|
||||
iconPainter = iconPainter,
|
||||
text = title,
|
||||
enabled = enabled,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconTextContent(
|
||||
iconPainter: Painter,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = iconPainter,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ProtonDimens.ExtraSmallSpacing))
|
||||
Text(
|
||||
text = text,
|
||||
style = ProtonTheme.typography.defaultStrongNorm(enabled),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProtonIconTextButtonLightPreview() {
|
||||
ProtonTheme(isDark = false) {
|
||||
ProtonIconTextButtonPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProtonIconTextButtonDarkPreview() {
|
||||
ProtonTheme(isDark = true) {
|
||||
ProtonIconTextButtonPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProtonIconTextButtonPreview() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = ProtonTheme.colors.backgroundNorm,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(vertical = 48.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
PreviewHelper(enabled = true)
|
||||
PreviewHelper(enabled = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewHelper(
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
ProtonIconTextButton(
|
||||
iconPainter = painterResource(CorePresentation.drawable.ic_proton_plus_circle_filled),
|
||||
title = stringResource(I18N.string.common_add_action),
|
||||
enabled = enabled,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -19,6 +19,7 @@
|
||||
package me.proton.core.drive.base.presentation.component
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
@@ -39,7 +40,7 @@ fun ProtonPullToRefresh(
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
topPadding: Dp = 0.dp,
|
||||
content: @Composable () -> Unit
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing = isRefreshing, onRefresh = onRefresh)
|
||||
Box(
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Core.
|
||||
*
|
||||
* Proton Core 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.
|
||||
*
|
||||
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.drive.base.presentation.component
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateIntOffsetAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.proton.core.compose.component.ProtonButton
|
||||
import me.proton.core.compose.component.protonTextButtonColors
|
||||
import me.proton.core.compose.theme.ProtonDimens.DefaultButtonMinHeight
|
||||
import me.proton.core.compose.theme.ProtonDimens.SmallSpacing
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.drive.i18n.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ProtonTab(
|
||||
@StringRes titleResId: Int,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onTab: () -> Unit,
|
||||
minWith: Dp = 120.dp,
|
||||
) {
|
||||
val brandColor = ProtonTheme.colors.brandNorm
|
||||
val dividerColor by animateColorAsState(
|
||||
targetValue = if (isSelected) brandColor else Color.Transparent,
|
||||
label = "dividerColor"
|
||||
)
|
||||
val textColor by animateColorAsState(
|
||||
targetValue = if (isSelected) brandColor else ProtonTheme.colors.textWeak,
|
||||
label = "textColor"
|
||||
)
|
||||
val thickness = 4.dp
|
||||
val offset by animateIntOffsetAsState(
|
||||
targetValue = if (isSelected) {
|
||||
IntOffset.Zero
|
||||
} else {
|
||||
with(LocalDensity.current) {
|
||||
IntOffset(0, thickness.toPx().roundToInt())
|
||||
}
|
||||
},
|
||||
label = "offset"
|
||||
)
|
||||
ProtonButton(
|
||||
modifier = modifier,
|
||||
onClick = { onTab() },
|
||||
contentPadding = PaddingValues(horizontal = SmallSpacing),
|
||||
colors = ButtonDefaults.protonTextButtonColors(false),
|
||||
shape = RoundedCornerShape(0.dp),
|
||||
border = null,
|
||||
elevation = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.sizeIn(minHeight = DefaultButtonMinHeight, minWidth = minWith),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = titleResId),
|
||||
style = ProtonTheme.typography.body2Regular.copy(
|
||||
color = textColor
|
||||
),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Divider(
|
||||
color = dividerColor,
|
||||
thickness = thickness,
|
||||
modifier = Modifier
|
||||
.offset { offset }
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(100.dp, 100.dp, 0.dp, 0.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProtonTabPreview() {
|
||||
ProtonTheme {
|
||||
var isSelected by remember { mutableIntStateOf(0) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
ProtonTab(
|
||||
titleResId = R.string.shared_with_me_title,
|
||||
isSelected = isSelected == 0,
|
||||
onTab = { isSelected = 0 }
|
||||
)
|
||||
ProtonTab(
|
||||
titleResId = R.string.shared_by_me_title,
|
||||
isSelected = isSelected == 1,
|
||||
onTab = { isSelected = 1 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -20,8 +20,8 @@ package me.proton.core.drive.base.presentation.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
|
||||
+9
-2
@@ -38,7 +38,9 @@ import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import me.proton.core.compose.flow.rememberFlowWithLifecycle
|
||||
@@ -47,6 +49,7 @@ import me.proton.core.compose.theme.ProtonDimens.DefaultIconSize
|
||||
import me.proton.core.compose.theme.ProtonDimens.ExtraSmallSpacing
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.headlineSmall
|
||||
import me.proton.core.compose.theme.headlineSmallNorm
|
||||
import me.proton.core.drive.base.presentation.common.Action
|
||||
import me.proton.core.drive.base.presentation.component.TopAppBarComponentTestTag.navigationButton
|
||||
|
||||
@@ -87,6 +90,7 @@ fun TopAppBar(
|
||||
backgroundColor: Color = ProtonTheme.colors.backgroundNorm,
|
||||
contentColor: Color = ProtonTheme.colors.textNorm,
|
||||
notificationDotVisible: Boolean = false,
|
||||
elevation: Dp = 0.dp,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -104,7 +108,7 @@ fun TopAppBar(
|
||||
actions = actions,
|
||||
backgroundColor = backgroundColor,
|
||||
contentColor = contentColor,
|
||||
elevation = 0.dp
|
||||
elevation = elevation,
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
@@ -112,13 +116,14 @@ fun Title(
|
||||
title: String,
|
||||
isTitleEncrypted: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = ProtonTheme.typography.headlineSmallNorm,
|
||||
) {
|
||||
if (isTitleEncrypted) {
|
||||
EncryptedItem(modifier = modifier.height(LARGE_HEIGHT))
|
||||
} else {
|
||||
Text(
|
||||
text = title,
|
||||
style = ProtonTheme.typography.headlineSmall,
|
||||
style = style,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
@@ -182,11 +187,13 @@ fun ActionButton(
|
||||
@Composable
|
||||
fun TopBarActions(
|
||||
actionFlow: Flow<Set<Action>>,
|
||||
iconTintColor: Color = IconTintColor,
|
||||
) {
|
||||
val actions by rememberFlowWithLifecycle(flow = actionFlow).collectAsState(initial = emptySet())
|
||||
actions.forEach { action ->
|
||||
ActionButton(
|
||||
icon = action.iconResId,
|
||||
iconTintColor = iconTintColor,
|
||||
contentDescription = action.contentDescriptionResId,
|
||||
notificationDotVisible = action.notificationDotVisible,
|
||||
onClick = action.onAction
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Core.
|
||||
*
|
||||
* Proton Core 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.
|
||||
*
|
||||
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.drive.base.presentation.extension
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
fun Color.blendToSolid(alpha: Float, bg: Color = Color.White): Color =
|
||||
alpha.coerceIn(0f, 1f).let { alpha ->
|
||||
Color(
|
||||
red = (red * alpha) + (bg.red * (1 - alpha)),
|
||||
green = (green * alpha) + (bg.green * (1 - alpha)),
|
||||
blue = (blue * alpha) + (bg.blue * (1 - alpha)),
|
||||
alpha = 1f,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="320dp"
|
||||
android:height="185dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="185">
|
||||
<path
|
||||
android:pathData="M66.08,66.2C63.22,55.54 69.55,44.57 80.22,41.71L176.96,15.79C187.63,12.93 198.6,19.26 201.46,29.93L225.27,118.79C228.13,129.46 221.8,140.43 211.13,143.29L114.39,169.21C103.72,172.07 92.75,165.74 89.89,155.07L66.08,66.2Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="188.9"
|
||||
android:startY="114.61"
|
||||
android:endX="121.27"
|
||||
android:endY="-28.94"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF6D4AFF"/>
|
||||
<item android:offset="1" android:color="#FFF4E5FF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M66.08,66.2C63.22,55.54 69.55,44.57 80.22,41.71L176.96,15.79C187.63,12.93 198.6,19.26 201.46,29.93L225.27,118.79C228.13,129.46 221.8,140.43 211.13,143.29L114.39,169.21C103.72,172.07 92.75,165.74 89.89,155.07L66.08,66.2Z"
|
||||
android:fillAlpha="0.3">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="130.4"
|
||||
android:startY="-9.39"
|
||||
android:endX="128.93"
|
||||
android:endY="33.06"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FFFFFFFF"/>
|
||||
<item android:offset="1" android:color="#00FFFFFF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M66.08,66.2C63.22,55.54 69.55,44.57 80.22,41.71L176.81,15.83C187.48,12.97 198.45,19.3 201.31,29.97L213.21,74.4L77.98,110.64L66.08,66.2Z"
|
||||
android:fillAlpha="0.2">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="128.52"
|
||||
android:startY="28.77"
|
||||
android:endX="134.21"
|
||||
android:endY="50.02"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FFFFFFFF"/>
|
||||
<item android:offset="1" android:color="#00FFFFFF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M89.89,155.07C92.75,165.74 103.72,172.07 114.39,169.21L210.98,143.33C221.65,140.47 227.98,129.51 225.12,118.83L213.21,74.4L77.98,110.64L89.89,155.07Z"
|
||||
android:fillAlpha="0.1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="162.68"
|
||||
android:startY="156.27"
|
||||
android:endX="156.99"
|
||||
android:endY="135.02"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FFFFFFFF"/>
|
||||
<item android:offset="1" android:color="#00FFFFFF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M90.9,59.61C90.9,48.57 99.86,39.61 110.9,39.61H211.06C222.1,39.61 231.06,48.57 231.06,59.61V151.61C231.06,162.66 222.1,171.61 211.06,171.61H110.9C99.86,171.61 90.9,162.66 90.9,151.61V59.61Z"
|
||||
android:fillAlpha="0.8">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="261.59"
|
||||
android:startY="160.11"
|
||||
android:endX="147.94"
|
||||
android:endY="5.72"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF6D4AFF"/>
|
||||
<item android:offset="1" android:color="#FFF4E5FF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M90.9,59.61C90.9,48.57 99.86,39.61 110.9,39.61H211.06C222.1,39.61 231.06,48.57 231.06,59.61V151.61C231.06,162.66 222.1,171.61 211.06,171.61H110.9C99.86,171.61 90.9,162.66 90.9,151.61V59.61Z"
|
||||
android:fillAlpha="0.3">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="156.82"
|
||||
android:startY="34.11"
|
||||
android:endX="164.56"
|
||||
android:endY="82.7"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FFFFFFFF"/>
|
||||
<item android:offset="1" android:color="#00FFFFFF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M90.9,59.61C90.9,48.57 99.86,39.61 110.9,39.61H210.9C221.95,39.61 230.9,48.57 230.9,59.61V105.61H90.9V59.61Z"
|
||||
android:fillAlpha="0.2">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="160.9"
|
||||
android:startY="39.61"
|
||||
android:endX="160.9"
|
||||
android:endY="61.61"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FFFFFFFF"/>
|
||||
<item android:offset="1" android:color="#00FFFFFF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M90.9,151.61C90.9,162.66 99.86,171.61 110.9,171.61H210.9C221.95,171.61 230.9,162.66 230.9,151.61V105.61H90.9V151.61Z"
|
||||
android:fillAlpha="0.1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="160.9"
|
||||
android:startY="171.61"
|
||||
android:endX="160.9"
|
||||
android:endY="149.61"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FFFFFFFF"/>
|
||||
<item android:offset="1" android:color="#00FFFFFF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M110.9,39.61C99.86,39.61 90.9,48.57 90.9,59.61V151.61C90.9,162.66 99.86,171.61 110.9,171.61H211.06C222.1,171.61 231.06,162.66 231.06,151.61V59.61C231.06,48.57 222.1,39.61 211.06,39.61H110.9ZM152.12,98.85C157.47,98.85 161.8,94.69 161.8,89.56C161.8,84.43 157.47,80.26 152.12,80.26C146.78,80.26 142.45,84.43 142.45,89.56C142.45,94.69 146.78,98.85 152.12,98.85ZM172.02,96.87L153.56,120.11C153.56,120.2 153.47,120.2 153.38,120.11L143.16,107.63C142,106.17 139.76,106.17 138.59,107.63L123.9,125.7C122.37,127.51 123.72,130.18 126.14,130.18H145.49H161.62H198.09C200.15,130.18 201.32,127.86 200.06,126.31L176.68,96.87C175.51,95.41 173.18,95.41 172.02,96.87Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillAlpha="0.5"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M90.9,59.61C90.9,48.57 99.86,39.61 110.9,39.61H210.9C221.95,39.61 230.9,48.57 230.9,59.61V105.61H90.9V59.61Z"
|
||||
android:fillAlpha="0.2">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="160.9"
|
||||
android:startY="39.61"
|
||||
android:endX="160.9"
|
||||
android:endY="61.61"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FFFFFFFF"/>
|
||||
<item android:offset="1" android:color="#00FFFFFF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M90.9,151.61C90.9,162.66 99.86,171.61 110.9,171.61H210.9C221.95,171.61 230.9,162.66 230.9,151.61V105.61H90.9V151.61Z"
|
||||
android:fillAlpha="0.1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="160.9"
|
||||
android:startY="171.61"
|
||||
android:endX="160.9"
|
||||
android:endY="149.61"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FFFFFFFF"/>
|
||||
<item android:offset="1" android:color="#00FFFFFF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M19.867,17.465C20.198,17.896 19.872,18.5 19.308,18.5L8.192,18.5C7.628,18.5 7.302,17.896 7.633,17.465L10.258,14.043C10.424,13.828 10.764,13.828 10.93,14.043C11.721,15.074 13.275,15.074 14.066,14.043L15.318,12.412C15.483,12.196 15.824,12.196 15.99,12.412L19.867,17.465Z"
|
||||
android:fillColor="#999693"/>
|
||||
<path
|
||||
android:pathData="M13.125,11C13.125,11.69 12.565,12.25 11.875,12.25C11.185,12.25 10.625,11.69 10.625,11C10.625,10.31 11.185,9.75 11.875,9.75C12.565,9.75 13.125,10.31 13.125,11Z"
|
||||
android:fillColor="#999693"/>
|
||||
<path
|
||||
android:pathData="M19,6L20,6C21.381,6 22.5,7.119 22.5,8.5L22.5,18.5C22.5,19.881 21.381,21 20,21L7.5,21C6.119,21 5,19.881 5,18.5L5,18L4,18C2.619,18 1.5,16.881 1.5,15.5L1.5,5.5C1.5,4.119 2.619,3 4,3L16.5,3C17.881,3 19,4.119 19,5.5L19,6ZM16.5,4.25L4,4.25C3.31,4.25 2.75,4.81 2.75,5.5L2.75,15.5C2.75,16.19 3.31,16.75 4,16.75L5,16.75L5,8.5C5,7.119 6.119,6 7.5,6L17.75,6L17.75,5.5C17.75,4.81 17.19,4.25 16.5,4.25ZM7.5,7.25L20,7.25C20.69,7.25 21.25,7.81 21.25,8.5L21.25,18.5C21.25,19.19 20.69,19.75 20,19.75L7.5,19.75C6.81,19.75 6.25,19.19 6.25,18.5L6.25,8.5C6.25,7.81 6.81,7.25 7.5,7.25Z"
|
||||
android:fillColor="#999693"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="50dp"
|
||||
android:height="50dp"
|
||||
android:viewportWidth="50"
|
||||
android:viewportHeight="50">
|
||||
<path
|
||||
android:pathData="M41.39,36.384C42.08,37.283 41.4,38.542 40.224,38.542L17.068,38.542C15.892,38.542 15.212,37.283 15.901,36.384L21.372,29.257C21.717,28.807 22.426,28.807 22.771,29.257C24.419,31.404 27.656,31.404 29.304,29.257L31.912,25.858C32.257,25.409 32.967,25.409 33.312,25.858L41.39,36.384Z"
|
||||
android:fillColor="#706D6B"/>
|
||||
<path
|
||||
android:pathData="M27.344,22.917C27.344,24.355 26.178,25.521 24.74,25.521C23.301,25.521 22.135,24.355 22.135,22.917C22.135,21.478 23.301,20.313 24.74,20.313C26.178,20.313 27.344,21.478 27.344,22.917Z"
|
||||
android:fillColor="#706D6B"/>
|
||||
<path
|
||||
android:pathData="M39.583,12.5L41.667,12.5C44.543,12.5 46.875,14.832 46.875,17.708L46.875,38.542C46.875,41.418 44.543,43.75 41.667,43.75L15.625,43.75C12.748,43.75 10.417,41.418 10.417,38.542L10.417,37.5L8.333,37.5C5.457,37.5 3.125,35.168 3.125,32.292L3.125,11.458C3.125,8.582 5.457,6.25 8.333,6.25L34.375,6.25C37.251,6.25 39.583,8.582 39.583,11.458L39.583,12.5ZM34.375,8.854L8.333,8.854C6.895,8.854 5.729,10.02 5.729,11.458L5.729,32.292C5.729,33.73 6.895,34.896 8.333,34.896L10.417,34.896L10.417,17.708C10.417,14.832 12.748,12.5 15.625,12.5L36.979,12.5L36.979,11.458C36.979,10.02 35.813,8.854 34.375,8.854ZM15.625,15.104L41.667,15.104C43.105,15.104 44.271,16.27 44.271,17.708L44.271,38.542C44.271,39.98 43.105,41.146 41.667,41.146L15.625,41.146C14.187,41.146 13.021,39.98 13.021,38.542L13.021,17.708C13.021,16.27 14.187,15.104 15.625,15.104Z"
|
||||
android:fillColor="#706D6B"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Proton AG.
|
||||
* This file is part of Proton Core.
|
||||
*
|
||||
* Proton Core 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.
|
||||
*
|
||||
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.drive.base.presentation.extension
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class ColorKtTest(
|
||||
private val fgColor: Int,
|
||||
private val alpha: Float,
|
||||
private val bgColor: Int,
|
||||
private val expected: Int,
|
||||
) {
|
||||
private fun assertColorEquals(expected: Color, actual: Color, delta: Float = 0.01f) {
|
||||
assertEquals(expected.red, actual.red, delta)
|
||||
assertEquals(expected.green, actual.green, delta)
|
||||
assertEquals(expected.blue, actual.blue, delta)
|
||||
assertEquals(expected.alpha, actual.alpha, delta)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBlendToSolid() {
|
||||
val actual = Color(fgColor).blendToSolid(
|
||||
alpha = alpha,
|
||||
bg = Color(bgColor),
|
||||
)
|
||||
assertColorEquals(Color(expected), actual)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@get:Parameterized.Parameters(name = "argb({0}).blendToSolid(alpha={1}, bgColor=argb({2})) = argb({3})")
|
||||
@get:JvmStatic
|
||||
val data = listOf(
|
||||
arrayOf(Color(0xFF007B58).toArgb(), 0.3f, Color.White.toArgb(), Color(0xFFB2D7CD).toArgb()),
|
||||
arrayOf(Color(0xFFFF5733).toArgb(), 0.5f, Color.Black.toArgb(), Color(0xFF7F2B19).toArgb()),
|
||||
arrayOf(Color(0xFF123456).toArgb(), 0.7f, Color.White.toArgb(), Color(0xFF597189).toArgb()),
|
||||
arrayOf(Color(0xFFABCDEF).toArgb(), 0.2f, Color.Black.toArgb(), Color(0xFF222930).toArgb()),
|
||||
arrayOf(Color(0xFF800080).toArgb(), 0.4f, Color(0xFF00FF00).toArgb(), Color(0xFF339933).toArgb()),
|
||||
)
|
||||
}
|
||||
}
|
||||
+18
-13
@@ -29,9 +29,10 @@ import me.proton.core.drive.key.domain.usecase.GetAddressKeys
|
||||
import me.proton.core.drive.key.domain.usecase.GetNodeHashKey
|
||||
import me.proton.core.drive.key.domain.usecase.GetNodeKey
|
||||
import me.proton.core.drive.key.domain.usecase.MoveNodeKey
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.Link
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.MoveInfo
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.domain.extension.requireParentId
|
||||
import me.proton.core.drive.link.domain.extension.shareId
|
||||
import me.proton.core.drive.link.domain.extension.userId
|
||||
@@ -52,35 +53,39 @@ class CreateMoveInfo @Inject constructor(
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
linkId: LinkId,
|
||||
newParentId: FolderId,
|
||||
newParentId: ParentId,
|
||||
): Result<MoveInfo> = coRunCatching {
|
||||
val link = getLink(linkId).toResult().getOrThrow()
|
||||
val decryptedLinkName = decryptLinkName(link).getOrThrow().text
|
||||
val currentParentFolder = getLink(link.requireParentId()).toResult().getOrThrow()
|
||||
val currentParentFolderKey = getNodeKey(currentParentFolder).getOrThrow()
|
||||
val newParentFolder = getLink(newParentId).toResult().getOrThrow()
|
||||
val newParentFolderKey = getNodeKey(newParentFolder).getOrThrow()
|
||||
val newParentFolderHashKey = getNodeHashKey(newParentFolder, newParentFolderKey).getOrThrow()
|
||||
val currentParent = getLink(link.requireParentId()).toResult().getOrThrow()
|
||||
val currentParentKey = getNodeKey(currentParent).getOrThrow()
|
||||
val newParent = getLink(newParentId).toResult().getOrThrow()
|
||||
val newParentKey = getNodeKey(newParent).getOrThrow()
|
||||
val newParentHashKey = when(newParent) {
|
||||
is Link.Album -> getNodeHashKey(newParent, newParentKey).getOrThrow()
|
||||
is Link.Folder -> getNodeHashKey(newParent, newParentKey).getOrThrow()
|
||||
else -> error("Either folder of album can be parent")
|
||||
}
|
||||
val userId = linkId.userId
|
||||
val signatureAddress = getSignatureAddress(link.shareId).getOrThrow()
|
||||
val newLinkKey = moveNodeKey(
|
||||
userId = userId,
|
||||
key = getNodeKey(linkId).getOrThrow(),
|
||||
oldParentKey = currentParentFolderKey,
|
||||
newParentKey = newParentFolderKey,
|
||||
oldParentKey = currentParentKey,
|
||||
newParentKey = newParentKey,
|
||||
signatureAddress = signatureAddress,
|
||||
).getOrThrow()
|
||||
MoveInfo(
|
||||
name = changeMessage(
|
||||
oldMessage = link.name,
|
||||
oldMessageDecryptionKey = currentParentFolderKey.keyHolder,
|
||||
oldMessageDecryptionKey = currentParentKey.keyHolder,
|
||||
newMessage = decryptedLinkName,
|
||||
newMessageEncryptionKey = newParentFolderKey.keyHolder,
|
||||
newMessageEncryptionKey = newParentKey.keyHolder,
|
||||
signKey = getAddressKeys(userId, signatureAddress).keyHolder,
|
||||
).getOrThrow(),
|
||||
hash = hmacSha256(newParentFolderHashKey, decryptedLinkName).getOrThrow(),// calculate with new parent,
|
||||
hash = hmacSha256(newParentHashKey, decryptedLinkName).getOrThrow(),// calculate with new parent,
|
||||
previousHash = link.hash,
|
||||
parentLinkId = newParentFolder.id.id,
|
||||
parentLinkId = newParent.id.id,
|
||||
signatureEmail = if (link.signatureEmail.isEmpty()) {
|
||||
signatureAddress
|
||||
} else {
|
||||
|
||||
@@ -26,6 +26,7 @@ import me.proton.core.drive.link.data.db.entity.LinkAlbumPropertiesEntity
|
||||
import me.proton.core.drive.link.data.db.entity.LinkEntity
|
||||
import me.proton.core.drive.link.data.db.entity.LinkFilePropertiesEntity
|
||||
import me.proton.core.drive.link.data.db.entity.LinkFolderPropertiesEntity
|
||||
import me.proton.core.drive.link.data.db.entity.LinkTagEntity
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
@@ -130,6 +131,7 @@ suspend fun FolderContext.folder(
|
||||
suspend fun FolderContext.file(
|
||||
id: String,
|
||||
sharingDetailsShareId: String? = null,
|
||||
tags: List<Long> = emptyList(),
|
||||
block: suspend FileContext.() -> Unit = {},
|
||||
): FileId {
|
||||
file(
|
||||
@@ -139,6 +141,7 @@ suspend fun FolderContext.file(
|
||||
type = 2L,
|
||||
sharingDetailsShareId = sharingDetailsShareId,
|
||||
),
|
||||
tags = tags,
|
||||
block = block,
|
||||
)
|
||||
return FileId(ShareId(user.userId, share.id), id)
|
||||
@@ -156,6 +159,7 @@ suspend fun FolderContext.file(
|
||||
contentKeyPacketSignature = null,
|
||||
activeRevisionSignatureAddress = null,
|
||||
),
|
||||
tags: List<Long> = emptyList(),
|
||||
block: suspend FileContext.() -> Unit = {},
|
||||
) {
|
||||
db.driveLinkDao.insertOrUpdate(link)
|
||||
@@ -174,6 +178,9 @@ suspend fun FolderContext.file(
|
||||
)
|
||||
)
|
||||
}
|
||||
db.driveLinkDao.insertTags(*tags.map { tag ->
|
||||
LinkTagEntity(userId, share.id, link.id, tag)
|
||||
}.toTypedArray())
|
||||
FileContext(db, user, account, volume, share, link, this.link, properties.activeRevisionId).block()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,7 @@ import me.proton.core.drive.link.data.db.entity.LinkAlbumPropertiesEntity
|
||||
import me.proton.core.drive.link.data.db.entity.LinkEntity
|
||||
import me.proton.core.drive.link.data.db.entity.LinkFilePropertiesEntity
|
||||
import me.proton.core.drive.link.data.db.entity.LinkFolderPropertiesEntity
|
||||
import me.proton.core.drive.link.data.db.entity.LinkTagEntity
|
||||
import me.proton.core.drive.link.selection.data.db.LinkSelectionConverters
|
||||
import me.proton.core.drive.link.selection.data.db.LinkSelectionDatabase
|
||||
import me.proton.core.drive.link.selection.data.db.entity.LinkSelectionEntity
|
||||
@@ -252,6 +253,7 @@ import me.proton.core.notification.data.local.db.NotificationDatabase as CoreNot
|
||||
LinkFilePropertiesEntity::class,
|
||||
LinkFolderPropertiesEntity::class,
|
||||
LinkAlbumPropertiesEntity::class,
|
||||
LinkTagEntity::class,
|
||||
LinkOfflineEntity::class,
|
||||
LinkDownloadStateEntity::class,
|
||||
DownloadBlockEntity::class,
|
||||
@@ -420,7 +422,7 @@ abstract class DriveDatabase :
|
||||
DriveObservabilityDatabase {
|
||||
|
||||
companion object {
|
||||
const val VERSION = 82
|
||||
const val VERSION = 84
|
||||
|
||||
private val migrations = listOf(
|
||||
DriveDatabaseMigrations.MIGRATION_1_2,
|
||||
@@ -504,6 +506,8 @@ abstract class DriveDatabase :
|
||||
DriveDatabaseMigrations.MIGRATION_79_80,
|
||||
DriveDatabaseMigrations.MIGRATION_80_81,
|
||||
DriveDatabaseMigrations.MIGRATION_81_82,
|
||||
DriveDatabaseMigrations.MIGRATION_82_83,
|
||||
DriveDatabaseMigrations.MIGRATION_83_84,
|
||||
)
|
||||
|
||||
fun buildDatabase(context: Context): DriveDatabase =
|
||||
|
||||
@@ -530,4 +530,16 @@ object DriveDatabaseMigrations {
|
||||
LinkUploadDatabase.MIGRATION_7.migrate(db)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_82_83 = object : Migration(82, 83) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
PhotosDatabase.MIGRATION_2.migrate(db)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_83_84 = object : Migration(83, 84) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
LinkDatabase.MIGRATION_3.migrate(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -29,6 +29,8 @@ import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.domain.extension.userId
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetDecryptedDriveLink @Inject constructor(
|
||||
@@ -63,6 +65,22 @@ class GetDecryptedDriveLink @Inject constructor(
|
||||
decryptDriveLink(driveLink, failOnDecryptionError).toDataResult()
|
||||
}
|
||||
|
||||
operator fun invoke(
|
||||
parentId: ParentId,
|
||||
failOnDecryptionError: Boolean = true,
|
||||
) = when (parentId) {
|
||||
is FolderId -> invoke(parentId.userId, parentId, failOnDecryptionError)
|
||||
is AlbumId -> invoke(parentId, failOnDecryptionError)
|
||||
}
|
||||
|
||||
operator fun invoke(
|
||||
userId: UserId,
|
||||
parentId: ParentId?,
|
||||
failOnDecryptionError: Boolean = true,
|
||||
) = parentId?.let {
|
||||
invoke(parentId, failOnDecryptionError)
|
||||
} ?: invoke(userId, null, failOnDecryptionError)
|
||||
|
||||
operator fun invoke(
|
||||
linkId: LinkId,
|
||||
failOnDecryptionError: Boolean = true,
|
||||
|
||||
+4
@@ -20,6 +20,7 @@ package me.proton.core.drive.drivelink.download.data.extension
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.linkdownload.domain.entity.DownloadState
|
||||
|
||||
internal val DriveLink.uniqueWorkName: String
|
||||
@@ -35,5 +36,8 @@ internal fun uniqueFolderWorkName(folderId: FolderId) =
|
||||
internal fun uniqueAlbumWorkName(albumId: AlbumId) =
|
||||
"${albumId.shareId.id}.${albumId.id}"
|
||||
|
||||
internal fun uniqueParentWorkName(parentId: ParentId) =
|
||||
"${parentId.shareId.id}.${parentId.id}"
|
||||
|
||||
internal val DriveLink.isNotDownloading: Boolean
|
||||
get() = downloadState != DownloadState.Downloading
|
||||
|
||||
+8
-5
@@ -123,12 +123,12 @@ class FolderDownloadWorker @AssistedInject constructor(
|
||||
while (mutableDescendants.isNotEmpty()) {
|
||||
// We peek at the first node in the list
|
||||
val currentDescendant = mutableDescendants.first()
|
||||
val parentId = currentDescendant.parentId
|
||||
val folderId = currentDescendant.parentId as? FolderId
|
||||
// If descendants list does not contain any child of parent folder, we can mark parent folder as downloaded
|
||||
if (mutableDescendants.none { link -> link.parentId == parentId }) {
|
||||
if (mutableDescendants.none { link -> link.parentId == folderId }) {
|
||||
mutableDescendants
|
||||
.filterIsInstance<Link.Folder>()
|
||||
.find { link -> link.id == parentId}
|
||||
.find { link -> link.id == folderId}
|
||||
?.let { parent ->
|
||||
workContinuation = workContinuation.then(parent.setAsDownloaded(userId, folder.id))
|
||||
}
|
||||
@@ -147,7 +147,7 @@ class FolderDownloadWorker @AssistedInject constructor(
|
||||
// folder to treat them at the same time and we create a task to download each file
|
||||
// We also remove them from the list of unhandled children
|
||||
mutableDescendants
|
||||
.filter { link -> link.parentId == parentId && link is Link.File }
|
||||
.filter { link -> link.parentId == folderId && link is Link.File }
|
||||
.also { siblings -> mutableDescendants.removeAll(siblings) }
|
||||
.fold(workContinuation) { continuation, link ->
|
||||
continuation.then((link as Link.File).setAsToDownload(userId, folderTag))
|
||||
@@ -190,7 +190,10 @@ class FolderDownloadWorker @AssistedInject constructor(
|
||||
fileId = id,
|
||||
revisionId = activeRevisionId,
|
||||
isRetryable = true,
|
||||
fileTags = listOfNotNull(folderTag, parentId?.let { uniqueFolderWorkName(it) }),
|
||||
fileTags = listOfNotNull(
|
||||
folderTag,
|
||||
(parentId as? FolderId)?.let { uniqueFolderWorkName(it) },
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
+9
-4
@@ -26,6 +26,7 @@ import me.proton.core.drive.documentsprovider.domain.usecase.GetContentDigest
|
||||
import me.proton.core.drive.drivelink.domain.usecase.GetDriveLinks
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.FileId
|
||||
import me.proton.core.drive.photo.domain.entity.AddToRemoveFromAlbumResult
|
||||
import me.proton.core.drive.photo.domain.repository.AlbumRepository
|
||||
import me.proton.core.drive.volume.domain.entity.VolumeId
|
||||
import javax.inject.Inject
|
||||
@@ -42,21 +43,24 @@ class AddPhotosToAlbum @Inject constructor(
|
||||
volumeId: VolumeId,
|
||||
albumId: AlbumId,
|
||||
photoIds: List<FileId>,
|
||||
) = coRunCatching {
|
||||
): Result<AddToRemoveFromAlbumResult> = coRunCatching {
|
||||
val (volumePhotoIds, sharedWithMePhotoIds) = volumeAndSharedWithMeFileIds(
|
||||
volumeId = volumeId,
|
||||
photoIds = photoIds,
|
||||
)
|
||||
addVolumePhotosToAlbum(
|
||||
val addVolumePhotosToAlbumResult = addVolumePhotosToAlbum(
|
||||
volumeId = volumeId,
|
||||
albumId = albumId,
|
||||
photoIds = volumePhotoIds,
|
||||
)
|
||||
addSharedWithMePhotosToAlbum(
|
||||
val addSharedWithMePhotosToAlbumResult = addSharedWithMePhotosToAlbum(
|
||||
volumeId = volumeId,
|
||||
albumId = albumId,
|
||||
photoIds = sharedWithMePhotoIds,
|
||||
)
|
||||
AddToRemoveFromAlbumResult(
|
||||
addVolumePhotosToAlbumResult.results + addSharedWithMePhotosToAlbumResult.results
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun addVolumePhotosToAlbum(
|
||||
@@ -79,10 +83,11 @@ class AddPhotosToAlbum @Inject constructor(
|
||||
volumeId: VolumeId,
|
||||
albumId: AlbumId,
|
||||
photoIds: List<FileId>,
|
||||
) {
|
||||
): AddToRemoveFromAlbumResult {
|
||||
if (photoIds.isNotEmpty()) {
|
||||
TODO("addSharedWithMePhotosToAlbum is not implemented yet")
|
||||
}
|
||||
return AddToRemoveFromAlbumResult()
|
||||
}
|
||||
|
||||
private suspend fun volumeAndSharedWithMeFileIds(
|
||||
|
||||
+5
-3
@@ -70,9 +70,11 @@ class RenameLink @Inject constructor(
|
||||
linkName: String,
|
||||
): Result<Unit> = coRunCatching {
|
||||
val link = getLink(linkId).toResult().getOrThrow()
|
||||
val parentFolderId = requireNotNull(link.parentId) { "Parent folder must not be null" }
|
||||
val parentFolder = getLink(parentFolderId).toResult().getOrThrow()
|
||||
invoke(parentFolder, link, linkName).getOrThrow()
|
||||
val parentId = requireNotNull(link.parentId) { "Parent must not be null" }
|
||||
when (val parent = getLink(parentId).toResult().getOrThrow()) {
|
||||
is Link.Folder -> invoke(parent, link, linkName).getOrThrow()
|
||||
else -> error("Album should not be renamed through this endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
suspend operator fun invoke(
|
||||
|
||||
+3
-3
@@ -24,11 +24,11 @@ import me.proton.core.drive.base.domain.provider.ConfigurationProvider
|
||||
import me.proton.core.drive.drivelink.data.extension.toDriveLinks
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.selection.data.db.DriveLinkSelectionDatabase
|
||||
import javax.inject.Inject
|
||||
import me.proton.core.drive.drivelink.selection.domain.repository.DriveLinkSelectionRepository
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import javax.inject.Inject
|
||||
|
||||
class DriveLinkSelectionRepositoryImpl @Inject constructor(
|
||||
private val db: DriveLinkSelectionDatabase,
|
||||
@@ -41,7 +41,7 @@ class DriveLinkSelectionRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun selectAll(
|
||||
parentId: FolderId,
|
||||
parentId: ParentId,
|
||||
selectionId: SelectionId?,
|
||||
getDriveLinks: (fromIndex: Int, count: Int) -> Flow<List<DriveLink>>,
|
||||
selectLinks: suspend (SelectionId?, List<LinkId>) -> Result<SelectionId>,
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ package me.proton.core.drive.drivelink.selection.domain.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
|
||||
interface DriveLinkSelectionRepository {
|
||||
@@ -28,7 +28,7 @@ interface DriveLinkSelectionRepository {
|
||||
fun getSelectedDriveLinks(selectionId: SelectionId): Flow<List<DriveLink>>
|
||||
|
||||
suspend fun selectAll(
|
||||
parentId: FolderId,
|
||||
parentId: ParentId,
|
||||
selectionId: SelectionId?,
|
||||
getDriveLinks: (fromIndex: Int, count: Int) -> Flow<List<DriveLink>>,
|
||||
selectLinks: suspend (SelectionId?, List<LinkId>) -> Result<SelectionId>,
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.selection.domain.repository.DriveLinkSelectionRepository
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
|
||||
import javax.inject.Inject
|
||||
@@ -34,7 +34,7 @@ class GetSelectedDriveLinks @Inject constructor(
|
||||
operator fun invoke(selectionId: SelectionId): Flow<List<DriveLink>> =
|
||||
repository.getSelectedDriveLinks(selectionId)
|
||||
|
||||
operator fun invoke(selectionId: SelectionId, parentId: FolderId): Flow<List<DriveLink>> =
|
||||
operator fun invoke(selectionId: SelectionId, parentId: ParentId): Flow<List<DriveLink>> =
|
||||
repository
|
||||
.getSelectedDriveLinks(selectionId)
|
||||
.map { driveLinks ->
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ import kotlinx.coroutines.flow.map
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.domain.repository.DriveLinkRepository
|
||||
import me.proton.core.drive.drivelink.selection.domain.repository.DriveLinkSelectionRepository
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
|
||||
import javax.inject.Inject
|
||||
@@ -33,7 +33,7 @@ class SelectAll @Inject constructor(
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
parentId: FolderId,
|
||||
parentId: ParentId,
|
||||
selectionId: SelectionId?,
|
||||
driveLinkFilter: (DriveLink) -> Boolean = { true },
|
||||
) = driveLinkSelectionRepository.selectAll(
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2022-2024 Proton AG.
|
||||
* This file is part of Proton Core.
|
||||
*
|
||||
* Proton Core 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.
|
||||
*
|
||||
* Proton Core 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 Proton Core. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package me.proton.core.drive.drivelink.shared.domain.usecase
|
||||
|
||||
import me.proton.core.drive.base.domain.extension.getOrNull
|
||||
import me.proton.core.drive.base.domain.extension.toResult
|
||||
import me.proton.core.drive.base.domain.log.LogTag.SHARING
|
||||
import me.proton.core.drive.base.domain.util.coRunCatching
|
||||
import me.proton.core.drive.eventmanager.base.domain.usecase.UpdateEventAction
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.usecase.GetLink
|
||||
import me.proton.core.drive.share.domain.usecase.DeleteShare
|
||||
import javax.inject.Inject
|
||||
|
||||
class DeleteShareUrlAndShare @Inject constructor(
|
||||
private val getLink: GetLink,
|
||||
private val deleteShareUrl: me.proton.core.drive.shareurl.base.domain.usecase.DeleteShareUrl,
|
||||
private val deleteShare: DeleteShare,
|
||||
private val updateEventAction: UpdateEventAction,
|
||||
) {
|
||||
suspend operator fun invoke(linkId: LinkId): Result<Unit> = coRunCatching {
|
||||
updateEventAction(linkId.shareId) {
|
||||
val sharingDetails = getLink(linkId).toResult().getOrThrow().sharingDetails
|
||||
deleteShareUrl(
|
||||
shareUrlId = requireNotNull(sharingDetails?.shareUrlId) {
|
||||
"ShareUrlId not found"
|
||||
},
|
||||
).getOrThrow()
|
||||
deleteShare(requireNotNull(sharingDetails?.shareId), force = false)
|
||||
.getOrNull(SHARING, "Cannot delete standard share")
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-11
@@ -43,6 +43,7 @@ import me.proton.core.domain.arch.onSuccess
|
||||
import me.proton.core.drive.base.data.extension.getDefaultMessage
|
||||
import me.proton.core.drive.base.data.extension.isRetryable
|
||||
import me.proton.core.drive.base.data.extension.log
|
||||
import me.proton.core.drive.base.domain.api.ProtonApiCode.NOT_EXISTS
|
||||
import me.proton.core.drive.base.domain.extension.combine
|
||||
import me.proton.core.drive.base.domain.extension.filterSuccessOrError
|
||||
import me.proton.core.drive.base.domain.extension.onFailure
|
||||
@@ -82,6 +83,7 @@ import me.proton.core.drive.messagequeue.domain.entity.BroadcastMessage
|
||||
import me.proton.core.drive.share.domain.entity.ShareId
|
||||
import me.proton.core.drive.share.user.domain.entity.ShareUser
|
||||
import me.proton.core.drive.share.user.domain.usecase.GetShareUsers
|
||||
import me.proton.core.network.domain.hasProtonErrorCode
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import me.proton.core.drive.i18n.R as I18N
|
||||
@@ -118,17 +120,19 @@ class ManageAccessViewModel @Inject constructor(
|
||||
private val sharedDriveLink = driveLink.filterNotNull().transformLatest { driveLink ->
|
||||
emitAll(getSharedDriveLink(driveLink))
|
||||
}.onEach { dataResult ->
|
||||
dataResult.onFailure { error ->
|
||||
if (error.cause !is NoSuchElementException) {
|
||||
error.cause?.log(SHARING)
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.WARNING,
|
||||
)
|
||||
dataResult.onFailure { resultError ->
|
||||
resultError.cause?.let { error ->
|
||||
if (error !is NoSuchElementException && !error.hasProtonErrorCode(NOT_EXISTS)) {
|
||||
error.log(SHARING)
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = error.getDefaultMessage(
|
||||
appContext,
|
||||
configurationProvider.useExceptionMessage
|
||||
),
|
||||
type = BroadcastMessage.Type.WARNING,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -111,7 +111,6 @@ private fun driveLinkFile(
|
||||
passphraseSignature = "signature",
|
||||
contentKeyPacket = "contentKeyPacket",
|
||||
contentKeyPacketSignature = null,
|
||||
isFavorite = false,
|
||||
attributes = Attributes(0),
|
||||
permissions = Permissions(0),
|
||||
state = Link.State.ACTIVE,
|
||||
@@ -169,7 +168,6 @@ private fun driveLinkFolder(
|
||||
key = "key",
|
||||
passphrase = "passphrase",
|
||||
passphraseSignature = "signature",
|
||||
isFavorite = false,
|
||||
attributes = Attributes(0),
|
||||
permissions = Permissions(0),
|
||||
state = Link.State.ACTIVE,
|
||||
|
||||
+4
-3
@@ -30,30 +30,31 @@ import me.proton.core.drive.linktrash.data.db.dao.LinkTrashDao
|
||||
@Dao
|
||||
interface DriveLinkTrashDao : DriveLinkDao {
|
||||
|
||||
//TODO: Once LinkEntity gets volumeId, WHERE clause should check for volumeId also
|
||||
@Transaction
|
||||
@Query("""
|
||||
SELECT ${DriveLinkDao.DRIVE_LINK_SELECT} FROM ${DriveLinkDao.DRIVE_LINK_ENTITY}
|
||||
WHERE
|
||||
LinkEntity.user_id = :userId AND
|
||||
ShareEntity.volume_id = :volumeId AND
|
||||
${LinkTrashDao.TRASHED_CONDITION}
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
fun getTrashLinks(
|
||||
userId: UserId,
|
||||
volumeId: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
): Flow<List<DriveLinkEntity>>
|
||||
|
||||
//TODO: Once LinkEntity gets volumeId, WHERE clause should check for volumeId also
|
||||
@Transaction
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT ${DriveLinkDao.DRIVE_LINK_SELECT} FROM ${DriveLinkDao.DRIVE_LINK_ENTITY}
|
||||
WHERE
|
||||
LinkEntity.user_id = :userId AND
|
||||
ShareEntity.volume_id = :volumeId AND
|
||||
${LinkTrashDao.TRASHED_CONDITION}
|
||||
)
|
||||
""")
|
||||
fun getTrashedLinksCount(userId: UserId): Flow<Int>
|
||||
fun getTrashedLinksCount(userId: UserId, volumeId: String): Flow<Int>
|
||||
}
|
||||
|
||||
+2
-2
@@ -38,10 +38,10 @@ class DriveLinkTrashRepositoryImpl @Inject constructor(
|
||||
fromIndex: Int,
|
||||
count: Int,
|
||||
): Flow<List<DriveLink>> =
|
||||
db.driveLinkTrashDao.getTrashLinks(userId, count, fromIndex).map { entities ->
|
||||
db.driveLinkTrashDao.getTrashLinks(userId, volumeId.id, count, fromIndex).map { entities ->
|
||||
entities.toDriveLinks()
|
||||
}
|
||||
|
||||
override fun getTrashDriveLinksCount(userId: UserId, volumeId: VolumeId): Flow<Int> =
|
||||
db.driveLinkTrashDao.getTrashedLinksCount(userId)
|
||||
db.driveLinkTrashDao.getTrashedLinksCount(userId, volumeId.id)
|
||||
}
|
||||
|
||||
+1
@@ -108,6 +108,7 @@ interface DriveLinkDao : LinkDao {
|
||||
LinkFilePropertiesEntity.*,
|
||||
LinkFolderPropertiesEntity.*,
|
||||
LinkAlbumPropertiesEntity.*,
|
||||
LinkTagsData.*,
|
||||
LinkOfflineEntity.${Column.USER_ID} AS ${OFFLINE_PREFIX}_${Column.USER_ID},
|
||||
LinkOfflineEntity.${Column.SHARE_ID} AS ${OFFLINE_PREFIX}_${Column.SHARE_ID},
|
||||
LinkOfflineEntity.${Column.LINK_ID} AS ${OFFLINE_PREFIX}_${Column.LINK_ID},
|
||||
|
||||
+3
-5
@@ -18,7 +18,6 @@
|
||||
|
||||
package me.proton.core.drive.drivelink.data.repository
|
||||
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -29,12 +28,11 @@ import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.domain.repository.DriveLinkRepository
|
||||
import me.proton.core.drive.link.data.extension.toLink
|
||||
import me.proton.core.drive.link.data.extension.toLinkWithProperties
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.domain.extension.userId
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class DriveLinkRepositoryImpl @Inject constructor(
|
||||
private val driveLinkDao: DriveLinkDao,
|
||||
) : DriveLinkRepository {
|
||||
@@ -44,10 +42,10 @@ class DriveLinkRepositoryImpl @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.map { entities -> entities.toDriveLinks().firstOrNull() }
|
||||
|
||||
override fun getDriveLinksCount(parentId: FolderId): Flow<Int> =
|
||||
override fun getDriveLinksCount(parentId: ParentId): Flow<Int> =
|
||||
driveLinkDao.getLinksCountFlow(parentId.userId, parentId.shareId.id, parentId.id)
|
||||
|
||||
override fun getDriveLinks(parentId: FolderId, fromIndex: Int, count: Int): Flow<List<DriveLink>> =
|
||||
override fun getDriveLinks(parentId: ParentId, fromIndex: Int, count: Int): Flow<List<DriveLink>> =
|
||||
driveLinkDao.getLinks(parentId.userId, parentId.shareId.id, parentId.id, count, fromIndex)
|
||||
.map { entities -> entities.toDriveLinks() }
|
||||
|
||||
|
||||
+3
-3
@@ -20,16 +20,16 @@ package me.proton.core.drive.drivelink.domain.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
|
||||
interface DriveLinkRepository {
|
||||
|
||||
fun getDriveLink(linkId: LinkId): Flow<DriveLink?>
|
||||
|
||||
fun getDriveLinks(parentId: FolderId, fromIndex: Int, count: Int): Flow<List<DriveLink>>
|
||||
fun getDriveLinks(parentId: ParentId, fromIndex: Int, count: Int): Flow<List<DriveLink>>
|
||||
|
||||
fun getDriveLinksCount(parentId: FolderId): Flow<Int>
|
||||
fun getDriveLinksCount(parentId: ParentId): Flow<Int>
|
||||
|
||||
fun getDriveLinks(linkIds: List<LinkId>): Flow<List<DriveLink>>
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ open class BaseGetFeatureFlag(
|
||||
private val configurationProvider: ConfigurationProvider,
|
||||
) {
|
||||
|
||||
protected val refreshAfterDuration: FeatureFlagCachePolicy = { featureFlagId ->
|
||||
val refreshAfterDuration: FeatureFlagCachePolicy = { featureFlagId ->
|
||||
featureFlagRepository
|
||||
.getLastRefreshTimestamp(featureFlagId.userId)
|
||||
.isOlderThen(configurationProvider.featureFlagFreshDuration)
|
||||
|
||||
-1
@@ -221,7 +221,6 @@ fun PreviewFileInfoContent() {
|
||||
passphraseSignature = "signature",
|
||||
contentKeyPacket = "contentKeyPacket",
|
||||
contentKeyPacketSignature = null,
|
||||
isFavorite = false,
|
||||
attributes = Attributes(0),
|
||||
permissions = Permissions(0),
|
||||
state = Link.State.ACTIVE,
|
||||
|
||||
+3
-3
@@ -24,8 +24,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.files.data.operation.move.worker.MoveFileWorker
|
||||
import me.proton.core.drive.files.domain.operation.FileOperationManager
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.SelectLinks
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -39,7 +39,7 @@ class FileOperationManagerImpl @Inject constructor(
|
||||
|
||||
private val String.uniqueMoveWorkName: String get() = "moving=$this"
|
||||
|
||||
override suspend fun changeParent(userId: UserId, linkIds: List<LinkId>, folderId: FolderId, allowUndo: Boolean) {
|
||||
override suspend fun changeParent(userId: UserId, linkIds: List<LinkId>, parentId: ParentId, allowUndo: Boolean) {
|
||||
val selectionId = selectLinks(linkIds).getOrThrow()
|
||||
workManager.enqueueUniqueWork(
|
||||
selectionId.id.uniqueMoveWorkName,
|
||||
@@ -47,7 +47,7 @@ class FileOperationManagerImpl @Inject constructor(
|
||||
MoveFileWorker.getWorkRequest(
|
||||
userId = userId,
|
||||
selectionId = selectionId,
|
||||
folderId = folderId,
|
||||
parentId = parentId,
|
||||
allowUndo = allowUndo,
|
||||
)
|
||||
)
|
||||
|
||||
+34
-14
@@ -36,9 +36,9 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.base.domain.api.ProtonApiCode
|
||||
import me.proton.core.drive.base.data.workmanager.addTags
|
||||
import me.proton.core.drive.base.data.workmanager.onProtonHttpException
|
||||
import me.proton.core.drive.base.domain.api.ProtonApiCode
|
||||
import me.proton.core.drive.base.domain.extension.resultValueOrNull
|
||||
import me.proton.core.drive.base.domain.log.LogTag
|
||||
import me.proton.core.drive.base.domain.usecase.BroadcastMessages
|
||||
@@ -46,9 +46,10 @@ import me.proton.core.drive.drivelink.domain.entity.DriveLink
|
||||
import me.proton.core.drive.drivelink.selection.domain.usecase.GetSelectedDriveLinks
|
||||
import me.proton.core.drive.files.domain.operation.notification.MoveFileExtra
|
||||
import me.proton.core.drive.files.domain.usecase.ChangeParent
|
||||
import me.proton.core.drive.link.domain.entity.AlbumId
|
||||
import me.proton.core.drive.link.domain.entity.Folder
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
import me.proton.core.drive.link.domain.usecase.GetLink
|
||||
import me.proton.core.drive.link.selection.domain.entity.SelectionId
|
||||
import me.proton.core.drive.link.selection.domain.usecase.DeselectLinks
|
||||
@@ -73,16 +74,14 @@ class MoveFileWorker @AssistedInject constructor(
|
||||
|
||||
private val userId = UserId(requireNotNull(inputData.getString(KEY_USER_ID)))
|
||||
private val selectionId = SelectionId(requireNotNull(inputData.getString(KEY_SELECTION_ID)))
|
||||
private val moveToFolderId = FolderId(
|
||||
ShareId(userId, requireNotNull(inputData.getString(KEY_MOVE_TO_SHARE_ID))),
|
||||
requireNotNull(inputData.getString(KEY_MOVE_TO_FOLDER_ID)),
|
||||
)
|
||||
private val shareId = ShareId(userId, requireNotNull(inputData.getString(KEY_MOVE_TO_SHARE_ID)))
|
||||
private val moveToParentId = getMoveToParentId()
|
||||
private val allowUndo = inputData.getBoolean(KEY_ALLOW_UNDO, true)
|
||||
|
||||
override suspend fun doWork(): Result = supervisorScope {
|
||||
val driveLinks = getSelectedDriveLinks(selectionId).first()
|
||||
val folder = getLink(moveToFolderId).resultValueOrNull()
|
||||
if (driveLinks.isEmpty() || folder == null) {
|
||||
val parentId = getLink(moveToParentId).resultValueOrNull()?.id
|
||||
if (driveLinks.isEmpty() || parentId == null || parentId !is ParentId) {
|
||||
broadcastMessages(
|
||||
userId = userId,
|
||||
message = applicationContext.getString(I18N.string.file_operation_error_occurred_moving_file),
|
||||
@@ -94,7 +93,7 @@ class MoveFileWorker @AssistedInject constructor(
|
||||
val succeeded: MutableList<DriveLink> = Collections.synchronizedList(arrayListOf())
|
||||
val deferred = driveLinks.map { driveLink ->
|
||||
async {
|
||||
changeParent(driveLink.id, folder.id)
|
||||
changeParent(driveLink.id, parentId)
|
||||
.onSuccess {
|
||||
succeeded.add(driveLink)
|
||||
}
|
||||
@@ -108,7 +107,7 @@ class MoveFileWorker @AssistedInject constructor(
|
||||
extra = MoveFileExtra(
|
||||
userId = userId,
|
||||
links = listOf(driveLink.parentId to driveLink.id),
|
||||
folderId = moveToFolderId,
|
||||
parentId = moveToParentId,
|
||||
allowUndo = allowUndo,
|
||||
exception = error,
|
||||
)
|
||||
@@ -126,7 +125,7 @@ class MoveFileWorker @AssistedInject constructor(
|
||||
extra = MoveFileExtra(
|
||||
userId = userId,
|
||||
links = succeeded.map { driveLink -> driveLink.parentId to driveLink.id },
|
||||
folderId = moveToFolderId,
|
||||
parentId = moveToParentId,
|
||||
allowUndo = allowUndo,
|
||||
)
|
||||
)
|
||||
@@ -170,18 +169,30 @@ class MoveFileWorker @AssistedInject constructor(
|
||||
}
|
||||
} ?: false
|
||||
|
||||
private fun getMoveToParentId(): ParentId =
|
||||
inputData.getString(KEY_MOVE_TO_FOLDER_ID)
|
||||
?.let { folderId ->
|
||||
FolderId(shareId, folderId)
|
||||
}
|
||||
?: inputData.getString(KEY_MOVE_TO_ALBUM_ID)
|
||||
?.let { albumId ->
|
||||
AlbumId(shareId, albumId)
|
||||
}
|
||||
?: error("Parent not found")
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_USER_ID = "KEY_USER_ID"
|
||||
private const val KEY_SELECTION_ID = "KEY_SELECTION_ID"
|
||||
private const val KEY_MOVE_TO_FOLDER_ID = "KEY_MOVE_TO_FOLDER_ID"
|
||||
private const val KEY_MOVE_TO_ALBUM_ID = "KEY_MOVE_TO_ALBUM_ID"
|
||||
private const val KEY_MOVE_TO_SHARE_ID = "KEY_MOVE_TO_SHARE_ID"
|
||||
private const val KEY_ALLOW_UNDO = "KEY_ALLOW_UNDO"
|
||||
|
||||
fun getWorkRequest(
|
||||
userId: UserId,
|
||||
selectionId: SelectionId,
|
||||
folderId: LinkId,
|
||||
parentId: ParentId,
|
||||
allowUndo: Boolean,
|
||||
tags: Collection<String> = emptyList(),
|
||||
): OneTimeWorkRequest = OneTimeWorkRequest.Builder(MoveFileWorker::class.java)
|
||||
@@ -189,8 +200,17 @@ class MoveFileWorker @AssistedInject constructor(
|
||||
Data.Builder()
|
||||
.putString(KEY_USER_ID, userId.id)
|
||||
.putString(KEY_SELECTION_ID, selectionId.id)
|
||||
.putString(KEY_MOVE_TO_FOLDER_ID, folderId.id)
|
||||
.putString(KEY_MOVE_TO_SHARE_ID, folderId.shareId.id)
|
||||
.apply {
|
||||
putString(
|
||||
when (parentId) {
|
||||
is FolderId -> KEY_MOVE_TO_FOLDER_ID
|
||||
is AlbumId -> KEY_MOVE_TO_ALBUM_ID
|
||||
},
|
||||
parentId.id,
|
||||
)
|
||||
|
||||
}
|
||||
.putString(KEY_MOVE_TO_SHARE_ID, parentId.shareId.id)
|
||||
.putBoolean(KEY_ALLOW_UNDO, allowUndo)
|
||||
.build()
|
||||
)
|
||||
|
||||
+6
-6
@@ -38,17 +38,17 @@ class FileOperationActionProvider @Inject constructor(
|
||||
|
||||
private fun MoveFileExtra.provideAction(): ActionProvider.Action? = if (exception != null) {
|
||||
ActionProvider.Action(I18N.string.files_operation_retry_action) {
|
||||
moveFile(userId, links.map { pair -> pair.second }, folderId)
|
||||
moveFile(userId, links.map { pair -> pair.second }, parentId)
|
||||
}
|
||||
} else if (allowUndo) {
|
||||
ActionProvider.Action(I18N.string.files_operation_undo_action) {
|
||||
val folderChildren = links.groupBy { pair -> pair.first }
|
||||
folderChildren.keys.forEach { originalFolderId ->
|
||||
originalFolderId?.let {
|
||||
val children = links.groupBy { pair -> pair.first }
|
||||
children.keys.forEach { originalParentId ->
|
||||
originalParentId?.let {
|
||||
moveFile(
|
||||
userId = userId,
|
||||
linkIds = folderChildren[originalFolderId]!!.map { pair -> pair.second },
|
||||
folderId = originalFolderId,
|
||||
linkIds = children[originalParentId]!!.map { pair -> pair.second },
|
||||
parentId = originalParentId,
|
||||
allowUndo = false,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -19,10 +19,10 @@
|
||||
package me.proton.core.drive.files.domain.operation
|
||||
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.drive.link.domain.entity.FolderId
|
||||
import me.proton.core.drive.link.domain.entity.LinkId
|
||||
import me.proton.core.drive.link.domain.entity.ParentId
|
||||
|
||||
interface FileOperationManager {
|
||||
|
||||
suspend fun changeParent(userId: UserId, linkIds: List<LinkId>, folderId: FolderId, allowUndo: Boolean)
|
||||
suspend fun changeParent(userId: UserId, linkIds: List<LinkId>, parentId: ParentId, allowUndo: Boolean)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user