Split MainViewModel: extract ExportViewModel
This commit is contained in:
committed by
pynicolas
parent
15e3d9d917
commit
7c53dcface
@@ -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"
|
||||
|
||||
46
app/src/main/java/org/fairscan/app/FairScanApp.kt
Normal file
46
app/src/main/java/org/fairscan/app/FairScanApp.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user