Skip to content

Commit

Permalink
Merge pull request #80 from Infomaniak/file-picker-transfer
Browse files Browse the repository at this point in the history
Add basic file picker logic backbone to new transfers
  • Loading branch information
LunarX authored Oct 8, 2024
2 parents d4d19a7 + ba3df97 commit df82d9a
Show file tree
Hide file tree
Showing 7 changed files with 387 additions and 23 deletions.
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

0 comments on commit df82d9a

Please sign in to comment.