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:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:name=".FairScanApp"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.FairScan" 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.Navigation
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.rememberCameraPermissionState 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.AboutScreen
import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.DocumentScreen 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.HomeScreen
import org.fairscan.app.ui.screens.LibrariesScreen import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.camera.CameraEvent 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.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 import org.opencv.android.OpenCVLoader
private const val PDF_MIME_TYPE = "application/pdf" private const val PDF_MIME_TYPE = "application/pdf"
@@ -70,10 +72,11 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
initLibraries() initLibraries()
val viewModel: MainViewModel by viewModels { MainViewModel.getFactory(this) } 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 cameraViewModel: CameraViewModel by viewModels { CameraViewModel.getFactory(this) }
val exportViewModel: ExportViewModel by viewModels { ExportViewModel.getFactory(this) }
lifecycleScope.launch(Dispatchers.IO) {
exportViewModel.cleanUpOldPdfs(1000 * 3600)
}
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -88,7 +91,7 @@ class MainActivity : ComponentActivity() {
val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle() val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState() val cameraPermission = rememberCameraPermissionState()
val savePdf = { savePdf(viewModel.getFinalPdf(), viewModel) } val savePdf = { savePdf(exportViewModel.getFinalPdf(), viewModel, exportViewModel) }
val storagePermissionLauncher = rememberLauncherForActivityResult( val storagePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { isGranted -> ) { isGranted ->
@@ -146,12 +149,12 @@ class MainActivity : ComponentActivity() {
ExportScreenWrapper( ExportScreenWrapper(
navigation = navigation, navigation = navigation,
pdfActions = PdfGenerationActions( pdfActions = PdfGenerationActions(
startGeneration = viewModel::startPdfGeneration, startGeneration = exportViewModel::startPdfGeneration,
setFilename = viewModel::setFilename, setFilename = exportViewModel::setFilename,
uiStateFlow = viewModel.pdfUiState, uiStateFlow = exportViewModel.pdfUiState,
sharePdf = { sharePdf(viewModel.getFinalPdf(), viewModel) }, sharePdf = { sharePdf(exportViewModel.getFinalPdf(), exportViewModel) },
savePdf = { checkPermissionThen(storagePermissionLauncher, savePdf) }, savePdf = { checkPermissionThen(storagePermissionLauncher, savePdf) },
openPdf = { openPdf(viewModel.pdfUiState.value.savedFileUri) } openPdf = { openPdf(exportViewModel.pdfUiState.value.savedFileUri) }
), ),
onCloseScan = { onCloseScan = {
viewModel.startNewDocument() 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) if (generatedPdf == null)
return return
viewModel.setPdfAsShared() 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) if (generatedPdf == null)
return return
val appScope = CoroutineScope(Dispatchers.IO) val appScope = CoroutineScope(Dispatchers.IO)
val context = this val context = this
appScope.launch { appScope.launch {
try { try {
val targetFile = viewModel.saveFile(generatedPdf.file) val targetFile = exportViewModel.saveFile(generatedPdf.file)
viewModel.addRecentDocument(targetFile.absolutePath, generatedPdf.pageCount) viewModel.addRecentDocument(targetFile.absolutePath, generatedPdf.pageCount)
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->

View File

@@ -17,44 +17,29 @@ package org.fairscan.app
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Environment
import android.util.Log
import androidx.core.net.toUri
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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.ImageRepository
import org.fairscan.app.data.PdfFileManager
import org.fairscan.app.data.recentDocumentsDataStore 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.NavigationState
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.PdfGenerationUiState
import org.fairscan.app.ui.state.RecentDocumentUiState import org.fairscan.app.ui.state.RecentDocumentUiState
import java.io.File import java.io.File
const val THUMBNAIL_SIZE_DP = 120
class MainViewModel( class MainViewModel(
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val pdfFileManager: PdfFileManager,
private val recentDocumentsDataStore: DataStore<RecentDocuments>, private val recentDocumentsDataStore: DataStore<RecentDocuments>,
): ViewModel() { ): ViewModel() {
@@ -62,15 +47,9 @@ class MainViewModel(
fun getFactory(context: Context) = object : ViewModelProvider.Factory { fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T { override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val density = context.resources.displayMetrics.density val app = context.applicationContext as FairScanApp
val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
return MainViewModel( return MainViewModel(
ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx), app.appContainer.imageRepository,
PdfFileManager(
File(context.cacheDir, "pdfs"),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter()
),
context.recentDocumentsDataStore, context.recentDocumentsDataStore,
) as T ) as T
} }
@@ -137,85 +116,6 @@ class MainViewModel(
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } 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>> = val recentDocuments: StateFlow<List<RecentDocumentUiState>> =
recentDocumentsDataStore.data.map { recentDocumentsDataStore.data.map {
it.documentsList.map { it.documentsList.map {
@@ -256,13 +156,3 @@ class MainViewModel(
_pageIds.value = imageRepository.imageIds() _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 * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * 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.content.Context
import android.text.format.Formatter 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import org.fairscan.app.R
import org.fairscan.app.data.GeneratedPdf import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.ui.Navigation 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.AboutScreenNavButton
import org.fairscan.app.ui.components.BackButton import org.fairscan.app.ui.components.BackButton
import org.fairscan.app.ui.components.MainActionButton 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.isLandscape
import org.fairscan.app.ui.components.pageCountText import org.fairscan.app.ui.components.pageCountText
import org.fairscan.app.ui.dummyNavigation import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.state.PdfGenerationUiState
import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.app.ui.theme.FairScanTheme
import java.io.File import java.io.File
import java.text.SimpleDateFormat 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,
)