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? =
|
||||
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
|
||||
* 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 {
|
||||
/** Cadence de la boucle — même valeur que `useSyncDevice(30_000)` Expo. */
|
||||
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.combinedClickable
|
||||
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
|
||||
@@ -41,7 +42,12 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.graphics.Color
|
||||
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.navigation.FloatingNavBar
|
||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -79,10 +86,47 @@ fun FolderListScreen(
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val importState by syncViewModel.importState.collectAsStateWithLifecycle()
|
||||
val connectionStatus by connectionStatusViewModel.status.collectAsStateWithLifecycle()
|
||||
val defaultRootId by viewModel.defaultRootId.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val folderLabel = stringResource(R.string.folder)
|
||||
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(
|
||||
ActivityResultContracts.OpenDocumentTree(),
|
||||
) { 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() }
|
||||
|
||||
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). */
|
||||
private fun Uri.displayName(context: Context): String? = runCatching {
|
||||
val docId = DocumentsContract.getTreeDocumentId(this)
|
||||
|
||||
+12
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vaultdrop.mobile.auth.TokenProvider
|
||||
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||
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.repository.FileRepository
|
||||
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||
@@ -27,16 +28,27 @@ class FolderListViewModel @Inject constructor(
|
||||
private val folderRepository: FolderRepository,
|
||||
private val fileRepository: FileRepository,
|
||||
private val tokenProvider: TokenProvider,
|
||||
private val defaultRootStore: DefaultRootStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(FolderListUiState())
|
||||
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 {
|
||||
observeFiles()
|
||||
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). */
|
||||
private fun observeFiles() {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -149,6 +149,11 @@ fun NavGraph(
|
||||
PdfBuilderScreen(
|
||||
initialResourceIds = ids,
|
||||
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="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="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>
|
||||
@@ -96,6 +100,10 @@
|
||||
<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_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_generated_folder">Generated PDFs</string>
|
||||
</resources>
|
||||
@@ -2,6 +2,10 @@
|
||||
<string name="app_name">VaultDrop</string>
|
||||
|
||||
<!-- É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="folder">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_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_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_generated_folder">PDF générés</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user