Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic file picker logic backbone to new transfers #80

Merged
merged 18 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Infomaniak SwissTransfer - Android
* Copyright (C) 2024 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.swisstransfer.ui.screen.newtransfer

import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class NewTransferViewModel @Inject constructor(private val transferFilesManager: TransferFilesManager) : ViewModel() {

private val _transferFiles = MutableStateFlow<List<TransferFile>>(emptyList())
val transferFiles: StateFlow<List<TransferFile>> = _transferFiles

private val _failedTransferFileCount = MutableSharedFlow<Int>()
val failedTransferFileCount: SharedFlow<Int> = _failedTransferFileCount

fun addFiles(uris: List<Uri>) {
viewModelScope.launch {
val alreadyUsedFileNames = buildSet { transferFiles.value.forEach { add(it.fileName) } }

val newTransferFiles = transferFilesManager.getTransferFiles(uris, alreadyUsedFileNames)

_transferFiles.value += newTransferFiles
_failedTransferFileCount.emit(uris.count() - newTransferFiles.count())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Infomaniak SwissTransfer - Android
* Copyright (C) 2024 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.swisstransfer.ui.screen.newtransfer

import android.net.Uri

data class TransferFile(val fileName: String, val size: Long, val uri: Uri)
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Infomaniak SwissTransfer - Android
* Copyright (C) 2024 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.swisstransfer.ui.screen.newtransfer

import android.content.ContentResolver
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.OpenableColumns
import com.infomaniak.swisstransfer.ui.utils.FileNameUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class TransferFilesManager @Inject constructor(@ApplicationContext private val appContext: Context) {
fun getTransferFiles(uris: List<Uri>, alreadyUsedFileNames: Set<String>): MutableSet<TransferFile> {
val currentUsedFileNames = alreadyUsedFileNames.toMutableSet()
val transferFiles = mutableSetOf<TransferFile>()

uris.forEach { uri ->
getTransferFile(uri, currentUsedFileNames)?.let { transferFile ->
currentUsedFileNames += transferFile.fileName
transferFiles += transferFile
}
}

return transferFiles
}

private fun getTransferFile(uri: Uri, alreadyUsedFileNames: Set<String>): TransferFile? {
val contentResolver: ContentResolver = appContext.contentResolver
val cursor: Cursor? = contentResolver.query(uri, null, null, null, null)

return cursor?.getFileNameAndSize()?.let { (name, size) ->
val uniqueName = FileNameUtils.postfixExistingFileNames(name, alreadyUsedFileNames)
TransferFile(uniqueName, size, uri)
}
}

private fun Cursor.getFileNameAndSize(): Pair<String, Long>? = use {
if (it.moveToFirst()) {
val displayNameColumnIndex = it.getColumnIndexOrNull(OpenableColumns.DISPLAY_NAME) ?: return null
val fileName = it.getString(displayNameColumnIndex)

val fileSizeColumnIndex = it.getColumnIndexOrNull(OpenableColumns.SIZE) ?: return null
val fileSize = it.getLong(fileSizeColumnIndex)

fileName to fileSize
} else {
null
}
}

private fun Cursor.getColumnIndexOrNull(column: String): Int? {
return getColumnIndex(column).takeIf { it != -1 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,53 @@
*/
package com.infomaniak.swisstransfer.ui.screen.newtransfer.importfiles

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.infomaniak.swisstransfer.R
import com.infomaniak.swisstransfer.ui.components.*
import com.infomaniak.swisstransfer.ui.images.AppImages.AppIcons
import com.infomaniak.swisstransfer.ui.images.AppImages.AppIllus
import com.infomaniak.swisstransfer.ui.images.icons.Add
import com.infomaniak.swisstransfer.ui.images.illus.MascotWithMagnifyingGlass
import com.infomaniak.swisstransfer.ui.screen.newtransfer.NewTransferViewModel
import com.infomaniak.swisstransfer.ui.screen.newtransfer.TransferFile
import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme
import com.infomaniak.swisstransfer.ui.utils.PreviewLargeWindow
import com.infomaniak.swisstransfer.ui.utils.PreviewSmallWindow

@Composable
fun ImportFilesScreen(navigateToTransferTypeScreen: () -> Unit, closeActivity: () -> Unit) {
fun ImportFilesScreen(
newTransferViewModel: NewTransferViewModel = hiltViewModel<NewTransferViewModel>(),
navigateToTransferTypeScreen: () -> Unit,
closeActivity: () -> Unit,
) {
val transferFiles by newTransferViewModel.transferFiles.collectAsStateWithLifecycle()
ImportFilesScreen({ transferFiles }, newTransferViewModel::addFiles, navigateToTransferTypeScreen, closeActivity)
}

@Composable
private fun ImportFilesScreen(
transferFiles: () -> List<TransferFile>,
addFiles: (List<Uri>) -> Unit,
navigateToTransferTypeScreen: () -> Unit,
closeActivity: () -> Unit,
) {
var showUploadSourceChoiceBottomSheet by rememberSaveable { mutableStateOf(true) }
var isNextButtonEnabled by rememberSaveable { mutableStateOf(false) }
val isNextButtonEnabled by remember { derivedStateOf { transferFiles().isNotEmpty() } }

val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris: List<Uri> ->
addFiles(uris)
}

BottomStickyButtonScaffold(
topBar = {
Expand All @@ -52,10 +79,7 @@ fun ImportFilesScreen(navigateToTransferTypeScreen: () -> Unit, closeActivity: (
titleRes = R.string.buttonAddFiles,
imageVector = AppIcons.Add,
style = ButtonType.TERTIARY,
onClick = {
showUploadSourceChoiceBottomSheet = true
isNextButtonEnabled = true // TODO: Move this where it should be
},
onClick = { showUploadSourceChoiceBottomSheet = true },
)
},
bottomButton = { modifier ->
Expand All @@ -67,14 +91,24 @@ fun ImportFilesScreen(navigateToTransferTypeScreen: () -> Unit, closeActivity: (
)
},
content = {
EmptyState(
icon = AppIllus.MascotWithMagnifyingGlass,
title = R.string.noFileTitle,
description = R.string.noFileDescription,
)
if (transferFiles().isEmpty()) {
EmptyState(
icon = AppIllus.MascotWithMagnifyingGlass,
title = R.string.noFileTitle,
description = R.string.noFileDescription,
)
} else {
LazyColumn {
items(items = transferFiles(), key = { it.fileName }) { file ->
Text(text = "${file.fileName} - ${file.size}")
}
}
}

UploadSourceChoiceBottomSheet(
isBottomSheetVisible = { showUploadSourceChoiceBottomSheet },
onDismissRequest = { showUploadSourceChoiceBottomSheet = false },
onFilePickerClicked = { filePickerLauncher.launch(arrayOf("*/*")) },
closeBottomSheet = { showUploadSourceChoiceBottomSheet = false },
)
},
)
Expand All @@ -85,6 +119,6 @@ fun ImportFilesScreen(navigateToTransferTypeScreen: () -> Unit, closeActivity: (
@Composable
private fun ImportFilesScreenPreview() {
SwissTransferTheme {
ImportFilesScreen({}, {})
ImportFilesScreen({ emptyList() }, {}, navigateToTransferTypeScreen = {}, closeActivity = {})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,30 +38,40 @@ import com.infomaniak.swisstransfer.ui.utils.PreviewSmallWindow
@Composable
fun UploadSourceChoiceBottomSheet(
isBottomSheetVisible: () -> Boolean,
onDismissRequest: () -> Unit,
onFilePickerClicked: () -> Unit,
closeBottomSheet: () -> Unit,
) {
if (isBottomSheetVisible()) {
SwissTransferBottomSheet(
onDismissRequest = onDismissRequest,
onDismissRequest = closeBottomSheet,
titleRes = R.string.transferUploadSourceChoiceTitle,
content = {
Column {
BottomSheetItem(
imageVector = AppIcons.Camera,
titleRes = R.string.transferUploadSourceChoiceCamera,
onClick = { /* TODO */ },
onClick = {
closeBottomSheet()
/* TODO */
},
)
HorizontalDivider(Modifier.padding(horizontal = Margin.Medium))
BottomSheetItem(
imageVector = AppIcons.PolaroidLandscape,
titleRes = R.string.transferUploadSourceChoiceGallery,
onClick = { /* TODO */ },
onClick = {
closeBottomSheet()
/* TODO */
},
)
HorizontalDivider(Modifier.padding(horizontal = Margin.Medium))
BottomSheetItem(
imageVector = AppIcons.Folder,
titleRes = R.string.transferUploadSourceChoiceFiles,
onClick = { /* TODO */ },
onClick = {
closeBottomSheet()
onFilePickerClicked()
},
)
}
},
Expand All @@ -75,7 +85,11 @@ fun UploadSourceChoiceBottomSheet(
private fun UploadSourceChoiceBottomSheetPreview() {
SwissTransferTheme {
Surface {
UploadSourceChoiceBottomSheet(isBottomSheetVisible = { true }, onDismissRequest = {})
UploadSourceChoiceBottomSheet(
isBottomSheetVisible = { true },
onFilePickerClicked = {},
closeBottomSheet = {}
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Infomaniak SwissTransfer - Android
* Copyright (C) 2024 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.swisstransfer.ui.utils

object FileNameUtils {

fun postfixExistingFileNames(fileName: String, existingFileNames: Set<String>): String {
return if (fileName in existingFileNames) {
val postfixedFileName = PostfixedFileName.fromFileName(fileName)

while (postfixedFileName.fullName() in existingFileNames) {
postfixedFileName.incrementPostfix()
}

postfixedFileName.fullName()
} else {
fileName
}
}

private data class PostfixedFileName(
private val start: String,
private var postfixNumber: Int,
private val end: String,
private val extension: String,
) {
fun incrementPostfix() {
postfixNumber += 1
}

fun fullName(): String = "$start$postfixNumber$end$extension"

companion object {
fun fromFileName(fileName: String): PostfixedFileName {
val (name, ext) = splitNameAndExtension(fileName)

return PostfixedFileName(
start = "$name(",
postfixNumber = 1,
end = ")",
extension = ext,
)
}

private fun splitNameAndExtension(fileName: String): Pair<String, String> {
val dotIndex = fileName.lastIndexOf('.')

// If there's no dot or it's the first/last character, return the whole name and an empty extension
return if (dotIndex == -1) {
fileName to ""
} else {
fileName.substring(0, dotIndex) to fileName.substring(dotIndex)
}
}
}
}
}
Loading
Loading