MainActivity shouldn't do the job of a ViewModel

This commit is contained in:
Pierre-Yves Nicolas
2025-11-23 19:05:20 +01:00
parent d4a3c78c23
commit f805201768
2 changed files with 82 additions and 39 deletions

View File

@@ -19,7 +19,6 @@ import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.PERMISSION_GRANTED import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.Q import android.os.Build.VERSION_CODES.Q
@@ -42,25 +41,23 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
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.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.screens.AboutScreen import org.fairscan.app.ui.screens.AboutScreen
import org.fairscan.app.ui.screens.DocumentScreen import org.fairscan.app.ui.screens.DocumentScreen
import org.fairscan.app.ui.screens.home.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.CameraScreen
import org.fairscan.app.ui.screens.camera.CameraViewModel import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.export.ExportEvent
import org.fairscan.app.ui.screens.export.ExportScreenWrapper import org.fairscan.app.ui.screens.export.ExportScreenWrapper
import org.fairscan.app.ui.screens.export.ExportViewModel import org.fairscan.app.ui.screens.export.ExportViewModel
import org.fairscan.app.ui.screens.export.PdfGenerationActions import org.fairscan.app.ui.screens.export.PdfGenerationActions
import org.fairscan.app.ui.screens.home.HomeScreen
import org.fairscan.app.ui.screens.home.HomeViewModel import org.fairscan.app.ui.screens.home.HomeViewModel
import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.app.ui.theme.FairScanTheme
import org.opencv.android.OpenCVLoader import org.opencv.android.OpenCVLoader
@@ -93,17 +90,34 @@ 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(exportViewModel.getFinalPdf(), homeViewModel, exportViewModel) }
val storagePermissionLauncher = rememberLauncherForActivityResult( val storagePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { isGranted -> ) { isGranted ->
if (isGranted) { if (isGranted) {
savePdf() exportViewModel.onSavePdfClicked()
} else { } else {
val message = getString(R.string.storage_permission_denied) val message = getString(R.string.storage_permission_denied)
Toast.makeText(context, message, Toast.LENGTH_SHORT).show() Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
} }
} }
LaunchedEffect(Unit) {
exportViewModel.events.collect { event ->
when (event) {
ExportEvent.RequestSavePdf -> {
checkPermissionThen(storagePermissionLauncher) {
exportViewModel.onRequestPdfSave(context, homeViewModel)
}
}
is ExportEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
ExportEvent.PdfSaved -> {
Toast.makeText(context, "PDF saved", Toast.LENGTH_SHORT).show()
}
}
}
}
FairScanTheme { FairScanTheme {
val navigation = Navigation( val navigation = Navigation(
toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) }, toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) },
@@ -155,7 +169,7 @@ class MainActivity : ComponentActivity() {
setFilename = exportViewModel::setFilename, setFilename = exportViewModel::setFilename,
uiStateFlow = exportViewModel.pdfUiState, uiStateFlow = exportViewModel.pdfUiState,
sharePdf = { sharePdf(exportViewModel.getFinalPdf(), exportViewModel) }, sharePdf = { sharePdf(exportViewModel.getFinalPdf(), exportViewModel) },
savePdf = { checkPermissionThen(storagePermissionLauncher, savePdf) }, savePdf = { exportViewModel.onSavePdfClicked() },
openPdf = { openPdf(exportViewModel.pdfUiState.value.savedFileUri) } openPdf = { openPdf(exportViewModel.pdfUiState.value.savedFileUri) }
), ),
onCloseScan = { onCloseScan = {
@@ -210,37 +224,6 @@ class MainActivity : ComponentActivity() {
} }
} }
private fun savePdf(
generatedPdf: GeneratedPdf?,
homeViewModel: HomeViewModel,
exportViewModel: ExportViewModel
) {
if (generatedPdf == null)
return
val appScope = CoroutineScope(Dispatchers.IO)
val context = this
appScope.launch {
try {
val targetFile = exportViewModel.saveFile(generatedPdf.file)
homeViewModel.addRecentDocument(targetFile.absolutePath, generatedPdf.pageCount)
suspendCancellableCoroutine { continuation ->
MediaScannerConnection.scanFile(
context,
arrayOf(targetFile.absolutePath),
arrayOf(PDF_MIME_TYPE)
) { _, _ -> continuation.resume(Unit) {} }
}
} catch (e: Exception) {
Log.e("FairScan", "Failed to save PDF", e)
withContext(Dispatchers.Main) {
Toast.makeText(context,
getString(R.string.error_save), Toast.LENGTH_SHORT).show()
}
}
}
}
private fun openPdf(fileUri: Uri?) { private fun openPdf(fileUri: Uri?) {
if (fileUri == null) return if (fileUri == null) return
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(

View File

@@ -15,6 +15,7 @@
package org.fairscan.app.ui.screens.export package org.fairscan.app.ui.screens.export
import android.content.Context import android.content.Context
import android.media.MediaScannerConnection
import android.util.Log import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -22,20 +23,33 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.fairscan.app.FairScanApp import org.fairscan.app.FairScanApp
import org.fairscan.app.data.GeneratedPdf 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.PdfFileManager
import org.fairscan.app.ui.screens.home.HomeViewModel
import org.fairscan.app.ui.state.PdfGenerationUiState import org.fairscan.app.ui.state.PdfGenerationUiState
import java.io.File import java.io.File
private const val PDF_MIME_TYPE = "application/pdf"
sealed interface ExportEvent {
data object RequestSavePdf : ExportEvent
data class ShowToast(val message: String) : ExportEvent
data object PdfSaved : ExportEvent
}
class ExportViewModel( class ExportViewModel(
private val pdfFileManager: PdfFileManager, private val pdfFileManager: PdfFileManager,
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
@@ -53,6 +67,9 @@ class ExportViewModel(
} }
} }
private val _events = MutableSharedFlow<ExportEvent>()
val events = _events.asSharedFlow()
private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) { private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val imageIds = imageRepository.imageIds() val imageIds = imageRepository.imageIds()
val jpegs = imageIds.asSequence() val jpegs = imageIds.asSequence()
@@ -127,6 +144,49 @@ class ExportViewModel(
return copiedFile return copiedFile
} }
fun onSavePdfClicked() {
viewModelScope.launch {
_events.emit(ExportEvent.RequestSavePdf)
}
}
fun onRequestPdfSave(context: Context, homeViewModel: HomeViewModel) {
viewModelScope.launch {
performPdfSave(context, homeViewModel)
}
}
private suspend fun performPdfSave(context: Context, homeViewModel: HomeViewModel) {
try {
val pdf = getFinalPdf() ?: return
val targetFile = saveFile(pdf.file)
mediaScan(context, targetFile)
// TODO remove that call: that should be handled through the ExportEvent
homeViewModel.addRecentDocument(
targetFile.absolutePath,
pdf.pageCount
)
_events.emit(ExportEvent.PdfSaved)
} catch (e: Exception) {
Log.e("FairScan", "Failed to save PDF", e)
_events.emit(ExportEvent.ShowToast("Error while saving PDF"))
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun mediaScan(context: Context, file: File) =
suspendCancellableCoroutine { continuation ->
MediaScannerConnection.scanFile(
context,
arrayOf(file.absolutePath),
arrayOf(PDF_MIME_TYPE)
) { _, _ -> continuation.resume(Unit) {} }
}
fun cleanUpOldPdfs(thresholdInMillis: Int) { fun cleanUpOldPdfs(thresholdInMillis: Int) {
pdfFileManager.cleanUpOldFiles(thresholdInMillis) pdfFileManager.cleanUpOldFiles(thresholdInMillis)
} }