Split MainViewModel: extract ExportViewModel

This commit is contained in:
Pierre-Yves Nicolas
2025-11-21 00:02:42 +01:00
committed by pynicolas
parent 15e3d9d917
commit 7c53dcface
6 changed files with 217 additions and 131 deletions

View File

@@ -15,6 +15,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name=".FairScanApp"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FairScan"

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app
import android.app.Application
import android.content.Context
import android.os.Environment
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.data.PdfFileManager
import org.fairscan.app.platform.AndroidPdfWriter
import org.fairscan.app.platform.OpenCvTransformations
import java.io.File
class FairScanApp : Application() {
lateinit var appContainer: AppContainer
override fun onCreate() {
super.onCreate()
appContainer = AppContainer(this)
}
}
const val THUMBNAIL_SIZE_DP = 120
class AppContainer(context: Context) {
private val density = context.resources.displayMetrics.density
private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
val imageRepository = ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx)
val pdfFileManager = PdfFileManager(
File(context.cacheDir, "pdfs"),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter()
)
}

View File

@@ -51,15 +51,17 @@ import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.rememberCameraPermissionState
import org.fairscan.app.ui.theme.FairScanTheme
import org.fairscan.app.ui.screens.AboutScreen
import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.DocumentScreen
import org.fairscan.app.ui.screens.ExportScreenWrapper
import org.fairscan.app.ui.screens.HomeScreen
import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.camera.CameraEvent
import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.export.ExportScreenWrapper
import org.fairscan.app.ui.screens.export.ExportViewModel
import org.fairscan.app.ui.screens.export.PdfGenerationActions
import org.fairscan.app.ui.theme.FairScanTheme
import org.opencv.android.OpenCVLoader
private const val PDF_MIME_TYPE = "application/pdf"
@@ -70,10 +72,11 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
initLibraries()
val viewModel: MainViewModel by viewModels { MainViewModel.getFactory(this) }
lifecycleScope.launch(Dispatchers.IO) {
viewModel.cleanUpOldPdfs(1000 * 3600)
}
val cameraViewModel: CameraViewModel by viewModels { CameraViewModel.getFactory(this) }
val exportViewModel: ExportViewModel by viewModels { ExportViewModel.getFactory(this) }
lifecycleScope.launch(Dispatchers.IO) {
exportViewModel.cleanUpOldPdfs(1000 * 3600)
}
enableEdgeToEdge()
setContent {
LaunchedEffect(Unit) {
@@ -88,7 +91,7 @@ class MainActivity : ComponentActivity() {
val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState()
val savePdf = { savePdf(viewModel.getFinalPdf(), viewModel) }
val savePdf = { savePdf(exportViewModel.getFinalPdf(), viewModel, exportViewModel) }
val storagePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
@@ -146,12 +149,12 @@ class MainActivity : ComponentActivity() {
ExportScreenWrapper(
navigation = navigation,
pdfActions = PdfGenerationActions(
startGeneration = viewModel::startPdfGeneration,
setFilename = viewModel::setFilename,
uiStateFlow = viewModel.pdfUiState,
sharePdf = { sharePdf(viewModel.getFinalPdf(), viewModel) },
startGeneration = exportViewModel::startPdfGeneration,
setFilename = exportViewModel::setFilename,
uiStateFlow = exportViewModel.pdfUiState,
sharePdf = { sharePdf(exportViewModel.getFinalPdf(), exportViewModel) },
savePdf = { checkPermissionThen(storagePermissionLauncher, savePdf) },
openPdf = { openPdf(viewModel.pdfUiState.value.savedFileUri) }
openPdf = { openPdf(exportViewModel.pdfUiState.value.savedFileUri) }
),
onCloseScan = {
viewModel.startNewDocument()
@@ -170,7 +173,7 @@ class MainActivity : ComponentActivity() {
}
}
private fun sharePdf(generatedPdf: GeneratedPdf?, viewModel: MainViewModel) {
private fun sharePdf(generatedPdf: GeneratedPdf?, viewModel: ExportViewModel) {
if (generatedPdf == null)
return
viewModel.setPdfAsShared()
@@ -205,14 +208,18 @@ class MainActivity : ComponentActivity() {
}
}
private fun savePdf(generatedPdf: GeneratedPdf?, viewModel: MainViewModel) {
private fun savePdf(
generatedPdf: GeneratedPdf?,
viewModel: MainViewModel,
exportViewModel: ExportViewModel
) {
if (generatedPdf == null)
return
val appScope = CoroutineScope(Dispatchers.IO)
val context = this
appScope.launch {
try {
val targetFile = viewModel.saveFile(generatedPdf.file)
val targetFile = exportViewModel.saveFile(generatedPdf.file)
viewModel.addRecentDocument(targetFile.absolutePath, generatedPdf.pageCount)
suspendCancellableCoroutine { continuation ->

View File

@@ -17,44 +17,29 @@ package org.fairscan.app
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Environment
import android.util.Log
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.data.PdfFileManager
import org.fairscan.app.data.recentDocumentsDataStore
import org.fairscan.app.platform.AndroidPdfWriter
import org.fairscan.app.platform.OpenCvTransformations
import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.PdfGenerationUiState
import org.fairscan.app.ui.state.RecentDocumentUiState
import java.io.File
const val THUMBNAIL_SIZE_DP = 120
class MainViewModel(
private val imageRepository: ImageRepository,
private val pdfFileManager: PdfFileManager,
private val recentDocumentsDataStore: DataStore<RecentDocuments>,
): ViewModel() {
@@ -62,15 +47,9 @@ class MainViewModel(
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val density = context.resources.displayMetrics.density
val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
val app = context.applicationContext as FairScanApp
return MainViewModel(
ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx),
PdfFileManager(
File(context.cacheDir, "pdfs"),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter()
),
app.appContainer.imageRepository,
context.recentDocumentsDataStore,
) as T
}
@@ -137,85 +116,6 @@ class MainViewModel(
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val imageIds = imageRepository.imageIds()
val jpegs = imageIds.asSequence()
.map { id -> imageRepository.getContent(id) }
.filterNotNull()
return@withContext pdfFileManager.generatePdf(jpegs)
}
private val _pdfUiState = MutableStateFlow(PdfGenerationUiState())
val pdfUiState: StateFlow<PdfGenerationUiState> = _pdfUiState.asStateFlow()
private var generationJob: Job? = null
private var desiredFilename: String = ""
fun setFilename(name: String) {
desiredFilename = name
}
fun startPdfGeneration() {
cancelPdfGeneration()
generationJob = viewModelScope.launch {
try {
val result = generatePdf()
_pdfUiState.update {
it.copy(
isGenerating = false,
generatedPdf = result
)
}
} catch (e: Exception) {
Log.e("FairScan", "PDF generation failed", e)
_pdfUiState.update {
it.copy(
isGenerating = false,
errorMessage = "PDF generation failed"
)
}
}
}
}
fun cancelPdfGeneration() {
generationJob?.cancel()
_pdfUiState.value = PdfGenerationUiState()
}
fun setPdfAsShared() {
_pdfUiState.update { it.copy(hasSharedPdf = true) }
}
fun getFinalPdf(): GeneratedPdf? {
val tempPdf = _pdfUiState.value.generatedPdf ?: return null
val tempFile = tempPdf.file
val fileName = PdfFileManager.addExtensionIfMissing(desiredFilename)
val newFile = File(tempFile.parentFile, fileName)
if (tempFile.absolutePath != newFile.absolutePath) {
if (newFile.exists()) newFile.delete()
val success = tempFile.renameTo(newFile)
if (!success) return null
_pdfUiState.update {
it.copy(generatedPdf = GeneratedPdf(
newFile, tempPdf.sizeInBytes, tempPdf.pageCount)
)
}
}
return _pdfUiState.value.generatedPdf
}
fun saveFile(pdfFile: File): File {
val copiedFile = pdfFileManager.copyToExternalDir(pdfFile)
_pdfUiState.update { it.copy(savedFileUri = copiedFile.toUri()) }
return copiedFile
}
fun cleanUpOldPdfs(thresholdInMillis: Int) {
pdfFileManager.cleanUpOldFiles(thresholdInMillis)
}
val recentDocuments: StateFlow<List<RecentDocumentUiState>> =
recentDocumentsDataStore.data.map {
it.documentsList.map {
@@ -256,13 +156,3 @@ class MainViewModel(
_pageIds.value = imageRepository.imageIds()
}
}
// TODO Move somewhere else: ViewModel should not depend on that
data class PdfGenerationActions(
val startGeneration: () -> Unit,
val setFilename: (String) -> Unit,
val uiStateFlow: StateFlow<PdfGenerationUiState>,// TODO is it ok to have that here?
val sharePdf: () -> Unit,
val savePdf: () -> Unit,
val openPdf: () -> Unit,
)

View File

@@ -12,7 +12,7 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens
package org.fairscan.app.ui.screens.export
import android.content.Context
import android.text.format.Formatter
@@ -66,11 +66,9 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import org.fairscan.app.R
import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.ui.Navigation
import org.fairscan.app.PdfGenerationActions
import org.fairscan.app.R
import org.fairscan.app.ui.state.PdfGenerationUiState
import org.fairscan.app.ui.components.AboutScreenNavButton
import org.fairscan.app.ui.components.BackButton
import org.fairscan.app.ui.components.MainActionButton
@@ -78,6 +76,7 @@ import org.fairscan.app.ui.components.NewDocumentDialog
import org.fairscan.app.ui.components.isLandscape
import org.fairscan.app.ui.components.pageCountText
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.state.PdfGenerationUiState
import org.fairscan.app.ui.theme.FairScanTheme
import java.io.File
import java.text.SimpleDateFormat

View File

@@ -0,0 +1,143 @@
/*
* Copyright 2025 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.export
import android.content.Context
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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 org.fairscan.app.FairScanApp
import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.data.PdfFileManager
import org.fairscan.app.ui.state.PdfGenerationUiState
import java.io.File
class ExportViewModel(
private val pdfFileManager: PdfFileManager,
private val imageRepository: ImageRepository,
): ViewModel() {
companion object {
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val app = context.applicationContext as FairScanApp
val pdfFileManager = app.appContainer.pdfFileManager
val imageRepository = app.appContainer.imageRepository
return ExportViewModel(pdfFileManager, imageRepository) as T
}
}
}
private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val imageIds = imageRepository.imageIds()
val jpegs = imageIds.asSequence()
.map { id -> imageRepository.getContent(id) }
.filterNotNull()
return@withContext pdfFileManager.generatePdf(jpegs)
}
private val _pdfUiState = MutableStateFlow(PdfGenerationUiState())
val pdfUiState: StateFlow<PdfGenerationUiState> = _pdfUiState.asStateFlow()
private var generationJob: Job? = null
private var desiredFilename: String = ""
fun setFilename(name: String) {
desiredFilename = name
}
fun startPdfGeneration() {
cancelPdfGeneration()
generationJob = viewModelScope.launch {
try {
val result = generatePdf()
_pdfUiState.update {
it.copy(
isGenerating = false,
generatedPdf = result
)
}
} catch (e: Exception) {
Log.e("FairScan", "PDF generation failed", e)
_pdfUiState.update {
it.copy(
isGenerating = false,
errorMessage = "PDF generation failed"
)
}
}
}
}
fun cancelPdfGeneration() {
generationJob?.cancel()
_pdfUiState.value = PdfGenerationUiState()
}
fun setPdfAsShared() {
_pdfUiState.update { it.copy(hasSharedPdf = true) }
}
fun getFinalPdf(): GeneratedPdf? {
val tempPdf = _pdfUiState.value.generatedPdf ?: return null
val tempFile = tempPdf.file
val fileName = PdfFileManager.addExtensionIfMissing(desiredFilename)
val newFile = File(tempFile.parentFile, fileName)
if (tempFile.absolutePath != newFile.absolutePath) {
if (newFile.exists()) newFile.delete()
val success = tempFile.renameTo(newFile)
if (!success) return null
_pdfUiState.update {
it.copy(generatedPdf = GeneratedPdf(
newFile, tempPdf.sizeInBytes, tempPdf.pageCount)
)
}
}
return _pdfUiState.value.generatedPdf
}
fun saveFile(pdfFile: File): File {
val copiedFile = pdfFileManager.copyToExternalDir(pdfFile)
_pdfUiState.update { it.copy(savedFileUri = copiedFile.toUri()) }
return copiedFile
}
fun cleanUpOldPdfs(thresholdInMillis: Int) {
pdfFileManager.cleanUpOldFiles(thresholdInMillis)
}
}
data class PdfGenerationActions(
val startGeneration: () -> Unit,
val setFilename: (String) -> Unit,
val uiStateFlow: StateFlow<PdfGenerationUiState>,// TODO is it ok to have that here?
val sharePdf: () -> Unit,
val savePdf: () -> Unit,
val openPdf: () -> Unit,
)