pdf build
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.vaultdrop.mobile.data.preferences
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Racine VaultDrop : le dossier SAF unique et obligatoire choisi au premier
|
||||||
|
* lancement. Tous les exports (assemblage PDF) y sont écrits directement,
|
||||||
|
* sans sélecteur — la permission persistante sur l'arbre est acquise à ce
|
||||||
|
* moment-là.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DefaultRootStore @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences =
|
||||||
|
context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun get(): String? = prefs.getString(KEY_ROOT_RESOURCE_ID, null)
|
||||||
|
|
||||||
|
fun set(resourceId: String) {
|
||||||
|
prefs.edit().putString(KEY_ROOT_RESOURCE_ID, resourceId).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
prefs.edit().remove(KEY_ROOT_RESOURCE_ID).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FILE_NAME = "vaultdrop_prefs"
|
||||||
|
private const val KEY_ROOT_RESOURCE_ID = "vaultdrop.default_root_resource_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -36,6 +36,9 @@ class FolderRepository @Inject constructor(
|
|||||||
suspend fun getFolder(resourceId: String): FolderEntity? =
|
suspend fun getFolder(resourceId: String): FolderEntity? =
|
||||||
folderDao.getByResourceId(resourceId)
|
folderDao.getByResourceId(resourceId)
|
||||||
|
|
||||||
|
/** Récupère un dossier par son uri SAF (utilisé pour retrouver la racine par défaut). */
|
||||||
|
suspend fun getByUri(uri: String): FolderEntity? = folderDao.getByUri(uri)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch `GET /files/folders` et upsert dans Room. Les dossiers cloud
|
* Fetch `GET /files/folders` et upsert dans Room. Les dossiers cloud
|
||||||
* n'ont pas d'uri (uri = NULL → cloud-only). En V1 le serveur ne renvoie
|
* n'ont pas d'uri (uri = NULL → cloud-only). En V1 le serveur ne renvoie
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ class SyncViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Id Room d'une racine SAF à partir de son uri (après un `importRoot`). */
|
||||||
|
suspend fun rootResourceId(uri: String): String? =
|
||||||
|
folderRepository.getByUri(uri)?.resourceId
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */
|
/** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */
|
||||||
const val INTERVAL_MS = 30_000L
|
const val INTERVAL_MS = 30_000L
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.vaultdrop.mobile.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État du mode multi-sélection (long-press pour entrer, tap pour cocher).
|
||||||
|
* Volatile : perdu à la rotation, ce qui est acceptable pour une sélection
|
||||||
|
* transitoire.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class SelectionState {
|
||||||
|
var active by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var ids by mutableStateOf<Set<String>>(emptySet())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun start(id: String) {
|
||||||
|
active = true
|
||||||
|
ids = setOf(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggle(id: String) {
|
||||||
|
ids = if (id in ids) ids - id else ids + id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
active = false
|
||||||
|
ids = emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberSelectionState(): SelectionState = remember { SelectionState() }
|
||||||
|
|
||||||
|
/** Pastille de sélection affichée sur une carte en mode multi-sélection. */
|
||||||
|
@Composable
|
||||||
|
fun SelectionStatusIcon(selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (selected) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
+106
@@ -12,6 +12,7 @@ import androidx.compose.foundation.BorderStroke
|
|||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -41,7 +42,12 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -63,6 +69,7 @@ import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
|||||||
import com.vaultdrop.mobile.ui.components.rememberSelectionState
|
import com.vaultdrop.mobile.ui.components.rememberSelectionState
|
||||||
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
||||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -79,10 +86,47 @@ fun FolderListScreen(
|
|||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val importState by syncViewModel.importState.collectAsStateWithLifecycle()
|
val importState by syncViewModel.importState.collectAsStateWithLifecycle()
|
||||||
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
||||||
|
val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val folderLabel = stringResource(R.string.folder)
|
val folderLabel = stringResource(R.string.folder)
|
||||||
val selection = rememberSelectionState()
|
val selection = rememberSelectionState()
|
||||||
|
|
||||||
|
// Racine par défaut : dossier VaultDrop choisi au premier lancement.
|
||||||
|
val defaultRootLabel = stringResource(R.string.default_root_folder_label)
|
||||||
|
var pendingDefaultPick by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
val pickDefaultRootLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri: Uri? ->
|
||||||
|
if (uri == null) return@rememberLauncherForActivityResult
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pendingDefaultPick = uri
|
||||||
|
syncViewModel.importRoot(uri = uri.toString(), name = defaultRootLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attend la création de la racine (importRoot la sauvegarde en Room puis
|
||||||
|
// lance le walk) et l'enregistre comme racine par défaut.
|
||||||
|
LaunchedEffect(pendingDefaultPick) {
|
||||||
|
val uri = pendingDefaultPick ?: return@LaunchedEffect
|
||||||
|
val target = uri.toString()
|
||||||
|
var attempts = 0
|
||||||
|
while (attempts < 100) { // ~10 s max
|
||||||
|
val id = syncViewModel.rootResourceId(target)
|
||||||
|
if (id != null) {
|
||||||
|
pendingDefaultPick = null
|
||||||
|
viewModel.setDefaultRoot(id)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
delay(100)
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
pendingDefaultPick = null
|
||||||
|
}
|
||||||
|
|
||||||
val pickFolderLauncher = rememberLauncherForActivityResult(
|
val pickFolderLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.OpenDocumentTree(),
|
ActivityResultContracts.OpenDocumentTree(),
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
@@ -100,6 +144,15 @@ fun FolderListScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (defaultRootId == null) {
|
||||||
|
DefaultRootOnboarding(
|
||||||
|
importing = importState.isImporting,
|
||||||
|
error = importState.error,
|
||||||
|
onPickFolder = { pickDefaultRootLauncher.launch(null) },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = selection.active) { selection.clear() }
|
BackHandler(enabled = selection.active) { selection.clear() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -165,6 +218,59 @@ fun FolderListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Écran obligatoire du premier lancement : choix/création de la racine VaultDrop. */
|
||||||
|
@Composable
|
||||||
|
private fun DefaultRootOnboarding(
|
||||||
|
importing: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onPickFolder: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.default_root_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.default_root_message),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onPickFolder,
|
||||||
|
enabled = !importing,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.default_root_pick))
|
||||||
|
}
|
||||||
|
if (importing) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
error?.let { message ->
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Nom affiché d'un dossier SAF via DocumentsContract (DISPLAY_NAME). */
|
/** Nom affiché d'un dossier SAF via DocumentsContract (DISPLAY_NAME). */
|
||||||
private fun Uri.displayName(context: Context): String? = runCatching {
|
private fun Uri.displayName(context: Context): String? = runCatching {
|
||||||
val docId = DocumentsContract.getTreeDocumentId(this)
|
val docId = DocumentsContract.getTreeDocumentId(this)
|
||||||
|
|||||||
+12
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.vaultdrop.mobile.auth.TokenProvider
|
import com.vaultdrop.mobile.auth.TokenProvider
|
||||||
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
import com.vaultdrop.mobile.data.local.referenceDate
|
import com.vaultdrop.mobile.data.local.referenceDate
|
||||||
|
import com.vaultdrop.mobile.data.preferences.DefaultRootStore
|
||||||
import com.vaultdrop.mobile.data.remote.ApiException
|
import com.vaultdrop.mobile.data.remote.ApiException
|
||||||
import com.vaultdrop.mobile.data.repository.FileRepository
|
import com.vaultdrop.mobile.data.repository.FileRepository
|
||||||
import com.vaultdrop.mobile.data.repository.FolderRepository
|
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||||
@@ -27,16 +28,27 @@ class FolderListViewModel @Inject constructor(
|
|||||||
private val folderRepository: FolderRepository,
|
private val folderRepository: FolderRepository,
|
||||||
private val fileRepository: FileRepository,
|
private val fileRepository: FileRepository,
|
||||||
private val tokenProvider: TokenProvider,
|
private val tokenProvider: TokenProvider,
|
||||||
|
private val defaultRootStore: DefaultRootStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(FolderListUiState())
|
private val _uiState = MutableStateFlow(FolderListUiState())
|
||||||
val uiState: StateFlow<FolderListUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<FolderListUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
/** Racine VaultDrop obligatoire : null = premier lancement (onboarding). */
|
||||||
|
private val _defaultRootId = MutableStateFlow(defaultRootStore.get())
|
||||||
|
val defaultRootId: StateFlow<String?> = _defaultRootId.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observeFiles()
|
observeFiles()
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enregistre la racine choisie au premier lancement (persistant). */
|
||||||
|
fun setDefaultRoot(resourceId: String) {
|
||||||
|
defaultRootStore.set(resourceId)
|
||||||
|
_defaultRootId.value = resourceId
|
||||||
|
}
|
||||||
|
|
||||||
/** Grille d'accueil : tous les fichiers visibles, groupés par jour (date de référence). */
|
/** Grille d'accueil : tous les fichiers visibles, groupés par jour (date de référence). */
|
||||||
private fun observeFiles() {
|
private fun observeFiles() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ fun NavGraph(
|
|||||||
PdfBuilderScreen(
|
PdfBuilderScreen(
|
||||||
initialResourceIds = ids,
|
initialResourceIds = ids,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
|
onOpenDocument = { id ->
|
||||||
|
navController.navigate(Routes.document(id)) {
|
||||||
|
popUpTo(Routes.PDF_BUILDER) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+315
@@ -0,0 +1,315 @@
|
|||||||
|
package com.vaultdrop.mobile.ui.pdfbuilder
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.pdf.PdfDocument
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import android.text.Layout
|
||||||
|
import android.text.StaticLayout
|
||||||
|
import android.text.TextPaint
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.domain.FileCategory
|
||||||
|
import com.vaultdrop.mobile.ui.components.categoryValue
|
||||||
|
import com.vaultdrop.mobile.ui.document.content.DocumentContent
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Générateur de PDF d'assemblage (APIs système, aucune dépendance).
|
||||||
|
*
|
||||||
|
* - PDF source → pages au format source conservé, rendues en bitmap
|
||||||
|
* (`PdfRenderer`, mode PRINT) puis dessinées dans le nouveau document.
|
||||||
|
* - Image → page A4 portrait/paysage selon l'orientation, image centrée.
|
||||||
|
* - Note → texte paginé vectoriel (`StaticLayout` : sélectionnable, léger).
|
||||||
|
*
|
||||||
|
* Les bitmaps sont recyclés page par page et la génération est annulable
|
||||||
|
* (`ensureActive` par page). L'échec d'un item n'arrête pas le lot : les IDs
|
||||||
|
* échoués sont retournés dans [PdfBuildResult].
|
||||||
|
*/
|
||||||
|
class PdfBuilderEngine @Inject constructor() {
|
||||||
|
|
||||||
|
suspend fun buildPdf(
|
||||||
|
context: Context,
|
||||||
|
items: List<PdfBuilderItem>,
|
||||||
|
outputFile: File,
|
||||||
|
onProgress: (Float) -> Unit,
|
||||||
|
): PdfBuildResult = withContext(Dispatchers.IO) {
|
||||||
|
val failed = ArrayList<String>()
|
||||||
|
val total = items.size.coerceAtLeast(1)
|
||||||
|
|
||||||
|
outputFile.outputStream().buffered().use { out ->
|
||||||
|
val document = PdfDocument()
|
||||||
|
try {
|
||||||
|
items.forEachIndexed { index, item ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
onProgress(index.toFloat() / total)
|
||||||
|
val ok = when (item) {
|
||||||
|
is PdfBuilderItem.FileItem -> renderFileItem(context, document, item.file)
|
||||||
|
is PdfBuilderItem.NoteItem -> renderNoteItem(document, item.body)
|
||||||
|
}
|
||||||
|
if (!ok) failed += item.id
|
||||||
|
onProgress((index + 1).toFloat() / total)
|
||||||
|
}
|
||||||
|
document.writeTo(out)
|
||||||
|
} finally {
|
||||||
|
document.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PdfBuildResult(outputFile = outputFile, failedIds = failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ PDF
|
||||||
|
|
||||||
|
private suspend fun renderFileItem(
|
||||||
|
context: Context,
|
||||||
|
document: PdfDocument,
|
||||||
|
file: FileEntity,
|
||||||
|
): Boolean = when (file.categoryValue()) {
|
||||||
|
FileCategory.PDF -> appendPdf(context, document, file)
|
||||||
|
FileCategory.IMAGE -> appendImage(context, document, file)
|
||||||
|
FileCategory.TEXT -> appendTextFile(context, document, file)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun appendPdf(
|
||||||
|
context: Context,
|
||||||
|
document: PdfDocument,
|
||||||
|
file: FileEntity,
|
||||||
|
): Boolean {
|
||||||
|
val uri = file.uri ?: return false
|
||||||
|
val pfd = DocumentContent.openFileDescriptor(context.contentResolver, uri)
|
||||||
|
?: return false
|
||||||
|
var renderer: PdfRenderer? = null
|
||||||
|
var appended = false
|
||||||
|
try {
|
||||||
|
renderer = runCatching { PdfRenderer(pfd) }.getOrNull() ?: return false
|
||||||
|
repeat(renderer.pageCount) { pageIndex ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val page = renderer.openPage(pageIndex)
|
||||||
|
try {
|
||||||
|
val srcW = page.width.toFloat()
|
||||||
|
val srcH = page.height.toFloat()
|
||||||
|
if (srcW <= 0f || srcH <= 0f) return@repeat
|
||||||
|
|
||||||
|
val maxDim = maxOf(srcW, srcH)
|
||||||
|
val scale = minOf(1f, MAX_PAGE_POINTS / maxDim, MAX_IMAGE_PX / maxDim)
|
||||||
|
val pageW = (srcW * scale).toInt().coerceAtLeast(1)
|
||||||
|
val pageH = (srcH * scale).toInt().coerceAtLeast(1)
|
||||||
|
|
||||||
|
val bitmap = Bitmap.createBitmap(pageW, pageH, Bitmap.Config.ARGB_8888)
|
||||||
|
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_PRINT)
|
||||||
|
appendBitmapPage(document, bitmap, pageW, pageH, dst = null)
|
||||||
|
bitmap.recycle()
|
||||||
|
appended = true
|
||||||
|
} finally {
|
||||||
|
page.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appended
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return appended
|
||||||
|
} finally {
|
||||||
|
renderer?.close()
|
||||||
|
pfd.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Image
|
||||||
|
|
||||||
|
private fun appendImage(
|
||||||
|
context: Context,
|
||||||
|
document: PdfDocument,
|
||||||
|
file: FileEntity,
|
||||||
|
): Boolean { val uri = file.uri ?: return false
|
||||||
|
|
||||||
|
val sample = runCatching {
|
||||||
|
DocumentContent.openInputStream(context.contentResolver, uri)?.use { input ->
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeStream(input, null, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return false
|
||||||
|
var sampler = 1
|
||||||
|
while (bounds.outWidth / sampler > MAX_IMAGE_PX ||
|
||||||
|
bounds.outHeight / sampler > MAX_IMAGE_PX
|
||||||
|
) {
|
||||||
|
sampler *= 2
|
||||||
|
}
|
||||||
|
sampler
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: return false
|
||||||
|
|
||||||
|
val bitmap = DocumentContent.openInputStream(context.contentResolver, uri)
|
||||||
|
?.use { input ->
|
||||||
|
val options = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||||
|
BitmapFactory.decodeStream(input, null, options)
|
||||||
|
}
|
||||||
|
?: return false
|
||||||
|
|
||||||
|
val landscape = bitmap.width > bitmap.height
|
||||||
|
val pageWidth = if (landscape) A4_H.toInt() else A4_W.toInt()
|
||||||
|
val pageHeight = if (landscape) A4_W.toInt() else A4_H.toInt()
|
||||||
|
val dst = fitRect(bitmap.width, bitmap.height, pageWidth.toFloat(), pageHeight.toFloat())
|
||||||
|
appendBitmapPage(document, bitmap, pageWidth, pageHeight, dst)
|
||||||
|
bitmap.recycle()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Texte
|
||||||
|
|
||||||
|
private suspend fun appendTextFile(
|
||||||
|
context: Context,
|
||||||
|
document: PdfDocument,
|
||||||
|
file: FileEntity,
|
||||||
|
): Boolean {
|
||||||
|
val uri = file.uri ?: return false
|
||||||
|
val stream = DocumentContent.openInputStream(context.contentResolver, uri)
|
||||||
|
?: return false
|
||||||
|
stream.use { input ->
|
||||||
|
val buffer = ByteArray(MAX_NOTE_CHARS * 4)
|
||||||
|
var offset = 0
|
||||||
|
while (offset < buffer.size) {
|
||||||
|
val read = input.read(buffer, offset, buffer.size - offset)
|
||||||
|
if (read < 0) break
|
||||||
|
offset += read
|
||||||
|
}
|
||||||
|
val body = String(buffer, 0, offset, Charsets.UTF_8)
|
||||||
|
.removePrefix("\uFEFF")
|
||||||
|
.take(MAX_NOTE_CHARS)
|
||||||
|
return renderNoteItem(document, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun renderNoteItem(
|
||||||
|
document: PdfDocument,
|
||||||
|
body: String,
|
||||||
|
): Boolean {
|
||||||
|
val clean = body.replace('\u0000', ' ').trim()
|
||||||
|
if (clean.isEmpty()) return true
|
||||||
|
|
||||||
|
val textWidth = (A4_W - NOTE_MARGIN * 2f).toInt()
|
||||||
|
val textHeight = A4_H - NOTE_MARGIN * 2f
|
||||||
|
val paint = TextPaint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
color = Color.BLACK
|
||||||
|
textSize = NOTE_TEXT_SIZE
|
||||||
|
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = clean
|
||||||
|
var pages = 0
|
||||||
|
try {
|
||||||
|
while (remaining.isNotEmpty() && pages < MAX_NOTE_PAGES) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val layout = buildLayout(remaining, textWidth, paint)
|
||||||
|
if (layout.lineCount == 0) break
|
||||||
|
|
||||||
|
val lineHeight: Float = if (layout.lineCount >= 2) {
|
||||||
|
(layout.getLineTop(1) - layout.getLineTop(0)).toFloat()
|
||||||
|
} else {
|
||||||
|
paint.fontSpacing
|
||||||
|
}
|
||||||
|
if (lineHeight <= 0f) break
|
||||||
|
|
||||||
|
val linesThatFit = (textHeight / lineHeight).toInt().coerceAtLeast(1)
|
||||||
|
val lastLine = (linesThatFit - 1).coerceAtMost(layout.lineCount - 1)
|
||||||
|
val endOffset = layout.getLineEnd(lastLine)
|
||||||
|
if (endOffset <= 0) break
|
||||||
|
|
||||||
|
val pageText = remaining.substring(0, endOffset)
|
||||||
|
val pageLayout = buildLayout(pageText, textWidth, paint)
|
||||||
|
val info = PdfDocument.PageInfo.Builder(A4_W.toInt(), A4_H.toInt(), pages).create()
|
||||||
|
val page = document.startPage(info)
|
||||||
|
page.canvas.drawColor(Color.WHITE)
|
||||||
|
page.canvas.save()
|
||||||
|
page.canvas.translate(NOTE_MARGIN, NOTE_MARGIN)
|
||||||
|
pageLayout.draw(page.canvas)
|
||||||
|
page.canvas.restore()
|
||||||
|
document.finishPage(page)
|
||||||
|
|
||||||
|
remaining = remaining.substring(endOffset).trimStart()
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLayout(text: String, width: Int, paint: TextPaint): StaticLayout =
|
||||||
|
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
|
||||||
|
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||||
|
.setLineSpacing(0f, LINE_SPACING)
|
||||||
|
.setIncludePad(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Common
|
||||||
|
|
||||||
|
private fun appendBitmapPage(
|
||||||
|
document: PdfDocument,
|
||||||
|
bitmap: Bitmap,
|
||||||
|
pageWidth: Int,
|
||||||
|
pageHeight: Int,
|
||||||
|
dst: RectF?,
|
||||||
|
) {
|
||||||
|
val info = PdfDocument.PageInfo.Builder(
|
||||||
|
pageWidth.coerceAtLeast(1),
|
||||||
|
pageHeight.coerceAtLeast(1),
|
||||||
|
document.pages.size,
|
||||||
|
).create()
|
||||||
|
val page = document.startPage(info)
|
||||||
|
page.canvas.drawColor(Color.WHITE)
|
||||||
|
if (dst != null) {
|
||||||
|
page.canvas.drawBitmap(bitmap, null, dst, SCALE_PAINT)
|
||||||
|
} else {
|
||||||
|
page.canvas.drawBitmap(
|
||||||
|
bitmap,
|
||||||
|
null,
|
||||||
|
RectF(0f, 0f, pageWidth.toFloat(), pageHeight.toFloat()),
|
||||||
|
SCALE_PAINT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
document.finishPage(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rectangle d'affichage d'une image centrée dans une page, marges incluses. */
|
||||||
|
private fun fitRect(bmpW: Int, bmpH: Int, pageW: Float, pageH: Float): RectF {
|
||||||
|
val availW = pageW - IMAGE_MARGIN * 2f
|
||||||
|
val availH = pageH - IMAGE_MARGIN * 2f
|
||||||
|
val scale = minOf(availW / bmpW, availH / bmpH).coerceAtMost(1f)
|
||||||
|
val w = bmpW * scale
|
||||||
|
val h = bmpH * scale
|
||||||
|
val left = (pageW - w) / 2f
|
||||||
|
val top = (pageH - h) / 2f
|
||||||
|
return RectF(left, top, left + w, top + h)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val A4_W = 595f
|
||||||
|
private const val A4_H = 842f
|
||||||
|
private const val NOTE_MARGIN = 48f
|
||||||
|
private const val NOTE_TEXT_SIZE = 11f
|
||||||
|
private const val LINE_SPACING = 1.4f
|
||||||
|
private const val IMAGE_MARGIN = 40f
|
||||||
|
|
||||||
|
/** Bornes mémoire : pages et bitmaps jamais au-delà. */
|
||||||
|
private const val MAX_PAGE_POINTS = 1200f
|
||||||
|
private const val MAX_IMAGE_PX = 3000
|
||||||
|
private const val MAX_NOTE_CHARS = 100_000
|
||||||
|
private const val MAX_NOTE_PAGES = 30
|
||||||
|
|
||||||
|
private val SCALE_PAINT = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PdfBuildResult(
|
||||||
|
val outputFile: File,
|
||||||
|
val failedIds: List<String>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.vaultdrop.mobile.ui.pdfbuilder
|
||||||
|
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.domain.FileCategory
|
||||||
|
import com.vaultdrop.mobile.ui.components.categoryValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item de l'assemblage PDF : un fichier lisible localement (PDF / image / texte)
|
||||||
|
* ou une note de texte libre. L'ordre de la liste est l'ordre du PDF final.
|
||||||
|
*/
|
||||||
|
sealed interface PdfBuilderItem {
|
||||||
|
val id: String
|
||||||
|
|
||||||
|
data class FileItem(
|
||||||
|
val file: FileEntity,
|
||||||
|
val failed: Boolean = false,
|
||||||
|
) : PdfBuilderItem {
|
||||||
|
override val id: String get() = file.resourceId
|
||||||
|
}
|
||||||
|
|
||||||
|
data class NoteItem(
|
||||||
|
override val id: String,
|
||||||
|
val body: String,
|
||||||
|
) : PdfBuilderItem
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fichier pouvant entrer dans un assemblage : copie locale + catégorie lisible. */
|
||||||
|
fun FileEntity.isPdfBuilderSource(): Boolean =
|
||||||
|
uri != null && categoryValue() in PDF_BUILDER_CATEGORIES
|
||||||
|
|
||||||
|
private val PDF_BUILDER_CATEGORIES =
|
||||||
|
setOf(FileCategory.PDF, FileCategory.IMAGE, FileCategory.TEXT)
|
||||||
+492
@@ -0,0 +1,492 @@
|
|||||||
|
package com.vaultdrop.mobile.ui.pdfbuilder
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.MergeType
|
||||||
|
import androidx.compose.material.icons.filled.TextSnippet
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vaultdrop.mobile.R
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PdfBuilderScreen(
|
||||||
|
initialResourceIds: List<String>,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenDocument: (String) -> Unit,
|
||||||
|
viewModel: PdfBuilderViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val items by viewModel.items.collectAsStateWithLifecycle()
|
||||||
|
val buildPhase by viewModel.buildState.collectAsStateWithLifecycle()
|
||||||
|
val availableFiles by viewModel.availableFiles.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
var showNoteDialog by remember { mutableStateOf(false) }
|
||||||
|
var showFileSheet by remember { mutableStateOf(false) }
|
||||||
|
var showNameDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Nom suggéré par défaut : date du jour au format jour-mois-année.
|
||||||
|
val defaultPdfName = remember {
|
||||||
|
"${LocalDate.now().format(DEFAULT_DATE_FORMAT)}.pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(initialResourceIds) {
|
||||||
|
viewModel.loadInitial(initialResourceIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(buildPhase) {
|
||||||
|
when (buildPhase) {
|
||||||
|
is BuildPhase.Ready -> showNameDialog = true
|
||||||
|
is BuildPhase.Saved -> onOpenDocument((buildPhase as BuildPhase.Saved).resourceId)
|
||||||
|
is BuildPhase.Failed -> {
|
||||||
|
snackbarHostState.showSnackbar((buildPhase as BuildPhase.Failed).message)
|
||||||
|
viewModel.acknowledgeFailure()
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.pdf_builder_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
) {
|
||||||
|
when (buildPhase) {
|
||||||
|
is BuildPhase.Building -> LinearProgressIndicator(
|
||||||
|
progress = { (buildPhase as BuildPhase.Building).progress },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showNameDialog) {
|
||||||
|
NamePdfDialog(
|
||||||
|
defaultName = defaultPdfName,
|
||||||
|
onDismiss = { showNameDialog = false },
|
||||||
|
onConfirm = { name ->
|
||||||
|
showNameDialog = false
|
||||||
|
viewModel.saveAndImport(name)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showNoteDialog) {
|
||||||
|
AddNoteDialog(
|
||||||
|
onDismiss = { showNoteDialog = false },
|
||||||
|
onConfirm = { body ->
|
||||||
|
viewModel.addNote(body)
|
||||||
|
showNoteDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFileSheet) {
|
||||||
|
SelectFilesSheet(
|
||||||
|
files = availableFiles,
|
||||||
|
existingIds = items.mapNotNull { item ->
|
||||||
|
(item as? PdfBuilderItem.FileItem)?.file?.resourceId
|
||||||
|
}.toSet(),
|
||||||
|
onDismiss = { showFileSheet = false },
|
||||||
|
onConfirm = { ids ->
|
||||||
|
viewModel.addFiles(ids.toList())
|
||||||
|
showFileSheet = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pdf_builder_empty),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(items, key = { it.id }) { item ->
|
||||||
|
BuilderItemRow(
|
||||||
|
item = item,
|
||||||
|
onMoveUp = { viewModel.moveUp(item.id) },
|
||||||
|
onMoveDown = { viewModel.moveDown(item.id) },
|
||||||
|
onRemove = { viewModel.removeItem(item.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BuilderActions(
|
||||||
|
onAddFile = { showFileSheet = true },
|
||||||
|
onAddNote = { showNoteDialog = true },
|
||||||
|
onAssemble = {
|
||||||
|
if (buildPhase is BuildPhase.Ready) showNameDialog = true
|
||||||
|
else viewModel.generate()
|
||||||
|
},
|
||||||
|
enabled = items.isNotEmpty(),
|
||||||
|
building = buildPhase is BuildPhase.Building,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SelectFilesSheet(
|
||||||
|
files: List<FileEntity>,
|
||||||
|
existingIds: Set<String>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (Set<String>) -> Unit,
|
||||||
|
) {
|
||||||
|
var selected by remember(files) { mutableStateOf(existingIds.toMutableSet()) }
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pdf_builder_pick_files),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items(files, key = { it.resourceId }) { file ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
if (file.resourceId in selected) selected.remove(file.resourceId)
|
||||||
|
else selected.add(file.resourceId)
|
||||||
|
}
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
FileCategoryIcon(file = file, size = 24.dp)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = file.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatSize(file.size),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (file.resourceId in selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { onConfirm(selected) },
|
||||||
|
enabled = selected.isNotEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.pdf_builder_add_files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NamePdfDialog(
|
||||||
|
defaultName: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf(defaultName) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.pdf_builder_name_title)) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
placeholder = { Text(stringResource(R.string.pdf_builder_name_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(name.trim()) },
|
||||||
|
enabled = name.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.pdf_builder_name_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.pdf_builder_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddNoteDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var body by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.pdf_builder_add_note)) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = body,
|
||||||
|
onValueChange = { body = it },
|
||||||
|
placeholder = { Text(stringResource(R.string.pdf_builder_note_hint)) },
|
||||||
|
minLines = 5,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(body) },
|
||||||
|
enabled = body.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.pdf_builder_add))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.pdf_builder_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BuilderItemRow(
|
||||||
|
item: PdfBuilderItem,
|
||||||
|
onMoveUp: () -> Unit,
|
||||||
|
onMoveDown: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
when (item) {
|
||||||
|
is PdfBuilderItem.FileItem -> FileCategoryIcon(file = item.file, size = 24.dp)
|
||||||
|
is PdfBuilderItem.NoteItem -> Icon(
|
||||||
|
imageVector = Icons.Filled.TextSnippet,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
val isFailed = item is PdfBuilderItem.FileItem && item.failed
|
||||||
|
Text(
|
||||||
|
text = when (item) {
|
||||||
|
is PdfBuilderItem.FileItem -> item.file.name
|
||||||
|
is PdfBuilderItem.NoteItem -> stringResource(R.string.pdf_builder_note)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (isFailed) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = when (item) {
|
||||||
|
is PdfBuilderItem.FileItem -> if (isFailed) {
|
||||||
|
stringResource(R.string.pdf_builder_unreadable)
|
||||||
|
} else {
|
||||||
|
formatSize(item.file.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PdfBuilderItem.NoteItem -> item.body
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.take(80)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (isFailed) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
IconButton(onClick = onMoveUp) {
|
||||||
|
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.pdf_builder_move_up))
|
||||||
|
}
|
||||||
|
IconButton(onClick = onMoveDown) {
|
||||||
|
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.pdf_builder_move_down))
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.pdf_builder_remove))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BuilderActions(
|
||||||
|
onAddFile: () -> Unit,
|
||||||
|
onAddNote: () -> Unit,
|
||||||
|
onAssemble: () -> Unit,
|
||||||
|
enabled: Boolean,
|
||||||
|
building: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onAddFile,
|
||||||
|
enabled = !building,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.pdf_builder_add_files))
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onAddNote,
|
||||||
|
enabled = !building,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.TextSnippet, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.pdf_builder_add_note))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onAssemble,
|
||||||
|
enabled = enabled && !building,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.MergeType, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.pdf_builder_assemble))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DEFAULT_DATE_FORMAT: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("dd-MM-yyyy")
|
||||||
|
|
||||||
|
private fun formatSize(bytes: Long): String {
|
||||||
|
if (bytes < 1_024) return "$bytes o"
|
||||||
|
if (bytes < 1_024 * 1_024) {
|
||||||
|
return String.format(Locale.getDefault(), "%.1f ko", bytes / 1_024f)
|
||||||
|
}
|
||||||
|
return String.format(Locale.getDefault(), "%.1f Mo", bytes / (1_024f * 1_024f))
|
||||||
|
}
|
||||||
+328
@@ -0,0 +1,328 @@
|
|||||||
|
package com.vaultdrop.mobile.ui.pdfbuilder
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vaultdrop.mobile.R
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.data.preferences.DefaultRootStore
|
||||||
|
import com.vaultdrop.mobile.data.repository.FileRepository
|
||||||
|
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||||
|
import com.vaultdrop.mobile.data.repository.SaveFileInput
|
||||||
|
import com.vaultdrop.mobile.domain.GenerateId
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel de l'assemblage PDF : panier ordonné (fichiers + notes), génération
|
||||||
|
* vers le cache puis sauvegarde (ACTION_CREATE_DOCUMENT) avec ré-import Room.
|
||||||
|
*
|
||||||
|
* L'URI créé est attaché au dossier SAF importé qui le contient (par préfixe de
|
||||||
|
* documentId). Sinon il atterrit dans un dossier racine local « PDF générés ».
|
||||||
|
* Dans les deux cas `FileRepository.saveLocalFile` déduplique par URI : un
|
||||||
|
* rescan SAF ne crée jamais de doublon.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class PdfBuilderViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val fileRepository: FileRepository,
|
||||||
|
private val folderRepository: FolderRepository,
|
||||||
|
private val defaultRootStore: DefaultRootStore,
|
||||||
|
private val generateId: GenerateId,
|
||||||
|
private val engine: PdfBuilderEngine,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _items = MutableStateFlow<List<PdfBuilderItem>>(emptyList())
|
||||||
|
val items: StateFlow<List<PdfBuilderItem>> = _items.asStateFlow()
|
||||||
|
|
||||||
|
private val _buildState = MutableStateFlow<BuildPhase>(BuildPhase.Idle)
|
||||||
|
val buildState: StateFlow<BuildPhase> = _buildState.asStateFlow()
|
||||||
|
|
||||||
|
private val _availableFiles = MutableStateFlow<List<FileEntity>>(emptyList())
|
||||||
|
val availableFiles: StateFlow<List<FileEntity>> = _availableFiles.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
fileRepository.observeAllVisible().collect { files ->
|
||||||
|
_availableFiles.value = files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge la sélection initiale (ids des fichiers cochés sur les écrans de liste). */
|
||||||
|
fun loadInitial(resourceIds: List<String>) {
|
||||||
|
if (resourceIds.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val loaded = resourceIds.mapNotNull { fileRepository.getFile(it) }
|
||||||
|
.map { file ->
|
||||||
|
PdfBuilderItem.FileItem(file = file, failed = !file.isPdfBuilderSource())
|
||||||
|
}
|
||||||
|
_items.update { existing ->
|
||||||
|
existing + loaded.filterNot { item ->
|
||||||
|
existing.any { existingItem -> existingItem.id == item.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addFiles(resourceIds: List<String>) {
|
||||||
|
if (resourceIds.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val loaded = resourceIds.mapNotNull { fileRepository.getFile(it) }
|
||||||
|
.map { file ->
|
||||||
|
PdfBuilderItem.FileItem(file = file, failed = !file.isPdfBuilderSource())
|
||||||
|
}
|
||||||
|
_items.update { existing ->
|
||||||
|
existing + loaded.filterNot { item ->
|
||||||
|
existing.any { existingItem -> existingItem.id == item.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addNote(body: String) {
|
||||||
|
val clean = body.trim()
|
||||||
|
if (clean.isEmpty()) return
|
||||||
|
_items.update {
|
||||||
|
it + PdfBuilderItem.NoteItem(
|
||||||
|
id = generateId.newResourceId(),
|
||||||
|
body = clean.take(MAX_NOTE_CHARS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeItem(id: String) {
|
||||||
|
_items.update { list -> list.filterNot { it.id == id } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveUp(id: String) {
|
||||||
|
_items.update { list ->
|
||||||
|
val index = list.indexOfFirst { it.id == id }
|
||||||
|
if (index <= 0) list
|
||||||
|
else list.toMutableList().apply {
|
||||||
|
add(index - 1, removeAt(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveDown(id: String) {
|
||||||
|
_items.update { list ->
|
||||||
|
val index = list.indexOfFirst { it.id == id }
|
||||||
|
if (index < 0 || index >= list.size - 1) list
|
||||||
|
else list.toMutableList().apply {
|
||||||
|
add(index + 1, removeAt(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lance la génération vers un fichier cache ; émet les échecs sur les items. */
|
||||||
|
fun generate() {
|
||||||
|
val current = _buildState.value
|
||||||
|
if (current is BuildPhase.Building) return
|
||||||
|
if (_items.value.isEmpty()) {
|
||||||
|
_buildState.value = BuildPhase.Failed(context.getString(R.string.pdf_builder_no_items))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildState.value = BuildPhase.Building(0f)
|
||||||
|
val snapshot = _items.value
|
||||||
|
val cacheFile = File(context.cacheDir, "vaultdrop_build_${System.currentTimeMillis()}.pdf")
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
engine.buildPdf(
|
||||||
|
context = context,
|
||||||
|
items = snapshot,
|
||||||
|
outputFile = cacheFile,
|
||||||
|
onProgress = { p -> _buildState.value = BuildPhase.Building(p) },
|
||||||
|
)
|
||||||
|
}.onSuccess { result ->
|
||||||
|
if (result.failedIds.isNotEmpty()) {
|
||||||
|
val failedSet = result.failedIds.toHashSet()
|
||||||
|
_items.update { list ->
|
||||||
|
list.map { item ->
|
||||||
|
if (item is PdfBuilderItem.FileItem && item.id in failedSet) {
|
||||||
|
item.copy(failed = true)
|
||||||
|
} else item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_buildState.value = BuildPhase.Ready(result.outputFile)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Timber.e(e, "PDF build failed")
|
||||||
|
cacheFile.delete()
|
||||||
|
_buildState.value = BuildPhase.Failed(
|
||||||
|
context.getString(R.string.pdf_builder_error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consomme un échec (le message a été affiché). */
|
||||||
|
fun acknowledgeFailure() {
|
||||||
|
if (_buildState.value is BuildPhase.Failed) _buildState.value = BuildPhase.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écrit le cache directement dans la racine VaultDrop (DocumentsContract.
|
||||||
|
* createDocument sur l'arbre) puis ré-importe le fichier dans Room.
|
||||||
|
*
|
||||||
|
* Le nom est obligatoire (Option C) : en vide, l'export est refusé.
|
||||||
|
* `takePersistableUriPermission` est best-effort — l'insert Room ne dépend
|
||||||
|
* jamais de cette permission.
|
||||||
|
*/
|
||||||
|
fun saveAndImport(name: String) {
|
||||||
|
val ready = _buildState.value as? BuildPhase.Ready ?: return
|
||||||
|
val cacheFile = ready.file
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
_buildState.value = BuildPhase.Failed(
|
||||||
|
context.getString(R.string.pdf_builder_name_required),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val rootResourceId = defaultRootStore.get()
|
||||||
|
if (rootResourceId == null) {
|
||||||
|
_buildState.value = BuildPhase.Failed(
|
||||||
|
context.getString(R.string.pdf_builder_save_error),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
val root = folderRepository.getFolder(rootResourceId)
|
||||||
|
?: error("default root not found: $rootResourceId")
|
||||||
|
val rootUri = checkNotNull(Uri.parse(root.uri)) { "default root has no uri" }
|
||||||
|
|
||||||
|
val fileName = if (trimmed.lowercase().endsWith(".pdf")) trimmed else "$trimmed.pdf"
|
||||||
|
val createdUri = DocumentsContract.createDocument(
|
||||||
|
context.contentResolver,
|
||||||
|
rootUri,
|
||||||
|
"application/pdf",
|
||||||
|
fileName,
|
||||||
|
) ?: error("cannot create document in default root")
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
copyInto(createdUri, cacheFile, context.contentResolver)
|
||||||
|
}
|
||||||
|
|
||||||
|
val folderId = resolveTargetFolder(createdUri)
|
||||||
|
val actualName = displayName(createdUri) ?: fileName
|
||||||
|
val bytes = cacheFile.length()
|
||||||
|
val inserted = fileRepository.saveLocalFile(
|
||||||
|
input = SaveFileInput(
|
||||||
|
uri = createdUri.toString(),
|
||||||
|
name = actualName,
|
||||||
|
extension = "pdf",
|
||||||
|
size = bytes,
|
||||||
|
mimeType = "application/pdf",
|
||||||
|
lastModified = System.currentTimeMillis(),
|
||||||
|
exists = true,
|
||||||
|
syncStatus = "local",
|
||||||
|
),
|
||||||
|
folderResourceId = folderId,
|
||||||
|
)
|
||||||
|
cacheFile.delete()
|
||||||
|
|
||||||
|
// Best-effort : prolonge l'accès au-delà de l'intent initial.
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
createdUri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
}.onFailure { Timber.w(it, "persistable uri permission absent") }
|
||||||
|
|
||||||
|
inserted.resourceId
|
||||||
|
}.onSuccess { resourceId ->
|
||||||
|
_buildState.value = BuildPhase.Saved(resourceId)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Timber.e(e, "PDF save failed")
|
||||||
|
_buildState.value = BuildPhase.Failed(context.getString(R.string.pdf_builder_save_error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
private fun copyInto(uri: Uri, source: File, resolver: ContentResolver) {
|
||||||
|
val target = resolver.openOutputStream(uri)
|
||||||
|
?: error("cannot open output stream")
|
||||||
|
target.use { out ->
|
||||||
|
source.inputStream().use { input ->
|
||||||
|
input.copyTo(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayName(uri: Uri): String? = runCatching {
|
||||||
|
context.contentResolver.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(OpenableColumns.DISPLAY_NAME),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associe l'URI créé au dossier SAF importé qui le contient (par préfixe
|
||||||
|
* du documentId), sinon à un dossier racine local « PDF générés ».
|
||||||
|
*/
|
||||||
|
private suspend fun resolveTargetFolder(uri: Uri): String {
|
||||||
|
val documentId = runCatching { DocumentsContract.getDocumentId(uri) }.getOrNull()
|
||||||
|
if (documentId != null) {
|
||||||
|
folderRepository.getRootFolders().filter { it.uri != null }.forEach { folder ->
|
||||||
|
val treeDocId = runCatching {
|
||||||
|
DocumentsContract.getDocumentId(Uri.parse(folder.uri))
|
||||||
|
}.getOrNull()
|
||||||
|
if (treeDocId != null && documentId.startsWith("$treeDocId/")) {
|
||||||
|
return folder.resourceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return generatedFolderResourceId()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun generatedFolderResourceId(): String {
|
||||||
|
val label = context.getString(R.string.pdf_generated_folder)
|
||||||
|
folderRepository.getRootFolders()
|
||||||
|
.firstOrNull { it.uri == null && it.name == label && it.parentResourceId == null }
|
||||||
|
?.let { return it.resourceId }
|
||||||
|
return folderRepository.saveFolder(
|
||||||
|
input = com.vaultdrop.mobile.data.repository.SaveFolderInput(
|
||||||
|
uri = null,
|
||||||
|
name = label,
|
||||||
|
exists = true,
|
||||||
|
),
|
||||||
|
parentResourceId = null,
|
||||||
|
).resourceId
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_NOTE_CHARS = 100_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface BuildPhase {
|
||||||
|
data object Idle : BuildPhase
|
||||||
|
data class Building(val progress: Float) : BuildPhase
|
||||||
|
data class Ready(val file: File) : BuildPhase
|
||||||
|
data class Saved(val resourceId: String) : BuildPhase
|
||||||
|
data class Failed(val message: String) : BuildPhase
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
<string name="files">Files</string>
|
<string name="files">Files</string>
|
||||||
<string name="folder">Folder</string>
|
<string name="folder">Folder</string>
|
||||||
|
<string name="default_root_title">Choose your VaultDrop folder</string>
|
||||||
|
<string name="default_root_message">VaultDrop needs a folder to store all the PDFs it creates. You can also create a new VaultDrop folder right in the picker. This step is required, once only.</string>
|
||||||
|
<string name="default_root_pick">Choose my VaultDrop folder</string>
|
||||||
|
<string name="default_root_folder_label">VaultDrop</string>
|
||||||
<string name="add_folder">Add a folder</string>
|
<string name="add_folder">Add a folder</string>
|
||||||
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string>
|
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string>
|
||||||
<string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string>
|
<string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string>
|
||||||
@@ -96,6 +100,10 @@
|
|||||||
<string name="pdf_builder_no_items">Select at least one file or add a note.</string>
|
<string name="pdf_builder_no_items">Select at least one file or add a note.</string>
|
||||||
<string name="pdf_builder_error">Could not generate the PDF.</string>
|
<string name="pdf_builder_error">Could not generate the PDF.</string>
|
||||||
<string name="pdf_builder_save_error">Could not save the PDF.</string>
|
<string name="pdf_builder_save_error">Could not save the PDF.</string>
|
||||||
|
<string name="pdf_builder_name_required">The file name cannot be empty.</string>
|
||||||
|
<string name="pdf_builder_name_title">PDF name</string>
|
||||||
|
<string name="pdf_builder_name_hint">e.g. january_invoices</string>
|
||||||
|
<string name="pdf_builder_name_save">Save</string>
|
||||||
<string name="pdf_builder_saved">PDF created</string>
|
<string name="pdf_builder_saved">PDF created</string>
|
||||||
<string name="pdf_generated_folder">Generated PDFs</string>
|
<string name="pdf_generated_folder">Generated PDFs</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
<string name="app_name">VaultDrop</string>
|
<string name="app_name">VaultDrop</string>
|
||||||
|
|
||||||
<!-- Écran Fichiers -->
|
<!-- Écran Fichiers -->
|
||||||
|
<string name="default_root_title">Choisis ton dossier VaultDrop</string>
|
||||||
|
<string name="default_root_message">VaultDrop a besoin d\'un dossier pour stocker tous les PDF créés. Tu peux aussi créer un nouveau dossier VaultDrop directement dans le sélecteur. Cette étape est obligatoire, une seule fois.</string>
|
||||||
|
<string name="default_root_pick">Choisir mon dossier VaultDrop</string>
|
||||||
|
<string name="default_root_folder_label">VaultDrop</string>
|
||||||
<string name="files">Fichiers</string>
|
<string name="files">Fichiers</string>
|
||||||
<string name="folder">Dossier</string>
|
<string name="folder">Dossier</string>
|
||||||
<string name="add_folder">Ajouter un dossier</string>
|
<string name="add_folder">Ajouter un dossier</string>
|
||||||
@@ -104,6 +108,10 @@
|
|||||||
<string name="pdf_builder_no_items">Sélectionne au moins un fichier ou ajoute une note.</string>
|
<string name="pdf_builder_no_items">Sélectionne au moins un fichier ou ajoute une note.</string>
|
||||||
<string name="pdf_builder_error">Impossible de générer le PDF.</string>
|
<string name="pdf_builder_error">Impossible de générer le PDF.</string>
|
||||||
<string name="pdf_builder_save_error">Impossible d\'enregistrer le PDF.</string>
|
<string name="pdf_builder_save_error">Impossible d\'enregistrer le PDF.</string>
|
||||||
|
<string name="pdf_builder_name_required">Le nom du fichier ne peut pas être vide.</string>
|
||||||
|
<string name="pdf_builder_name_title">Nom du PDF</string>
|
||||||
|
<string name="pdf_builder_name_hint">ex. factures_janvier</string>
|
||||||
|
<string name="pdf_builder_name_save">Enregistrer</string>
|
||||||
<string name="pdf_builder_saved">PDF créé</string>
|
<string name="pdf_builder_saved">PDF créé</string>
|
||||||
<string name="pdf_generated_folder">PDF générés</string>
|
<string name="pdf_generated_folder">PDF générés</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user