Compare commits
@@ -32,3 +32,12 @@ jobs:
|
||||
|
||||
- name: Run Gradle
|
||||
run: ./gradlew clean license check assembleRelease --no-daemon
|
||||
|
||||
- name: Upload APKs
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FairScan-APKs-PR-${{ github.event.pull_request.number }}
|
||||
path: |
|
||||
app/build/outputs/apk/release/*.apk
|
||||
retention-days: 30
|
||||
|
||||
Generated
+2
-1
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.2.21" />
|
||||
<option name="externalSystemId" value="Gradle" />
|
||||
<option name="version" value="2.3.10" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
@@ -1,3 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
## Contributing
|
||||
|
||||
Thanks for your interest in contributing to FairScan.
|
||||
|
||||
Before contributing, please take a moment to understand the scope of the project.
|
||||
FairScan is an app that focuses on simplicity. The goal is to provide a fast, reliable scanning experience without requiring manual adjustments.
|
||||
|
||||
- The goal is not to re-implement all features found in similar apps
|
||||
- The goal is not to give users control over every aspect of the generated PDFs
|
||||
|
||||
Recommended reading: https://fairscan.org/blog/fairscan-purpose/
|
||||
|
||||
### Issues
|
||||
|
||||
You're very welcome to describe the problems you face when using FairScan.
|
||||
|
||||
When the automatic processing gives a bad result, please share the original image whenever possible. You can get it by tapping "Report a problem with last captured image" in the "About" screen. You can either attach the image to the GitHub issue or send it privately to contact@fairscan.org. Screenshots are usually not helpful to understand image-processing issues.
|
||||
|
||||
The most useful issues focus on the problem rather than on a proposed solution. Understanding the underlying problem is essential to finding the right solution. A detailed description of the problem and its context is therefore usually much more valuable than discussing a specific change to the app.
|
||||
|
||||
- ❌ "Please add a button or an option to..."
|
||||
- ✅ "Here is the problem I face, the context in which it occurs, and why it is problematic..."
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- Please avoid starting any significant work without discussing it first.
|
||||
- Small, focused PRs are much easier to review and integrate.
|
||||
- FairScan is primarily developed by its maintainer, and core parts of the app are implemented directly to ensure consistency with the overall vision. Large or architectural changes are unlikely to be accepted.
|
||||
|
||||
### Project direction
|
||||
|
||||
FairScan is currently maintained by a single person, and product and technical decisions are centralized.
|
||||
This helps keep the project consistent and aligned with its original vision, and may evolve in the future.
|
||||
|
||||
Thanks for your understanding.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
Copyright 2025-2026 The FairScan authors
|
||||
|
||||
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
|
||||
|
||||
@@ -32,6 +32,23 @@ And **respectful**: open source, minimal permissions, no tracking, no ads.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome, but please read the guidelines first: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## Translations
|
||||
|
||||
Translations are managed on Weblate, and everyone is welcome to contribute.
|
||||
|
||||
[](https://hosted.weblate.org/engage/fairscan/)
|
||||
|
||||
Start translating:
|
||||
https://hosted.weblate.org/projects/fairscan/
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Scan | Preview | Save & Share |
|
||||
@@ -43,17 +60,26 @@ And **respectful**: open source, minimal permissions, no tracking, no ads.
|
||||
## Features
|
||||
|
||||
- **Clear, distraction-free interface**
|
||||
- **Easy scanning process**: scan → preview → share
|
||||
- **Easy flow**: scan, review if needed, save or share
|
||||
- **Automatic document detection** using a custom segmentation model
|
||||
- **Automatic perspective correction**
|
||||
- **Automatic image enhancement**
|
||||
- **Fast PDF generation** with no manual adjustments
|
||||
- **Fully offline** – the app has *no* internet permission
|
||||
- **All document processing happens locally on your device**, no cloud processing
|
||||
- **Minimal permissions**
|
||||
- **Open source**, GPLv3
|
||||
|
||||
---
|
||||
|
||||
## What FairScan is not
|
||||
|
||||
FairScan is **not** intended to:
|
||||
- provide fine-grained manual control over document processing
|
||||
- replicate all features found in other scanning apps
|
||||
- optimize for highly specific use cases at the expense of simplicity
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
FairScan works on any device that:
|
||||
@@ -95,6 +121,7 @@ FairScan uses:
|
||||
- [CameraX](https://developer.android.com/media/camera/camerax) for image capture
|
||||
- [LiteRT](https://ai.google.dev/edge/litert) to run the custom segmentation model for automatic document detection
|
||||
- [OpenCV](https://opencv.org/) for perspective correction and image enhancement
|
||||
- [Tesseract](https://github.com/tesseract-ocr/tesseract) for text recognition (OCR)
|
||||
- [PDFBox-Android](https://github.com/TomRoush/PdfBox-Android) for PDF generation
|
||||
|
||||
---
|
||||
|
||||
+5
-21
@@ -3,7 +3,6 @@ plugins {
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.aboutLibrariesAndroid)
|
||||
alias(libs.plugins.protobuf)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
@@ -26,8 +25,8 @@ android {
|
||||
// https://ai.google.dev/edge/litert/android/index
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 48 // increment by 3 because of ABI-specific APKs
|
||||
versionName = "1.12.1"
|
||||
versionCode = 93 // increment by 3 because of ABI-specific APKs
|
||||
versionName = "2.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -100,7 +99,7 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
apply(from = "download-tflite.gradle.kts")
|
||||
apply(from = file("download-tflite.gradle.kts"))
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -122,10 +121,8 @@ dependencies {
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.protobuf.javalite)
|
||||
implementation(libs.litert)
|
||||
implementation(libs.litert.support)
|
||||
implementation(libs.litert.metadata)
|
||||
@@ -139,9 +136,11 @@ dependencies {
|
||||
implementation(libs.reorderable)
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.tesseract4android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.assertj)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
@@ -159,21 +158,6 @@ aboutLibraries {
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:4.32.0"
|
||||
}
|
||||
generateProtoTasks {
|
||||
all().forEach { task ->
|
||||
task.builtins {
|
||||
create("java") {
|
||||
option("lite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See https://developer.android.com/build/configure-apk-splits
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import org.gradle.api.tasks.Copy
|
||||
import java.net.URL
|
||||
|
||||
val modelVersion = "v1.1.0"
|
||||
val modelVersion = "v1.2.0"
|
||||
val modelFileName = "fairscan-segmentation-model.tflite"
|
||||
val modelUrl = "https://github.com/pynicolas/fairscan-segmentation-model/releases/download/$modelVersion/$modelFileName"
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025-2026 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.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.fairscan.app.ui.screens.camera.extractDocumentFromBitmap
|
||||
import org.fairscan.imageprocessing.detectDocumentQuad
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.opencv.android.OpenCVLoader
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DocumentDetectionTest {
|
||||
@Test
|
||||
fun extractDocumentFromImage() {
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("org.fairscan.app", appContext.packageName)
|
||||
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val segmentationService = ImageSegmentationService(context) { _, _, _ -> }
|
||||
segmentationService.initialize()
|
||||
OpenCVLoader.initLocal()
|
||||
|
||||
listOf("img01.jpg", "img02.jpg", "img03.jpg").forEach { imageFileName ->
|
||||
val inputStream = context.assets.open("uncropped/$imageFileName")
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
var outputBitmap: Bitmap? = null
|
||||
|
||||
val segmentationResult = runBlocking {
|
||||
segmentationService.runSegmentationAndReturn(bitmap, 0)
|
||||
}
|
||||
if (segmentationResult != null) {
|
||||
val mask = segmentationResult.segmentation
|
||||
val quad = detectDocumentQuad(mask, false)
|
||||
if (quad != null) {
|
||||
val resizedQuad =
|
||||
quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
|
||||
outputBitmap = extractDocumentFromBitmap(bitmap, resizedQuad, 0, mask).page
|
||||
val file = File(context.getExternalFilesDir(null), imageFileName)
|
||||
FileOutputStream(file).use {
|
||||
outputBitmap.compress(Bitmap.CompressFormat.JPEG, 95, it)
|
||||
}
|
||||
Log.i("DocumentDetectionTest", "Image saved to ${file.absolutePath}")
|
||||
}
|
||||
}
|
||||
if (outputBitmap == null) {
|
||||
fail("Failed to extract document from image $imageFileName")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Required (on Android 9 and lower) to save files -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<!-- To download Tesseract language data -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- REMOVE ACCESS_NETWORK_STATE -->
|
||||
<!-- cameraX 1.6.1 depends on androidx.media3:media3-common which adds ACCESS_NETWORK_STATE -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -33,6 +38,12 @@
|
||||
<action android:name="org.fairscan.app.action.SCAN_TO_PDF" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.GET_CONTENT"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.OPENABLE"/>
|
||||
<data android:mimeType="application/pdf"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -17,18 +17,23 @@ package org.fairscan.app
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fairscan.app.data.FileLogger
|
||||
import org.fairscan.app.data.FileManager
|
||||
import org.fairscan.app.data.LogRepository
|
||||
import org.fairscan.app.data.recentDocumentsDataStore
|
||||
import org.fairscan.app.data.OcrLanguageRepository
|
||||
import org.fairscan.app.domain.ImageSegmentationService
|
||||
import org.fairscan.app.domain.OcrService
|
||||
import org.fairscan.app.platform.AndroidImageLoader
|
||||
import org.fairscan.app.platform.AndroidPdfWriter
|
||||
import org.fairscan.app.ui.screens.about.AboutViewModel
|
||||
import org.fairscan.app.ui.screens.camera.CameraViewModel
|
||||
import org.fairscan.app.ui.screens.home.HomeViewModel
|
||||
import org.fairscan.app.ui.screens.settings.SettingsRepository
|
||||
import org.fairscan.app.ui.screens.settings.SettingsViewModel
|
||||
import java.io.File
|
||||
@@ -45,19 +50,31 @@ class FairScanApp : Application() {
|
||||
|
||||
const val THUMBNAIL_SIZE_DP = 120
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings")
|
||||
|
||||
class AppContainer(context: Context) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val cacheDir = context.cacheDir
|
||||
private val dataStore = context.dataStore
|
||||
val preparationDir = File(context.cacheDir, "pdfs")
|
||||
val ocrLanguageRepository =
|
||||
OcrLanguageRepository(dataStore, File(context.filesDir, "tesseract/tessdata"))
|
||||
val ocrService = OcrService(ocrLanguageRepository, scope)
|
||||
val fileManager = FileManager(
|
||||
preparationDir,
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
AndroidPdfWriter()
|
||||
AndroidPdfWriter(ocrService, context.assets)
|
||||
)
|
||||
val logRepository = LogRepository(File(context.filesDir, "logs.txt"))
|
||||
val logger = FileLogger(logRepository)
|
||||
val imageSegmentationService = ImageSegmentationService(context, logger)
|
||||
val recentDocumentsDataStore = context.recentDocumentsDataStore
|
||||
val settingsRepository = SettingsRepository(context)
|
||||
val imageLoader = AndroidImageLoader(context.contentResolver)
|
||||
val settingsRepository = SettingsRepository(context, dataStore)
|
||||
|
||||
init {
|
||||
scope.launch { imageSegmentationService.initialize() }
|
||||
scope.launch { ocrService.initialize() }
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <reified VM : ViewModel> viewModelFactory(
|
||||
@@ -68,7 +85,6 @@ class AppContainer(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val homeViewModelFactory = viewModelFactory { HomeViewModel(it, context) }
|
||||
val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) }
|
||||
val settingsViewModelFactory = viewModelFactory { SettingsViewModel(it) }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -27,6 +27,7 @@ import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Build.VERSION_CODES.Q
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
@@ -53,8 +54,8 @@ import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.Screen
|
||||
import org.fairscan.app.ui.components.rememberCameraPermissionState
|
||||
import org.fairscan.app.ui.screens.DocumentScreen
|
||||
import org.fairscan.app.ui.screens.LibrariesScreen
|
||||
import org.fairscan.app.ui.screens.ResumeScanScreen
|
||||
import org.fairscan.app.ui.screens.about.AboutEvent
|
||||
import org.fairscan.app.ui.screens.about.AboutScreen
|
||||
import org.fairscan.app.ui.screens.about.AboutViewModel
|
||||
@@ -62,15 +63,16 @@ import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
|
||||
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.crop.CropScreen
|
||||
import org.fairscan.app.ui.screens.document.DocumentScreen
|
||||
import org.fairscan.app.ui.screens.export.ExportActions
|
||||
import org.fairscan.app.ui.screens.export.ExportEvent
|
||||
import org.fairscan.app.ui.screens.export.ExportResult
|
||||
import org.fairscan.app.ui.screens.export.ExportScreenWrapper
|
||||
import org.fairscan.app.ui.screens.export.ExportViewModel
|
||||
import org.fairscan.app.ui.screens.home.HomeScreen
|
||||
import org.fairscan.app.ui.screens.home.HomeViewModel
|
||||
import org.fairscan.app.ui.screens.settings.ExportFormat
|
||||
import org.fairscan.app.ui.screens.settings.OcrLanguagesScreen
|
||||
import org.fairscan.app.ui.screens.settings.SettingsScreen
|
||||
import org.fairscan.app.ui.screens.settings.SettingsUiState
|
||||
import org.fairscan.app.ui.screens.settings.SettingsViewModel
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.opencv.android.OpenCVLoader
|
||||
@@ -78,6 +80,9 @@ import java.io.File
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private lateinit var cameraViewModel: CameraViewModel
|
||||
private lateinit var viewModel: MainViewModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
initLibraries()
|
||||
@@ -94,11 +99,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
val imageRepository = sessionViewModel.imageRepository
|
||||
val viewModel: MainViewModel by viewModels {
|
||||
viewModel = viewModels<MainViewModel> {
|
||||
appContainer.viewModelFactory {
|
||||
MainViewModel(imageRepository, launchMode)
|
||||
MainViewModel(imageRepository, appContainer.logger)
|
||||
}
|
||||
}
|
||||
}.value
|
||||
val exportViewModel: ExportViewModel by viewModels {
|
||||
appContainer.viewModelFactory {
|
||||
ExportViewModel(appContainer, imageRepository)
|
||||
@@ -109,8 +114,7 @@ class MainActivity : ComponentActivity() {
|
||||
AboutViewModel(appContainer, imageRepository)
|
||||
}
|
||||
}
|
||||
val homeViewModel: HomeViewModel by viewModels { appContainer.homeViewModelFactory }
|
||||
val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory }
|
||||
cameraViewModel = viewModels<CameraViewModel> { appContainer.cameraViewModelFactory }.value
|
||||
|
||||
val settingsViewModel: SettingsViewModel
|
||||
by viewModels { appContainer.settingsViewModelFactory }
|
||||
@@ -119,11 +123,16 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val logger = appContainer.logger
|
||||
val context = LocalContext.current
|
||||
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
|
||||
val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
|
||||
val importState by cameraViewModel.importState.collectAsStateWithLifecycle()
|
||||
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
|
||||
val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
|
||||
val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
|
||||
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val cameraPermission = rememberCameraPermissionState()
|
||||
CollectCameraEvents(cameraViewModel, viewModel)
|
||||
CollectExportEvents(context, exportViewModel)
|
||||
@@ -131,66 +140,99 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
FairScanTheme {
|
||||
val navigation = navigation(viewModel, launchMode)
|
||||
when (val screen = currentScreen) {
|
||||
is Screen.Main.Home -> {
|
||||
val recentDocs by homeViewModel.recentDocuments.collectAsStateWithLifecycle()
|
||||
HomeScreen(
|
||||
cameraPermission = cameraPermission,
|
||||
currentDocument = document,
|
||||
navigation = navigation,
|
||||
onClearScan = { viewModel.startNewDocument() },
|
||||
recentDocuments = recentDocs,
|
||||
onOpenPdf = { fileUri -> openUri(fileUri, ExportFormat.PDF.mimeType) }
|
||||
val onExportClick = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
|
||||
{
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val result = exportViewModel.generatePdfForExternalCall()
|
||||
sendActivityResult(result)
|
||||
} catch (e: Exception) {
|
||||
val message = "Export failed"
|
||||
showToast(message)
|
||||
appContainer.logger.e("MainActivity", message, e)
|
||||
return@launch
|
||||
}
|
||||
viewModel.startNewDocument()
|
||||
finish()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
} else {
|
||||
navigation.toExportScreen
|
||||
}
|
||||
|
||||
when (currentScreen) {
|
||||
null -> {
|
||||
// waiting to load pages to get an initial screen
|
||||
}
|
||||
is Screen.Main.ResumeScan -> {
|
||||
ResumeScanScreen(
|
||||
currentDocument = documentUiState,
|
||||
onResumeScan = navigation.toCameraScreen,
|
||||
onStartNewScan = {
|
||||
viewModel.startNewDocument()
|
||||
navigation.toCameraScreen()
|
||||
}
|
||||
)
|
||||
}
|
||||
is Screen.Main.Camera -> {
|
||||
val pickMultiple = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetMultipleContents()) {
|
||||
uris -> cameraViewModel.importPhotos(uris)
|
||||
}
|
||||
CameraScreen(
|
||||
viewModel,
|
||||
cameraViewModel,
|
||||
navigation,
|
||||
liveAnalysisState,
|
||||
importState,
|
||||
onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
|
||||
onFinalizePressed = navigation.toDocumentScreen,
|
||||
cameraPermission = cameraPermission
|
||||
onFinalizePressed = onExportClick,
|
||||
cameraPermission = cameraPermission,
|
||||
onImportClicked = {
|
||||
cameraViewModel.onImportClicked()
|
||||
pickMultiple.launch("image/*")
|
||||
}
|
||||
)
|
||||
}
|
||||
is Screen.Main.EditImage -> {
|
||||
CropScreen(
|
||||
pageId = documentUiState.currentPage?.key?.pageId ?: "",
|
||||
initState = cropInitialState,
|
||||
navigation = navigation,
|
||||
onUpdatePageQuad = { quad -> viewModel.setCurrentPageUserQuad(quad) },
|
||||
)
|
||||
}
|
||||
is Screen.Main.Document -> {
|
||||
DocumentScreen (
|
||||
document = document,
|
||||
initialPage = screen.initialPage,
|
||||
uiState = documentUiState,
|
||||
navigation = navigation,
|
||||
onExportClick = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
|
||||
{
|
||||
lifecycleScope.launch {
|
||||
val result = exportViewModel.generatePdfForExternalCall()
|
||||
sendActivityResult(result)
|
||||
viewModel.startNewDocument()
|
||||
finish()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
} else {
|
||||
navigation.toExportScreen
|
||||
},
|
||||
onDeleteImage = { id -> viewModel.deletePage(id) },
|
||||
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) },
|
||||
onExportClick = onExportClick,
|
||||
onDeleteImage = { viewModel.deleteCurrentPage() },
|
||||
onRotateImage = { clockwise -> viewModel.rotateCurrentPage(clockwise) },
|
||||
onToggleColorMode = { viewModel.toggleCurrentPageColorMode() },
|
||||
onCropClick = { viewModel.onClickOnCropButton() },
|
||||
onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
|
||||
onPageSelected = viewModel::onPageSelected
|
||||
)
|
||||
}
|
||||
is Screen.Main.Export -> {
|
||||
ExportScreenWrapper(
|
||||
navigation = navigation,
|
||||
uiState = exportUiState,
|
||||
pdfActions = ExportActions(
|
||||
initializeExportScreen = exportViewModel::initializeExportScreen,
|
||||
currentDocument = document,
|
||||
exportActions = ExportActions(
|
||||
prepareExportIfNeeded = exportViewModel::prepareExportIfNeeded,
|
||||
setFilename = exportViewModel::setFilename,
|
||||
share = { share(exportViewModel.applyRenaming(), exportViewModel) },
|
||||
share = { exportViewModel.onShareClicked() },
|
||||
save = { exportViewModel.onSaveClicked() },
|
||||
open = { item -> openUri(item.uri, item.format.mimeType) },
|
||||
open = { item -> openUri(item.uri, item.format.mimeType, logger) },
|
||||
cancelPreparationJob = exportViewModel::cancelPreparationJob,
|
||||
),
|
||||
onCloseScan = {
|
||||
exportViewModel.resetFilename()
|
||||
viewModel.startNewDocument()
|
||||
viewModel.navigateTo(Screen.Main.Home)
|
||||
viewModel.navigateTo(Screen.Main.Camera)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -205,13 +247,29 @@ class MainActivity : ComponentActivity() {
|
||||
onCopyLogs = { aboutViewModel.onCopyLogsClicked() },
|
||||
onContactWithLastImageClicked =
|
||||
{ aboutViewModel.onContactWithLastImageClicked() },
|
||||
onStartActivity = { intent ->
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
showToast(getString(R.string.error_occurred))
|
||||
}
|
||||
},
|
||||
onViewLibraries = navigation.toLibrariesScreen)
|
||||
}
|
||||
is Screen.Overlay.Libraries -> {
|
||||
LibrariesScreen(onBack = navigation.back)
|
||||
}
|
||||
is Screen.Overlay.Settings -> {
|
||||
SettingsScreenWrapper(settingsViewModel, navigation, appContainer.logger)
|
||||
SettingsScreenWrapper(settingsUiState, settingsViewModel, navigation, logger)
|
||||
}
|
||||
is Screen.Overlay.OcrLanguages -> {
|
||||
OcrLanguagesScreen(
|
||||
uiState = settingsUiState,
|
||||
onBack = navigation.back,
|
||||
onLanguageClick = settingsViewModel::onLanguageClick,
|
||||
onRemoveLanguage = settingsViewModel::onRemoveLanguage,
|
||||
onCancelOcrDownload = settingsViewModel::cancelOcrDownload,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,12 +279,18 @@ class MainActivity : ComponentActivity() {
|
||||
private fun resolveLaunchMode(intent: Intent?): LaunchMode {
|
||||
return when (intent?.action) {
|
||||
"org.fairscan.app.action.SCAN_TO_PDF" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
|
||||
"android.intent.action.GET_CONTENT" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
|
||||
else -> LaunchMode.NORMAL
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToast(text: String) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreenWrapper(
|
||||
settingsUiState: SettingsUiState,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
nav: Navigation,
|
||||
logger: FileLogger,
|
||||
@@ -242,27 +306,29 @@ class MainActivity : ComponentActivity() {
|
||||
settingsViewModel.setExportDirUri(uri.toString())
|
||||
} catch (e: Exception) {
|
||||
logger.e("Settings", "Failed to set export dir to $uri", e)
|
||||
val text = getString(R.string.error_file_picker_result)
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
showToast(this.getString(R.string.error_file_picker_result))
|
||||
}
|
||||
}
|
||||
}
|
||||
val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(Unit) {
|
||||
settingsViewModel.refreshExportDirName()
|
||||
}
|
||||
SettingsScreen(
|
||||
settingsUiState,
|
||||
onDefaultColorModeChanged = { mode -> settingsViewModel.setDefaultColorMode(mode) },
|
||||
onChooseDirectoryClick = {
|
||||
try {
|
||||
launcher.launch(null)
|
||||
} catch (e: Exception) {
|
||||
val message = getString(R.string.error_file_picker_launch)
|
||||
logger.e("Settings", message, e)
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
showToast(message)
|
||||
}
|
||||
},
|
||||
onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
|
||||
onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
|
||||
onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
|
||||
onBack = nav.back,
|
||||
navigation = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -281,7 +347,7 @@ class MainActivity : ComponentActivity() {
|
||||
clipboard.setClipEntry(
|
||||
ClipData.newPlainText("FairScan logs", event.logs).toClipEntry()
|
||||
)
|
||||
Toast.makeText(context, msgCopiedLogs, Toast.LENGTH_SHORT).show()
|
||||
showToast(msgCopiedLogs)
|
||||
}
|
||||
is AboutEvent.PrepareEmailWithLastImage -> {
|
||||
val file = imageRepository.lastAddedSourceFile()
|
||||
@@ -305,8 +371,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (isGranted) {
|
||||
exportViewModel.onSaveClicked()
|
||||
} else {
|
||||
val message = getString(R.string.storage_permission_denied)
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
showToast(this.getString(R.string.storage_permission_denied))
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -317,10 +382,8 @@ class MainActivity : ComponentActivity() {
|
||||
exportViewModel.onRequestSave(context)
|
||||
}
|
||||
}
|
||||
|
||||
is ExportEvent.SaveError -> {
|
||||
val text = getString(R.string.error_save)
|
||||
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
|
||||
is ExportEvent.Share -> {
|
||||
share(event.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,10 +404,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun share(result: ExportResult?, viewModel: ExportViewModel) {
|
||||
if (result == null || result.files.isEmpty()) return
|
||||
|
||||
viewModel.setAsShared()
|
||||
private fun share(result: ExportResult) {
|
||||
if (result.files.isEmpty()) return
|
||||
|
||||
val uris = result.files.map(::uriForFile)
|
||||
val intent = Intent().apply {
|
||||
@@ -401,7 +462,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUri(fileUri: Uri?, mimeType: String) {
|
||||
private fun openUri(fileUri: Uri?, mimeType: String, logger: FileLogger) {
|
||||
if (fileUri == null) return
|
||||
val uriToOpen: Uri =
|
||||
if (fileUri.scheme == ContentResolver.SCHEME_CONTENT) {
|
||||
@@ -409,14 +470,23 @@ class MainActivity : ComponentActivity() {
|
||||
} else {
|
||||
uriForFile(fileUri.toFile())
|
||||
}
|
||||
val openIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uriToOpen, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
try {
|
||||
startActivity(Intent.createChooser(openIntent, getString(R.string.open_file)))
|
||||
val openIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uriToOpen, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
clipData = ClipData.newUri(contentResolver, "file", uriToOpen)
|
||||
}
|
||||
val chooser = Intent.createChooser(openIntent, getString(R.string.open_file)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(chooser)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(this, getString(R.string.error_no_app), Toast.LENGTH_SHORT).show()
|
||||
showToast(getString(R.string.error_no_app))
|
||||
} catch (e: Exception) {
|
||||
val errorMessage =
|
||||
"Failed to open URI, scheme=${uriToOpen.scheme}, authority=${uriToOpen.authority}"
|
||||
logger.e("OpenUri", errorMessage, e)
|
||||
showToast(getString(R.string.error_occurred))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,8 +501,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
|
||||
toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) },
|
||||
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
|
||||
toEditImageScreen = { viewModel.navigateTo(Screen.Main.EditImage) },
|
||||
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
|
||||
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
|
||||
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
|
||||
@@ -442,6 +512,7 @@ class MainActivity : ComponentActivity() {
|
||||
viewModel.navigateTo(Screen.Overlay.Settings)
|
||||
}
|
||||
},
|
||||
toOcrLanguagesScreen = { viewModel.navigateTo(Screen.Overlay.OcrLanguages) },
|
||||
back = {
|
||||
val origin = viewModel.currentScreen.value
|
||||
viewModel.navigateBack()
|
||||
@@ -450,7 +521,21 @@ class MainActivity : ComponentActivity() {
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
},
|
||||
shouldDisplayBackButton = {
|
||||
viewModel.currentScreen.value !is Screen.Main.Camera
|
||||
|| launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
|
||||
keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
|
||||
if (viewModel.currentScreen.value is Screen.Main.Camera) {
|
||||
cameraViewModel.onVolumeKeyPressed()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,107 +16,274 @@ package org.fairscan.app
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.data.Logger
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.domain.ScanPage
|
||||
import org.fairscan.app.ui.NavigationState
|
||||
import org.fairscan.app.ui.Screen
|
||||
import org.fairscan.app.ui.screens.crop.CropInitState
|
||||
import org.fairscan.app.ui.screens.document.CurrentPageUiState
|
||||
import org.fairscan.app.ui.screens.document.DocumentUiState
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import org.fairscan.app.ui.state.PageThumbnail
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import kotlin.math.min
|
||||
|
||||
class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewModel() {
|
||||
|
||||
private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode))
|
||||
val currentScreen: StateFlow<Screen> = _navigationState.map { it.current }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current)
|
||||
private val _navigationState = MutableStateFlow<NavigationState?>(null)
|
||||
val currentScreen: StateFlow<Screen?> = _navigationState.map { it?.current }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val _pages = MutableStateFlow<List<ScanPage>>(emptyList())
|
||||
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val pages = imageRepository.pages()
|
||||
|
||||
_pages.value = pages
|
||||
|
||||
_navigationState.value =
|
||||
if (pages.isEmpty()) {
|
||||
NavigationState.initial()
|
||||
} else {
|
||||
NavigationState.initial().navigateTo(Screen.Main.ResumeScan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _pages = MutableStateFlow(imageRepository.pages())
|
||||
val documentUiModel: StateFlow<DocumentUiModel> =
|
||||
_pages.map { pages ->
|
||||
DocumentUiModel(
|
||||
pageKeys = pages.map { p ->
|
||||
PageViewKey(p.id, p.manualRotation)
|
||||
}.toImmutableList(),
|
||||
imageLoader = ::getBitmap,
|
||||
thumbnailLoader = ::getThumbnail,
|
||||
)
|
||||
}.stateIn(
|
||||
pages.map {
|
||||
val jpeg = imageRepository.getThumbnail(it.key())
|
||||
PageThumbnail(it.key(), jpeg)
|
||||
}.toImmutableList()
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.map { DocumentUiModel(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = DocumentUiModel(persistentListOf(), ::getBitmap, ::getThumbnail)
|
||||
initialValue = DocumentUiModel()
|
||||
)
|
||||
|
||||
private val _currentPageIndex = MutableStateFlow(0)
|
||||
private val _loadingPageId = MutableStateFlow<String?>(null)
|
||||
private val currentPageUiState: Flow<CurrentPageUiState?> =
|
||||
combine(_currentPageIndex, _pages, _loadingPageId) { index, pages, loadingId ->
|
||||
val page = pages.getOrNull(index)
|
||||
Pair(page, loadingId)
|
||||
}
|
||||
.mapLatest { (page,loadingId) ->
|
||||
page?.let {
|
||||
val isLoading = (it.id == loadingId)
|
||||
val canBeCropped = page.metadata != null
|
||||
val bitmap = try {
|
||||
imageRepository.jpegBytes(it.key())?.toBitmap()
|
||||
} catch (e: Exception) {
|
||||
logger.e("MainViewModel", "Failed to load image for ${it.id}", e)
|
||||
null
|
||||
}
|
||||
CurrentPageUiState(it.key(), bitmap, it.colorMode, canBeCropped, isLoading)
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
val documentUiState: StateFlow<DocumentUiState> =
|
||||
combine(_currentPageIndex, currentPageUiState, documentUiModel) { index, page, document ->
|
||||
DocumentUiState(index, page, document)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope, SharingStarted.Eagerly,
|
||||
DocumentUiState(0, null, DocumentUiModel())
|
||||
)
|
||||
|
||||
fun onPageSelected(index: Int) {
|
||||
_currentPageIndex.value = index
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Screen) {
|
||||
_navigationState.update { it.navigateTo(destination) }
|
||||
if (destination is Screen.Main.Document) {
|
||||
require(_pages.value.isNotEmpty()) {
|
||||
"Cannot navigate to DocumentScreen with zero pages"
|
||||
}
|
||||
_currentPageIndex.value = min(_pages.value.size - 1, destination.initialPage)
|
||||
}
|
||||
_navigationState.update { it?.navigateTo(destination) }
|
||||
}
|
||||
|
||||
fun navigateBack() {
|
||||
_navigationState.update { stack -> stack.navigateBack() }
|
||||
_navigationState.update { stack -> stack?.navigateBack() }
|
||||
}
|
||||
|
||||
fun rotateImage(id: String, clockwise: Boolean) {
|
||||
fun rotateCurrentPage(clockwise: Boolean) {
|
||||
viewModelScope.launch {
|
||||
imageRepository.rotate(id, clockwise)
|
||||
_pages.value = imageRepository.pages()
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.rotate(currentPage().id, clockwise)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
fun movePage(id: String, newIndex: Int) {
|
||||
viewModelScope.launch {
|
||||
imageRepository.movePage(id, newIndex)
|
||||
_pages.value = imageRepository.pages()
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.movePage(id, newIndex)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePage(id: String) {
|
||||
fun deleteCurrentPage() {
|
||||
viewModelScope.launch {
|
||||
imageRepository.delete(id)
|
||||
_pages.value = imageRepository.pages()
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.delete(currentPage().id)
|
||||
imageRepository.pages()
|
||||
}
|
||||
|
||||
if (pages.isEmpty()) {
|
||||
navigateTo(Screen.Main.Camera)
|
||||
_currentPageIndex.value = 0
|
||||
} else if (_currentPageIndex.value >= pages.size) {
|
||||
_currentPageIndex.value = pages.size - 1
|
||||
}
|
||||
_pages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleCurrentPageColorMode() {
|
||||
viewModelScope.launch {
|
||||
val currentPage = currentPage()
|
||||
currentPage.colorMode?.let {
|
||||
_loadingPageId.value = currentPage.id
|
||||
val newColorMode =
|
||||
if (it == ColorMode.COLOR) ColorMode.GRAYSCALE else ColorMode.COLOR
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.setColorMode(currentPage.id, newColorMode)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
_loadingPageId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurrentPageUserQuad(userQuad: Quad) {
|
||||
viewModelScope.launch {
|
||||
val currentPage = currentPage()
|
||||
val totalRotation = currentPage.totalRotation()
|
||||
val rotateIterations = (4 - totalRotation.degrees / 90) % 4
|
||||
val newQuad = userQuad.rotate90(rotateIterations, ImageSize(1, 1))
|
||||
_loadingPageId.value = currentPage.id
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
imageRepository.setUserQuad(currentPage.id, newQuad)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
_loadingPageId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentPage(): ScanPage {
|
||||
val index = _currentPageIndex.value
|
||||
val pages = _pages.value
|
||||
return pages.getOrNull(index) ?: throw IllegalStateException(
|
||||
"No current page for index $index (${pages.size} pages)")
|
||||
}
|
||||
|
||||
fun startNewDocument() {
|
||||
_pages.value = persistentListOf()
|
||||
viewModelScope.launch {
|
||||
imageRepository.clear()
|
||||
withContext(Dispatchers.IO) {
|
||||
imageRepository.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getBitmap(key: PageViewKey): Bitmap? {
|
||||
val bytes = imageRepository.jpegBytes(key)
|
||||
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
||||
}
|
||||
|
||||
fun getThumbnail(key: PageViewKey): Bitmap? {
|
||||
val bytes = imageRepository.getThumbnail(key)
|
||||
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
||||
}
|
||||
|
||||
fun handleImageCaptured(capturedPage: CapturedPage) {
|
||||
viewModelScope.launch {
|
||||
imageRepository.add(
|
||||
compressJpeg(capturedPage.page, 75),
|
||||
compressJpeg(capturedPage.source, 90),
|
||||
capturedPage.metadata,
|
||||
)
|
||||
_pages.value = imageRepository.pages()
|
||||
val pages = withContext(Dispatchers.IO) {
|
||||
val sourceJpeg = capturedPage.sourceJpeg.await()
|
||||
imageRepository.add(
|
||||
capturedPage.pageJpeg,
|
||||
sourceJpeg,
|
||||
capturedPage.metadata,
|
||||
capturedPage.colorMode,
|
||||
)
|
||||
imageRepository.pages()
|
||||
}
|
||||
_pages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
private fun compressJpeg(bitmap: Bitmap, quality: Int): ByteArray {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
|
||||
return outputStream.toByteArray()
|
||||
private val _cropInitState = MutableStateFlow<CropInitState>(CropInitState.Loading)
|
||||
val cropInitState: StateFlow<CropInitState> = _cropInitState
|
||||
|
||||
private var cropInitialStateJob: Job? = null
|
||||
fun onClickOnCropButton() {
|
||||
cropInitialStateJob?.cancel()
|
||||
cropInitialStateJob = viewModelScope.launch {
|
||||
_cropInitState.value = CropInitState.Loading
|
||||
|
||||
val page = currentPage()
|
||||
|
||||
val metadata = page.metadata
|
||||
val rotation = page.totalRotation()
|
||||
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
val source = imageRepository.source(page.id)
|
||||
val bytes = source?.bytes ?: return@withContext null
|
||||
|
||||
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
if (original != null && rotation != Rotation.R0) {
|
||||
val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) }
|
||||
Bitmap.createBitmap(
|
||||
original, 0, 0, original.width, original.height, matrix, true
|
||||
)
|
||||
} else {
|
||||
original
|
||||
}
|
||||
}
|
||||
|
||||
val quad = metadata?.normalizedQuad?.rotate90(
|
||||
rotation.degrees / 90,
|
||||
ImageSize(1, 1)
|
||||
)
|
||||
|
||||
_cropInitState.value = if (bitmap == null || quad == null)
|
||||
CropInitState.Error
|
||||
else
|
||||
CropInitState.Ready(page.id, bitmap, quad)
|
||||
navigateTo(Screen.Main.EditImage)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -17,8 +17,11 @@ package org.fairscan.app
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.platform.OpenCvTransformations
|
||||
import org.fairscan.app.data.Logger
|
||||
import org.fairscan.app.platform.ImageProcessor
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
@@ -39,7 +42,9 @@ class SessionViewModel(
|
||||
|
||||
private val sessionContainer = ScanSessionContainer(
|
||||
context = app,
|
||||
scanRootDir = sessionDir
|
||||
scanRootDir = sessionDir,
|
||||
scope = viewModelScope,
|
||||
logger = appContainer.logger,
|
||||
)
|
||||
|
||||
val imageRepository: ImageRepository = sessionContainer.imageRepository
|
||||
@@ -55,14 +60,17 @@ class SessionViewModel(
|
||||
|
||||
class ScanSessionContainer(
|
||||
context: Context,
|
||||
scanRootDir: File
|
||||
scanRootDir: File,
|
||||
scope: CoroutineScope,
|
||||
logger: Logger,
|
||||
) {
|
||||
private val density = context.resources.displayMetrics.density
|
||||
private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
|
||||
|
||||
val imageRepository = ImageRepository(
|
||||
scanRootDir,
|
||||
OpenCvTransformations(),
|
||||
thumbnailSizePx
|
||||
ImageProcessor(thumbnailSizePx),
|
||||
scope,
|
||||
logger,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,6 +15,7 @@
|
||||
package org.fairscan.app.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
@Serializable
|
||||
data class DocumentMetadataV1(
|
||||
@@ -39,7 +40,15 @@ data class PageV2(
|
||||
val baseRotationDegrees: Int = 0,
|
||||
val manualRotationDegrees: Int = 0,
|
||||
val quad: NormalizedQuad? = null,
|
||||
val isColored: Boolean? = null
|
||||
val quadVersion: Int = 0,
|
||||
val userQuad: NormalizedQuad? = null,
|
||||
val isColored: Boolean? = null,
|
||||
val colorMode: ColorMode? = null,
|
||||
val focalLength: Float? = null,
|
||||
val sensorWidth: Float? = null,
|
||||
val subjectDistance: Float? = null,
|
||||
val sourceWidth: Int? = null,
|
||||
val sourceHeight: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
import org.fairscan.app.domain.PageToExport
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
@@ -25,7 +26,12 @@ data class GeneratedPdf(
|
||||
)
|
||||
|
||||
fun interface PdfWriter {
|
||||
fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, outputStream: OutputStream): Int
|
||||
suspend fun writePdfFromJpegs(
|
||||
pages: List<PageToExport>,
|
||||
outputStream: OutputStream,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
class FileManager(
|
||||
@@ -42,15 +48,19 @@ class FileManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun generatePdf(jpegs: Sequence<ByteArray>): GeneratedPdf {
|
||||
suspend fun generatePdf(
|
||||
pages: List<PageToExport>,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit
|
||||
): GeneratedPdf {
|
||||
pdfDir.mkdirs()
|
||||
require(pdfDir.exists() && pdfDir.isDirectory) { "Invalid pdfDir: $pdfDir" }
|
||||
val file = File(pdfDir, "${System.currentTimeMillis()}.pdf")
|
||||
val pageCount = FileOutputStream(file).use {
|
||||
pdfWriter.writePdfFromJpegs(jpegs, it)
|
||||
FileOutputStream(file).use {
|
||||
pdfWriter.writePdfFromJpegs(pages, it, disableOcr, onProgress)
|
||||
}
|
||||
val sizeBytes = file.length()
|
||||
return GeneratedPdf(file, sizeBytes, pageCount)
|
||||
return GeneratedPdf(file, sizeBytes, pages.size)
|
||||
}
|
||||
|
||||
fun copyToExternalDir(original: File): File {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,22 +14,35 @@
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.domain.ScanPage
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.cameraIntrinsics
|
||||
import java.io.File
|
||||
import java.util.Collections.synchronizedMap
|
||||
|
||||
const val SOURCE_DIR_NAME = "sources"
|
||||
const val SCAN_DIR_NAME = "scanned_pages"
|
||||
const val PROCESSED_DIR_NAME = "scanned_pages"
|
||||
const val THUMBNAIL_DIR_NAME = "thumbnails"
|
||||
|
||||
/**
|
||||
@@ -41,33 +54,34 @@ const val THUMBNAIL_DIR_NAME = "thumbnails"
|
||||
class ImageRepository(
|
||||
scanRootDir: File,
|
||||
val transformations: ImageTransformations,
|
||||
private val thumbnailSizePx: Int,
|
||||
private val scope: CoroutineScope,
|
||||
private val logger: Logger,
|
||||
) {
|
||||
private val sourceDir = File(scanRootDir, SOURCE_DIR_NAME).apply { mkdirs() }
|
||||
private val processedDir = File(scanRootDir, PROCESSED_DIR_NAME).apply { mkdirs() }
|
||||
private val thumbnailDir = File(scanRootDir, THUMBNAIL_DIR_NAME)
|
||||
|
||||
private val sourceDir: File = File(scanRootDir, SOURCE_DIR_NAME).apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
private val scanDir: File = File(scanRootDir, SCAN_DIR_NAME).apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
private val thumbnailDir: File = File(scanRootDir, THUMBNAIL_DIR_NAME).apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
private val metadataFile = File(scanDir, "document.json")
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val metadataFile = File(processedDir, "document.json")
|
||||
private val json = Json { prettyPrint = false; encodeDefaults = true }
|
||||
private var pages: PageStore = PageStore(loadPages())
|
||||
|
||||
private val processingJobs = synchronizedMap(mutableMapOf<PageViewKey, Deferred<Unit>>())
|
||||
private val imageCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 50)
|
||||
private val thumbnailCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 1000)
|
||||
|
||||
private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> =
|
||||
synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<K, V>) = size > maxEntries
|
||||
})
|
||||
|
||||
// --- Metadata ---
|
||||
|
||||
private fun loadPages(): MutableList<PageV2> {
|
||||
thumbnailDir.deleteRecursively() // clean up dir that was used in older versions
|
||||
normalizeLegacyFiles()
|
||||
val filesOnDisk = scanDir.listFiles()
|
||||
val filesOnDisk = processedDir.listFiles()
|
||||
?.filter { it.extension == "jpg" }
|
||||
?.map { it.name }
|
||||
?.toSet()
|
||||
@@ -80,7 +94,7 @@ class ImageRepository(
|
||||
return when {
|
||||
metadataPages != null ->
|
||||
metadataPages
|
||||
.filter { it.workFileName() in filesOnDisk }
|
||||
.filter { processedImageFileName(it.id, it.colorMode, it.quadVersion) in filesOnDisk }
|
||||
.toMutableList()
|
||||
else ->
|
||||
filesOnDisk
|
||||
@@ -103,11 +117,8 @@ class ImageRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> {
|
||||
return meta.pages.map { old ->
|
||||
pageFromLegacyFileName(old.file)
|
||||
}.toMutableList()
|
||||
}
|
||||
private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> =
|
||||
meta.pages.map { pageFromLegacyFileName(it.file) }.toMutableList()
|
||||
|
||||
private fun pageFromLegacyFileName(fileName: String): PageV2 {
|
||||
val name = fileName.removeSuffix(".jpg")
|
||||
@@ -121,150 +132,228 @@ class ImageRepository(
|
||||
metadataFile.writeText(json.encodeToString(metadata))
|
||||
}
|
||||
|
||||
fun pages(): List<ScanPage> =
|
||||
// --- Main API ---
|
||||
|
||||
suspend fun pages(): List<ScanPage> = mutex.withLock {
|
||||
pages.pages().mapNotNull {
|
||||
runCatching {
|
||||
val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees)
|
||||
ScanPage(it.id, manualRotation, it.toMetadata())
|
||||
ScanPage(it.id, manualRotation, it.colorMode, it.quadVersion, it.toMetadata())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun page(id: String): PageV2? = pages.get(id)
|
||||
|
||||
fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) {
|
||||
val id = "${System.currentTimeMillis()}"
|
||||
val fileName = "$id.jpg"
|
||||
val file = File(scanDir, fileName)
|
||||
file.writeBytes(pageBytes)
|
||||
writeThumbnail(file)
|
||||
File(sourceDir, fileName).writeBytes(sourceBytes)
|
||||
pages.addOrReplace(
|
||||
PageV2(
|
||||
id = id,
|
||||
quad = metadata.normalizedQuad.toSerializable(),
|
||||
baseRotationDegrees = metadata.baseRotation.degrees,
|
||||
manualRotationDegrees = Rotation.R0.degrees,
|
||||
isColored = metadata.isColored
|
||||
)
|
||||
)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
private fun workFileName(key: PageViewKey): String =
|
||||
workFileName(key.pageId, key.rotation)
|
||||
|
||||
private fun workFileName(pageId: String, manualRotation: Rotation): String =
|
||||
workFileName(pageId, manualRotation.degrees)
|
||||
|
||||
private fun workFileName(pageId: String, manualRotationDegrees: Int): String =
|
||||
when (manualRotationDegrees) {
|
||||
0 -> "$pageId.jpg"
|
||||
else -> "$pageId-${manualRotationDegrees}.jpg"
|
||||
suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) =
|
||||
mutex.withLock {
|
||||
val id = "${System.currentTimeMillis()}"
|
||||
val key = PageViewKey(id, Rotation.R0, colorMode, 0)
|
||||
processedImageFile(key).writeBytes(processed.bytes)
|
||||
sourceFile(id).writeBytes(source.bytes)
|
||||
pages.addOrReplace(
|
||||
PageV2(
|
||||
id = id,
|
||||
quad = metadata.normalizedQuad.toSerializable(),
|
||||
baseRotationDegrees = metadata.baseRotation.degrees,
|
||||
manualRotationDegrees = Rotation.R0.degrees,
|
||||
isColored = metadata.autoColorMode == ColorMode.COLOR,
|
||||
colorMode = colorMode,
|
||||
focalLength = metadata.opticalMeasures?.cameraIntrinsics?.focalLength,
|
||||
sensorWidth = metadata.opticalMeasures?.cameraIntrinsics?.sensorWidth,
|
||||
subjectDistance = metadata.opticalMeasures?.subjectDistance,
|
||||
sourceWidth = metadata.sourceSize?.width?.toInt(),
|
||||
sourceHeight = metadata.sourceSize?.height?.toInt(),
|
||||
)
|
||||
)
|
||||
saveMetadata()
|
||||
// Pre-populate cache for R0
|
||||
imageCache.put(key, CompletableDeferred(processed))
|
||||
}
|
||||
|
||||
fun PageV2.workFileName() = workFileName(id, manualRotationDegrees)
|
||||
|
||||
fun rotate(id: String, clockwise: Boolean) {
|
||||
val page = pages.get(id) ?: return
|
||||
|
||||
val delta = if (clockwise) Rotation.R90 else Rotation.R270
|
||||
val newRotation = Rotation.fromDegrees(page.manualRotationDegrees).add(delta)
|
||||
|
||||
val inputFile = File(scanDir, "$id.jpg")
|
||||
val outputFile = File(scanDir, workFileName(id, newRotation.degrees))
|
||||
|
||||
if (inputFile.exists() && !outputFile.exists()) {
|
||||
transformations.rotate(
|
||||
inputFile,
|
||||
outputFile,
|
||||
newRotation.degrees,
|
||||
ExportQuality.BALANCED.jpegQuality
|
||||
suspend fun setColorMode(id: String, colorMode: ColorMode) {
|
||||
updatePage(id) { page, metadata ->
|
||||
PageUpdate(
|
||||
updatedPage = page.copy(colorMode = colorMode),
|
||||
normalizedQuad = metadata.normalizedQuad,
|
||||
colorMode = colorMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setUserQuad(id: String, newQuad: Quad) {
|
||||
updatePage(id) { page, metadata ->
|
||||
PageUpdate(
|
||||
updatedPage = page.copy(
|
||||
quadVersion = page.quadVersion + 1,
|
||||
userQuad = newQuad.toSerializable(),
|
||||
),
|
||||
normalizedQuad = newQuad,
|
||||
colorMode = page.colorMode ?: metadata.autoColorMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class PageUpdate(
|
||||
val updatedPage: PageV2,
|
||||
val normalizedQuad: Quad,
|
||||
val colorMode: ColorMode,
|
||||
)
|
||||
|
||||
private suspend fun updatePage(
|
||||
id: String,
|
||||
buildUpdate: (PageV2, PageMetadata) -> PageUpdate
|
||||
) {
|
||||
val page = mutex.withLock { pages.get(id) }
|
||||
val metadata = page?.toMetadata() ?: return
|
||||
val sourceFile = sourceFile(id)
|
||||
if (!sourceFile.exists())
|
||||
return
|
||||
|
||||
val update = buildUpdate(page, metadata)
|
||||
val key = PageViewKey(
|
||||
pageId = id,
|
||||
rotation = Rotation.R0,
|
||||
colorMode = update.colorMode,
|
||||
quadVersion = update.updatedPage.quadVersion
|
||||
)
|
||||
|
||||
val processedFile = processedImageFile(key)
|
||||
val job = processingJobs.computeIfAbsent(key) {
|
||||
scope.async(Dispatchers.IO) {
|
||||
if (!processedFile.exists()) {
|
||||
val sourceJpeg = Jpeg(sourceFile.readBytes())
|
||||
val processedJpeg =
|
||||
transformations.process(
|
||||
sourceJpeg,
|
||||
metadata = metadata.copy(normalizedQuad = update.normalizedQuad),
|
||||
colorMode = update.colorMode
|
||||
)
|
||||
processedFile.writeBytes(processedJpeg.bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
job.await()
|
||||
} finally {
|
||||
processingJobs.remove(key, job)
|
||||
}
|
||||
|
||||
mutex.withLock {
|
||||
pages.update(id) { update.updatedPage }
|
||||
saveMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock {
|
||||
val page = pages.get(id) ?: return@withLock
|
||||
val delta = if (clockwise) Rotation.R90 else Rotation.R270
|
||||
val newRotation = Rotation.fromDegrees(page.manualRotationDegrees).add(delta)
|
||||
pages.update(id) {
|
||||
it.copy(manualRotationDegrees = newRotation.degrees)
|
||||
}
|
||||
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
fun jpegBytes(key: PageViewKey): ByteArray? {
|
||||
val file = File(scanDir, workFileName(key))
|
||||
return (if (file.exists()) file else null)?.readBytes()
|
||||
}
|
||||
suspend fun jpegBytes(key: PageViewKey): Jpeg? =
|
||||
getOrCompute(imageCache, key, ::computeProcessedImage)
|
||||
|
||||
fun jpegBytes(id: String): ByteArray? {
|
||||
val page = page(id)
|
||||
if (page == null) return null
|
||||
val file = File(scanDir, page.workFileName())
|
||||
return (if (file.exists()) file else null)?.readBytes()
|
||||
}
|
||||
|
||||
fun sourceJpegBytes(id: String): ByteArray? {
|
||||
val file = getSourceFile(id)
|
||||
return if (file.exists()) file.readBytes() else null
|
||||
}
|
||||
suspend fun getThumbnail(key: PageViewKey): Jpeg? =
|
||||
getOrCompute(thumbnailCache, key, ::computeThumbnail)
|
||||
|
||||
private fun getSourceFile(id: String): File {
|
||||
return File(sourceDir, "$id.jpg")
|
||||
}
|
||||
// --- Cache compute functions ---
|
||||
|
||||
fun getThumbnail(key: PageViewKey): ByteArray? {
|
||||
val thumbFile = getThumbnailFile(key)
|
||||
if (thumbFile == null) {
|
||||
return null
|
||||
private suspend fun getOrCompute(
|
||||
cache: MutableMap<PageViewKey, Deferred<Jpeg?>>,
|
||||
key: PageViewKey,
|
||||
compute: suspend (PageViewKey) -> Jpeg?
|
||||
): Jpeg? {
|
||||
val deferred = cache.computeIfAbsent(key) { k ->
|
||||
scope.async(Dispatchers.IO) { compute(k) }
|
||||
}
|
||||
if (!thumbFile.exists()) {
|
||||
val workFile = File(scanDir, workFileName(key))
|
||||
if (!workFile.exists()) return null
|
||||
writeThumbnail(workFile)
|
||||
try {
|
||||
return deferred.await()
|
||||
} catch (e: Exception) {
|
||||
cache.remove(key, deferred)
|
||||
throw e
|
||||
}
|
||||
return if (thumbFile.exists()) thumbFile.readBytes() else null
|
||||
}
|
||||
|
||||
private fun writeThumbnail(originalFile: File) {
|
||||
val thumbFile = File(thumbnailDir, originalFile.name)
|
||||
transformations.resize(originalFile, thumbFile, thumbnailSizePx)
|
||||
private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val baseFile = processedImageFile(key)
|
||||
if (!baseFile.exists()) return@withContext null
|
||||
val baseJpeg = Jpeg(baseFile.readBytes())
|
||||
if (key.rotation == Rotation.R0) {
|
||||
baseJpeg
|
||||
} else {
|
||||
transformations.rotate(
|
||||
baseJpeg,
|
||||
key.rotation.degrees)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun computeThumbnail(key: PageViewKey): Jpeg? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val processed = getOrCompute(imageCache, key, ::computeProcessedImage)
|
||||
?: return@withContext null
|
||||
try {
|
||||
transformations.resizeToThumbnail(processed)
|
||||
} catch (e: Exception) {
|
||||
val message = "Failed to compute thumbnail for ${key.pageId}"
|
||||
logger.e("ImageRepository", message, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Other operations ---
|
||||
|
||||
private fun processedImageFileName(id: String, colorMode: ColorMode?, quadVersion: Int) : String {
|
||||
val sb = StringBuilder(id)
|
||||
if (colorMode != null)
|
||||
sb.append(".").append(colorMode.name.lowercase())
|
||||
if (quadVersion > 0)
|
||||
sb.append(".q").append(quadVersion)
|
||||
sb.append(".jpg")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun getThumbnailFile(key: PageViewKey): File? {
|
||||
return File(thumbnailDir, workFileName(key))
|
||||
private fun processedImageFile(key: PageViewKey) : File =
|
||||
File(processedDir, processedImageFileName(key.pageId, key.colorMode, key.quadVersion))
|
||||
|
||||
private fun sourceFile(id: String): File =
|
||||
File(sourceDir, "$id.jpg")
|
||||
|
||||
fun source(id: String): Jpeg? {
|
||||
val file = sourceFile(id)
|
||||
return if (file.exists()) Jpeg(file.readBytes()) else null
|
||||
}
|
||||
|
||||
fun movePage(id: String, newIndex: Int) {
|
||||
suspend fun movePage(id: String, newIndex: Int) = mutex.withLock {
|
||||
pages.move(id, newIndex)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
suspend fun delete(id: String) = mutex.withLock {
|
||||
pages.delete(id)
|
||||
saveMetadata()
|
||||
|
||||
getSourceFile(id).delete()
|
||||
scanDir.listFiles()
|
||||
?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") }
|
||||
?.forEach { it.delete() }
|
||||
thumbnailDir.listFiles()
|
||||
?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") }
|
||||
sourceFile(id).delete()
|
||||
processedDir.listFiles()
|
||||
?.filter { it.name.startsWith("$id.") || it.name.startsWith("$id-") }
|
||||
?.forEach { it.delete() }
|
||||
// No need to clean caches: stale entries will be evicted by LRU
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
suspend fun clear() = mutex.withLock {
|
||||
pages.clear()
|
||||
saveMetadata() // "empty" json file
|
||||
|
||||
thumbnailDir.listFiles()?.forEach {
|
||||
file -> file.delete()
|
||||
}
|
||||
scanDir.listFiles()?.forEach {
|
||||
file -> file.delete()
|
||||
}
|
||||
sourceDir.listFiles()?.forEach {
|
||||
file -> file.delete()
|
||||
}
|
||||
saveMetadata()
|
||||
sourceDir.listFiles()?.forEach { it.delete() }
|
||||
processedDir.listFiles()?.forEach { it.delete() }
|
||||
synchronized(imageCache) { imageCache.clear() }
|
||||
synchronized(thumbnailCache) { thumbnailCache.clear() }
|
||||
}
|
||||
|
||||
// --- Legacy migration ---
|
||||
|
||||
data class DiskPageFiles(
|
||||
val base: File?,
|
||||
val rotated: List<File>
|
||||
@@ -275,7 +364,7 @@ class ImageRepository(
|
||||
// and discard the others. We intentionally sacrifice exact rotation
|
||||
// fidelity to restore a coherent model.
|
||||
private fun normalizeLegacyFiles() {
|
||||
val jpgs = scanDir.listFiles()?.filter { it.extension == "jpg" }.orEmpty()
|
||||
val jpgs = processedDir.listFiles()?.filter { it.extension == "jpg" }.orEmpty()
|
||||
val byId = jpgs.groupBy { file ->
|
||||
val name = file.name.removeSuffix(".jpg")
|
||||
val dash = name.lastIndexOf('-')
|
||||
@@ -290,7 +379,7 @@ class ImageRepository(
|
||||
if (files.base == null && files.rotated.isNotEmpty()) {
|
||||
val sortedRotatedFiles = files.rotated.sortedBy { it.name }
|
||||
val legacyFile = sortedRotatedFiles.first()
|
||||
val target = File(scanDir, "$id.jpg")
|
||||
val target = File(processedDir, "$id.jpg")
|
||||
if (legacyFile.renameTo(target)) {
|
||||
sortedRotatedFiles.drop(1).forEach { it.delete() }
|
||||
}
|
||||
@@ -325,9 +414,17 @@ fun NormalizedQuad.toQuad(): Quad =
|
||||
|
||||
fun PageV2.toMetadata(): PageMetadata? {
|
||||
if (quad == null || isColored == null) return null
|
||||
val cameraIntrinsics = cameraIntrinsics(focalLength, sensorWidth)
|
||||
val sourceSize =
|
||||
if (sourceWidth != null && sourceHeight != null)
|
||||
ImageSize(sourceWidth, sourceHeight)
|
||||
else
|
||||
null
|
||||
return PageMetadata(
|
||||
quad.toQuad(),
|
||||
(userQuad ?: quad).toQuad(),
|
||||
Rotation.fromDegrees(baseRotationDegrees),
|
||||
isColored
|
||||
if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE,
|
||||
sourceSize,
|
||||
cameraIntrinsics?.let { OpticalMeasures(it, subjectDistance) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,12 +14,20 @@
|
||||
*/
|
||||
package org.fairscan.app.data
|
||||
|
||||
import java.io.File
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
interface ImageTransformations {
|
||||
|
||||
fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int)
|
||||
fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg
|
||||
|
||||
fun resize(inputFile: File, outputFile: File, maxSize: Int)
|
||||
fun resizeToThumbnail(input: Jpeg): Jpeg
|
||||
|
||||
fun process(
|
||||
source: Jpeg,
|
||||
metadata: PageMetadata,
|
||||
colorMode: ColorMode
|
||||
): Jpeg
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.data
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
data class OcrLanguage(
|
||||
val code: String,
|
||||
) {
|
||||
val locale: Locale by lazy {
|
||||
resolveLocale(code)
|
||||
}
|
||||
|
||||
fun displayName(displayLocale: Locale): String =
|
||||
locale.getDisplayName(displayLocale)
|
||||
|
||||
companion object {
|
||||
private fun verticalVariant(locale: Locale) =
|
||||
Locale.Builder().setLocale(locale).setVariant("vertical").build()
|
||||
|
||||
private fun resolveLocale(code: String): Locale {
|
||||
val firstPart = code.substringBefore("_")
|
||||
return LOCALE_OVERRIDES[code] ?: Locale.Builder().setLanguage(firstPart).build()
|
||||
}
|
||||
|
||||
val AVAILABLE_LANGUAGE_CODES = listOf(
|
||||
"afr", "amh", "ara", "asm", "aze_cyrl", "aze", "bel", "ben", "bod", "bos",
|
||||
"bre", "bul", "cat", "ceb", "ces", "chi_sim", "chi_sim_vert", "chi_tra",
|
||||
"chi_tra_vert", "chr", "cos", "cym", "dan", "deu", "div", "dzo",
|
||||
"ell", "eng", "enm", "epo", "est", "eus", "fao", "fas", "fil", "fin", "fra",
|
||||
"frm", "fry", "gla", "gle", "glg", "grc", "guj", "hat", "heb", "hin", "hrv",
|
||||
"hun", "hye", "iku", "ind", "isl", "ita", "jav", "jpn", "jpn_vert",
|
||||
"kan", "kat_old", "kat", "kaz", "khm", "kir", "kmr", "kor", "kor_vert", "lao",
|
||||
"lat", "lav", "lit", "ltz", "mal", "mar", "mkd", "mlt", "mon", "mri", "msa",
|
||||
"mya", "nep", "nld", "nor", "oci", "ori", "pan", "pol", "por", "pus", "que",
|
||||
"ron", "rus", "san", "sin", "slk", "slv", "snd", "spa", "sqi",
|
||||
"srp_latn", "srp", "sun", "swa", "swe", "syr", "tam", "tat", "tel", "tgk",
|
||||
"tha", "tir", "ton", "tur", "uig", "ukr", "urd", "uzb_cyrl", "uzb", "vie",
|
||||
"yid", "yor",
|
||||
)
|
||||
|
||||
private val LOCALE_OVERRIDES = mapOf(
|
||||
// Codes not recognized by Locale.Builder
|
||||
"bod" to Locale.forLanguageTag("bo"), // Tibetan
|
||||
"ces" to Locale.forLanguageTag("cs"), // Czech
|
||||
"cym" to Locale.forLanguageTag("cy"), // Welsh
|
||||
"deu" to Locale.GERMAN,
|
||||
"ell" to Locale.forLanguageTag("el"), // Greek
|
||||
"eus" to Locale.forLanguageTag("eu"), // Basque
|
||||
"fas" to Locale.forLanguageTag("fa"), // Persian
|
||||
"fra" to Locale.FRENCH,
|
||||
"hye" to Locale.forLanguageTag("hy"), // Armenian
|
||||
"isl" to Locale.forLanguageTag("is"), // Icelandic
|
||||
"kat" to Locale.forLanguageTag("ka"), // Georgian
|
||||
"kmr" to Locale.forLanguageTag("ku"), // Kurdish
|
||||
"mkd" to Locale.forLanguageTag("mk"), // Macedonian
|
||||
"mri" to Locale.forLanguageTag("mi"), // Maori
|
||||
"msa" to Locale.forLanguageTag("ms"), // Malay
|
||||
"mya" to Locale.forLanguageTag("my"), // Burmese
|
||||
"nld" to Locale.forLanguageTag("nl"), // Dutch
|
||||
"ron" to Locale.forLanguageTag("ro"), // Romanian
|
||||
"slk" to Locale.forLanguageTag("sk"), // Slovak
|
||||
"sqi" to Locale.forLanguageTag("sq"), // Albanian
|
||||
// Variants pointing to same Locale
|
||||
"chi_sim" to Locale.SIMPLIFIED_CHINESE,
|
||||
"chi_tra" to Locale.TRADITIONAL_CHINESE,
|
||||
"chi_sim_vert" to verticalVariant(Locale.SIMPLIFIED_CHINESE),
|
||||
"chi_tra_vert" to verticalVariant(Locale.TRADITIONAL_CHINESE),
|
||||
"jpn_vert" to verticalVariant(Locale.JAPANESE),
|
||||
"kor_vert" to verticalVariant(Locale.forLanguageTag("ko")),
|
||||
"aze_cyrl" to Locale.forLanguageTag("az-Cyrl"),
|
||||
"uzb_cyrl" to Locale.forLanguageTag("uz-Cyrl"),
|
||||
"srp_latn" to Locale.forLanguageTag("sr-Latn"),
|
||||
"deu_latf" to Locale.forLanguageTag("de-1901"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.data.OcrLanguage.Companion.AVAILABLE_LANGUAGE_CODES
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class OcrLanguageRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
val tessdataDir: File,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val ENABLED_LANGUAGES = stringSetPreferencesKey("enabled_ocr_languages")
|
||||
}
|
||||
|
||||
val enabledLanguages: Flow<Set<String>> =
|
||||
dataStore.data.map { prefs ->
|
||||
prefs[ENABLED_LANGUAGES] ?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun getInstalledLanguages(): Set<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!tessdataDir.exists()) {
|
||||
return@withContext emptySet()
|
||||
}
|
||||
return@withContext tessdataDir
|
||||
.listFiles { _, name -> name.endsWith(".traineddata") }
|
||||
?.map { f -> f.nameWithoutExtension }
|
||||
?.filter { it in AVAILABLE_LANGUAGE_CODES }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun getActiveLanguages(): Set<String> {
|
||||
val enabled = enabledLanguagesValue()
|
||||
val installed = getInstalledLanguages()
|
||||
return enabled.intersect(installed)
|
||||
}
|
||||
|
||||
suspend fun setLanguageEnabled(
|
||||
code: String,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
require(code in AVAILABLE_LANGUAGE_CODES)
|
||||
if (enabled && code !in getInstalledLanguages()) {
|
||||
return
|
||||
}
|
||||
dataStore.edit { prefs ->
|
||||
val current = prefs[ENABLED_LANGUAGES]?.toMutableSet() ?: mutableSetOf()
|
||||
if (enabled) {
|
||||
current.add(code)
|
||||
} else {
|
||||
current.remove(code)
|
||||
}
|
||||
prefs[ENABLED_LANGUAGES] = current
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadProgress(
|
||||
val downloadedBytes: Long,
|
||||
val totalBytes: Long?,
|
||||
)
|
||||
|
||||
suspend fun downloadLanguage(code: String, onProgress: (DownloadProgress) -> Unit) =
|
||||
withContext(Dispatchers.IO) {
|
||||
require(code in AVAILABLE_LANGUAGE_CODES)
|
||||
tessdataDir.mkdirs()
|
||||
|
||||
val url = URL("https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/$code.traineddata")
|
||||
val targetFile = File(tessdataDir, "$code.traineddata")
|
||||
val tempFile = File(tessdataDir, "$code.download")
|
||||
|
||||
val connection = (url.openConnection() as HttpURLConnection)
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 30_000
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.connect()
|
||||
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw IOException("HTTP ${connection.responseCode}")
|
||||
}
|
||||
connection.inputStream.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
val totalBytes = connection.contentLengthLong.takeIf { it > 0 }
|
||||
val buffer = ByteArray(8192)
|
||||
var downloadedBytes = 0L
|
||||
input.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
while (true) {
|
||||
coroutineContext.ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
downloadedBytes += read
|
||||
onProgress(DownloadProgress(downloadedBytes, totalBytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tempFile.length() < 300_000) {
|
||||
throw IOException("Downloaded file too small")
|
||||
}
|
||||
if (!tempFile.renameTo(targetFile)) {
|
||||
throw IOException("Failed to move downloaded file")
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteLanguage(code: String) {
|
||||
val file = File(tessdataDir, "$code.traineddata")
|
||||
file.delete()
|
||||
setLanguageEnabled(code, false)
|
||||
}
|
||||
|
||||
suspend fun buildTesseractLanguageString(): String {
|
||||
return getActiveLanguages()
|
||||
.sorted()
|
||||
.joinToString("+")
|
||||
}
|
||||
|
||||
private suspend fun enabledLanguagesValue(): Set<String> {
|
||||
return enabledLanguages.map { it }.first()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025-2026 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.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.Serializer
|
||||
import androidx.datastore.dataStore
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import org.fairscan.app.RecentDocuments
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object RecentDocumentsSerializer : Serializer<RecentDocuments> {
|
||||
override val defaultValue: RecentDocuments = RecentDocuments.getDefaultInstance()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): RecentDocuments {
|
||||
return try {
|
||||
RecentDocuments.parseFrom(input)
|
||||
} catch (e: InvalidProtocolBufferException) {
|
||||
throw CorruptionException("Cannot read proto.", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(
|
||||
t: RecentDocuments,
|
||||
output: OutputStream
|
||||
) = t.writeTo(output)
|
||||
}
|
||||
|
||||
val Context.recentDocumentsDataStore: DataStore<RecentDocuments> by dataStore(
|
||||
fileName = "recent_documents.pb",
|
||||
serializer = RecentDocumentsSerializer
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -12,13 +12,14 @@
|
||||
* 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.domain
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import kotlinx.coroutines.Deferred
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
data class CapturedPage(
|
||||
val page: Bitmap,
|
||||
val source: Bitmap,
|
||||
val pageJpeg: Jpeg,
|
||||
val sourceJpeg: Deferred<Jpeg>,
|
||||
val metadata: PageMetadata,
|
||||
val colorMode: ColorMode,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,110 +15,100 @@
|
||||
package org.fairscan.app.domain
|
||||
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.imageprocessing.extractDocument
|
||||
import org.fairscan.app.platform.processedImage
|
||||
import org.fairscan.imageprocessing.EstimatedDimensions
|
||||
import org.fairscan.imageprocessing.estimateRealDimensions
|
||||
import org.fairscan.imageprocessing.resizeForMaxPixels
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.core.MatOfByte
|
||||
import org.opencv.core.MatOfInt
|
||||
import org.opencv.imgcodecs.Imgcodecs
|
||||
|
||||
fun jpegsForExport(
|
||||
fun interface JpegProvider {
|
||||
suspend fun get(): Jpeg
|
||||
}
|
||||
|
||||
data class PageToExport(
|
||||
val page: ScanPage,
|
||||
val jpeg: JpegProvider,
|
||||
) {
|
||||
fun estimatedDimensions(): EstimatedDimensions? {
|
||||
val metadata = page.metadata
|
||||
if (metadata == null)
|
||||
return null
|
||||
val size = metadata.sourceSize
|
||||
if (size == null)
|
||||
return null
|
||||
val quad = metadata.normalizedQuad.scaledTo(1.0, 1.0, size.width, size.height)
|
||||
val realDimensions = estimateRealDimensions(
|
||||
quad, size.width.toInt(), size.height.toInt(), metadata.opticalMeasures
|
||||
).snapToStandardFormat()
|
||||
return realDimensions.applyRotation(page.totalRotation())
|
||||
}
|
||||
}
|
||||
|
||||
private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDimensions {
|
||||
if ((rotation == Rotation.R90 || rotation == Rotation.R270)
|
||||
&& this is EstimatedDimensions.Physical) {
|
||||
return EstimatedDimensions.Physical(heightMm, widthMm)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
suspend fun pagesToExport(
|
||||
imageRepository: ImageRepository,
|
||||
exportQuality: ExportQuality
|
||||
): Sequence<ByteArray> {
|
||||
): List<PageToExport> {
|
||||
|
||||
val pages = imageRepository.pages().asSequence()
|
||||
val pages = imageRepository.pages()
|
||||
return when (exportQuality) {
|
||||
ExportQuality.BALANCED -> pages.mapNotNull { imageRepository.jpegBytes(it.id) }
|
||||
ExportQuality.BALANCED -> pages.map {
|
||||
PageToExport(it) { jpeg(it, imageRepository) }
|
||||
}
|
||||
|
||||
ExportQuality.LOW -> pages.mapNotNull { page ->
|
||||
imageRepository.jpegBytes(page.id)?.let { jpeg ->
|
||||
ExportQuality.LOW -> pages.map { page ->
|
||||
PageToExport(page) {
|
||||
resizeJpegBytesForMaxPixels(
|
||||
jpegBytes = jpeg,
|
||||
jpeg = jpeg(page, imageRepository),
|
||||
maxPixels = exportQuality.maxPixels.toDouble(),
|
||||
jpegQuality = exportQuality.jpegQuality
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ExportQuality.HIGH -> pages.mapNotNull { page ->
|
||||
val sourceJpegBytes = imageRepository.sourceJpegBytes(page.id)
|
||||
val pageMetadata = page.metadata
|
||||
val manualRotation = page.manualRotation
|
||||
if (sourceJpegBytes != null && pageMetadata != null)
|
||||
prepareJpegForHigh(sourceJpegBytes, pageMetadata, manualRotation, exportQuality)
|
||||
else
|
||||
imageRepository.jpegBytes(page.id)
|
||||
ExportQuality.HIGH -> pages.map { page ->
|
||||
PageToExport(page) {
|
||||
val source = imageRepository.source(page.id)
|
||||
val metadata = page.metadata
|
||||
val colorMode = page.colorMode
|
||||
if (source != null && metadata != null && colorMode != null) {
|
||||
val rotation = page.totalRotation()
|
||||
processedImage(source, metadata, rotation, colorMode, exportQuality)
|
||||
}
|
||||
else
|
||||
jpeg(page, imageRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resizeJpegBytesForMaxPixels(
|
||||
jpegBytes: ByteArray,
|
||||
private suspend fun jpeg(page: ScanPage, imageRepository: ImageRepository): Jpeg {
|
||||
val key = page.key()
|
||||
return imageRepository.jpegBytes(key)
|
||||
?: throw IllegalArgumentException("JPEG not found for $key")
|
||||
}
|
||||
|
||||
private fun resizeJpegBytesForMaxPixels(
|
||||
jpeg: Jpeg,
|
||||
maxPixels: Double,
|
||||
jpegQuality: Int
|
||||
): ByteArray? {
|
||||
val decoded = decodeJpeg(jpegBytes)
|
||||
if (decoded == null)
|
||||
return null
|
||||
|
||||
val resized = resizeForMaxPixels(decoded, maxPixels)
|
||||
val outJpegBytes = encodeJpeg(resized, jpegQuality)
|
||||
|
||||
decoded.release()
|
||||
resized.release()
|
||||
return outJpegBytes
|
||||
}
|
||||
|
||||
fun prepareJpegForHigh(
|
||||
sourceJpegBytes: ByteArray,
|
||||
pageMetadata: PageMetadata,
|
||||
manualRotation: Rotation,
|
||||
exportQuality: ExportQuality,
|
||||
): ByteArray? {
|
||||
|
||||
val decoded = decodeJpeg(sourceJpegBytes)
|
||||
if (decoded == null)
|
||||
return null
|
||||
|
||||
val quad = pageMetadata.normalizedQuad.scaledTo(1,1,decoded.width(), decoded.height())
|
||||
val page = extractDocument(
|
||||
decoded,
|
||||
quad,
|
||||
pageMetadata.baseRotation.add(manualRotation).degrees,
|
||||
pageMetadata.isColored,
|
||||
exportQuality.maxPixels)
|
||||
val outJpegBytes = encodeJpeg(page, exportQuality.jpegQuality)
|
||||
|
||||
decoded.release()
|
||||
page.release()
|
||||
return outJpegBytes
|
||||
}
|
||||
|
||||
fun decodeJpeg(jpegBytes: ByteArray): Mat? {
|
||||
val src = MatOfByte(*jpegBytes)
|
||||
val decoded = Imgcodecs.imdecode(src, Imgcodecs.IMREAD_COLOR)
|
||||
src.release()
|
||||
if (decoded.empty()) {
|
||||
decoded.release()
|
||||
return null
|
||||
): Jpeg {
|
||||
var decoded: Mat? = null
|
||||
var resized: Mat? = null
|
||||
try {
|
||||
decoded = jpeg.toMat()
|
||||
resized = resizeForMaxPixels(decoded, maxPixels)
|
||||
return Jpeg.fromMat(resized, jpegQuality)
|
||||
} finally {
|
||||
decoded?.release()
|
||||
resized?.release()
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
fun encodeJpeg(mat: Mat, jpegQuality: Int): ByteArray? {
|
||||
val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, jpegQuality.coerceIn(0, 100))
|
||||
val encoded = MatOfByte()
|
||||
val ok = Imgcodecs.imencode(".jpg", mat, encoded, params)
|
||||
params.release()
|
||||
|
||||
if (!ok) {
|
||||
encoded.release()
|
||||
return null
|
||||
}
|
||||
|
||||
val result = encoded.toArray()
|
||||
encoded.release()
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.domain
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import org.fairscan.imageprocessing.decodeJpeg
|
||||
import org.fairscan.imageprocessing.encodeJpeg
|
||||
import org.opencv.core.Mat
|
||||
|
||||
class Jpeg(val bytes: ByteArray) {
|
||||
companion object {
|
||||
fun fromMat(mat: Mat, jpegQuality: Int): Jpeg = Jpeg(encodeJpeg(mat, jpegQuality))
|
||||
}
|
||||
fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
fun toMat() : Mat = decodeJpeg(bytes)
|
||||
}
|
||||
|
||||
interface ImageLoader {
|
||||
suspend fun load(uri: Uri): Bitmap
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -20,15 +20,10 @@ import android.graphics.Bitmap.createBitmap
|
||||
import android.graphics.Color
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.data.Logger
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Mask
|
||||
import org.opencv.core.CvType
|
||||
import org.opencv.core.Mat
|
||||
@@ -39,7 +34,6 @@ import org.tensorflow.lite.support.common.ops.NormalizeOp
|
||||
import org.tensorflow.lite.support.image.ImageProcessor
|
||||
import org.tensorflow.lite.support.image.TensorImage
|
||||
import org.tensorflow.lite.support.image.ops.ResizeOp
|
||||
import org.tensorflow.lite.support.image.ops.Rot90Op
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
@@ -49,9 +43,6 @@ class ImageSegmentationService(private val context: Context, private val logger:
|
||||
private const val TAG = "ImageSegmentation"
|
||||
}
|
||||
|
||||
private val _segmentation = MutableStateFlow<SegmentationResult?>(null)
|
||||
val segmentation: StateFlow<SegmentationResult?> = _segmentation.asStateFlow()
|
||||
|
||||
private var interpreter: Interpreter? = null
|
||||
private val inferenceLock = Mutex()
|
||||
|
||||
@@ -70,16 +61,14 @@ class ImageSegmentationService(private val context: Context, private val logger:
|
||||
}
|
||||
}
|
||||
|
||||
private fun runSegmentation(interpreter: Interpreter, bitmap: Bitmap, rotationDegrees: Int): SegmentationResult {
|
||||
private fun runSegmentation(interpreter: Interpreter, bitmap: Bitmap): SegmentationResult {
|
||||
val startTime = SystemClock.uptimeMillis()
|
||||
|
||||
val rotation = -rotationDegrees / 90
|
||||
val (_, h, w, _) = interpreter.getOutputTensor(0).shape()
|
||||
val imageProcessor =
|
||||
ImageProcessor
|
||||
.Builder()
|
||||
.add(ResizeOp(h, w, ResizeOp.ResizeMethod.BILINEAR))
|
||||
.add(Rot90Op(rotation))
|
||||
.add(NormalizeOp(127.5f, 127.5f)) // TODO check if it's correct
|
||||
.build()
|
||||
val tensorImage = TensorImage(DataType.FLOAT32)
|
||||
@@ -91,25 +80,12 @@ class ImageSegmentationService(private val context: Context, private val logger:
|
||||
return SegmentationResult(segmentResult, inferenceTime)
|
||||
}
|
||||
|
||||
suspend fun runSegmentationAndReturn(bitmap: Bitmap, rotationDegrees: Int): SegmentationResult? {
|
||||
suspend fun runSegmentationAndReturn(bitmap: Bitmap): SegmentationResult? {
|
||||
if (interpreter == null) {
|
||||
return null
|
||||
}
|
||||
return inferenceLock.withLock {
|
||||
runSegmentation(interpreter!!, bitmap, rotationDegrees)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runSegmentationAndEmit(bitmap: Bitmap, rotationDegrees: Int) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val segmentationResult = runSegmentationAndReturn(bitmap, rotationDegrees)
|
||||
if (isActive) {
|
||||
_segmentation.value = segmentationResult
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(TAG, "Error occurred in image segmentation", e)
|
||||
runSegmentation(interpreter!!, bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +139,8 @@ class ImageSegmentationService(private val context: Context, private val logger:
|
||||
mask.put(0, 0, data)
|
||||
return mask
|
||||
}
|
||||
|
||||
fun maskSize() = ImageSize(width, height)
|
||||
}
|
||||
|
||||
data class SegmentationResult(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.domain
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import com.googlecode.tesseract.android.TessBaseAPI
|
||||
import com.googlecode.tesseract.android.TessBaseAPI.PageIteratorLevel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.fairscan.app.data.OcrLanguageRepository
|
||||
import org.fairscan.imageprocessing.ImageRect
|
||||
import org.fairscan.imageprocessing.OcrTextBox
|
||||
|
||||
class OcrService(
|
||||
private val ocrLanguageRepository: OcrLanguageRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private var tess: TessBaseAPI? = null
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private var languageString = ""
|
||||
fun languageString() = languageString
|
||||
|
||||
fun initialize() {
|
||||
scope.launch {
|
||||
ocrLanguageRepository.enabledLanguages.collect { _ -> reinitialize() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reinitialize() {
|
||||
mutex.withLock {
|
||||
tess?.recycle()
|
||||
tess = null
|
||||
|
||||
languageString = ocrLanguageRepository.buildTesseractLanguageString()
|
||||
if (languageString.isEmpty()) return
|
||||
|
||||
val dataPath = ocrLanguageRepository.tessdataDir.parent!!
|
||||
val newTess = TessBaseAPI()
|
||||
if (!newTess.init(dataPath, languageString)) {
|
||||
newTess.recycle()
|
||||
return
|
||||
}
|
||||
tess = newTess
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runOcr(bitmap: Bitmap): List<OcrTextBox> {
|
||||
mutex.withLock {
|
||||
val tess = this.tess ?: return listOf()
|
||||
val textBoxes = mutableListOf<OcrTextBox>()
|
||||
tess.setImage(bitmap)
|
||||
tess.getUTF8Text() // Trigger text recognition
|
||||
val iterator = tess.resultIterator
|
||||
iterator.begin()
|
||||
do {
|
||||
val word = iterator.getUTF8Text(PageIteratorLevel.RIL_WORD) ?: continue
|
||||
val wordBox = iterator.getBoundingRect(PageIteratorLevel.RIL_WORD)
|
||||
val lineBox = iterator.getBoundingRect(PageIteratorLevel.RIL_TEXTLINE)
|
||||
val confidence = iterator.confidence(PageIteratorLevel.RIL_WORD)
|
||||
if (confidence > 50) {
|
||||
val rect = wordBox.toImageRect()
|
||||
textBoxes.add(OcrTextBox(word, rect, lineBox.height(), lineBox.bottom))
|
||||
}
|
||||
} while (iterator.next(PageIteratorLevel.RIL_WORD))
|
||||
iterator.delete()
|
||||
return textBoxes
|
||||
}
|
||||
}
|
||||
|
||||
private fun Rect.toImageRect(): ImageRect = ImageRect(left, top, right, bottom)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,27 +14,36 @@
|
||||
*/
|
||||
package org.fairscan.app.domain
|
||||
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
data class PageMetadata(
|
||||
val normalizedQuad: Quad,
|
||||
val baseRotation: Rotation,
|
||||
val isColored: Boolean,
|
||||
val autoColorMode: ColorMode,
|
||||
val sourceSize: ImageSize?,
|
||||
val opticalMeasures: OpticalMeasures?,
|
||||
)
|
||||
|
||||
data class ScanPage(
|
||||
val id: String,
|
||||
val manualRotation: Rotation,
|
||||
val colorMode: ColorMode?,
|
||||
val quadVersion: Int,
|
||||
val metadata: PageMetadata?,
|
||||
)
|
||||
) {
|
||||
fun key() = PageViewKey(id, manualRotation, colorMode, quadVersion)
|
||||
fun totalRotation() = manualRotation.add(metadata?.baseRotation ?: Rotation.R0)
|
||||
}
|
||||
|
||||
data class PageViewKey(
|
||||
val pageId: String,
|
||||
val rotation: Rotation,
|
||||
) {
|
||||
val saveKey: String get() = "$pageId-${rotation.degrees}"
|
||||
}
|
||||
|
||||
val colorMode: ColorMode?,
|
||||
val quadVersion: Int,
|
||||
)
|
||||
enum class Rotation(val degrees: Int) {
|
||||
R0(0),
|
||||
R90(90),
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.platform
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.util.component1
|
||||
import androidx.core.util.component2
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.domain.ImageLoader
|
||||
import java.io.IOException
|
||||
|
||||
class AndroidImageLoader(
|
||||
private val contentResolver: ContentResolver
|
||||
) : ImageLoader {
|
||||
|
||||
override suspend fun load(uri: Uri): Bitmap {
|
||||
val bitmap = loadBitmapFromUri(contentResolver, uri)
|
||||
return ensureArgb8888(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadBitmapFromUri(
|
||||
contentResolver: ContentResolver,
|
||||
uri: Uri,
|
||||
maxPixels: Int = 12_000_000,
|
||||
): Bitmap = withContext(Dispatchers.IO) {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
decodeWithImageDecoder(contentResolver, uri, maxPixels)
|
||||
} else {
|
||||
decodeWithBitmapFactory(contentResolver, uri)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(28)
|
||||
private fun decodeWithImageDecoder(
|
||||
contentResolver: ContentResolver,
|
||||
uri: Uri,
|
||||
maxPixels: Int
|
||||
): Bitmap {
|
||||
val source = ImageDecoder.createSource(contentResolver, uri)
|
||||
return ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||
val (width, height) = info.size
|
||||
val scale = computeScale(width, height, maxPixels)
|
||||
decoder.setTargetSize((width * scale).toInt(), (height * scale).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeWithBitmapFactory(contentResolver: ContentResolver, uri: Uri, ): Bitmap {
|
||||
val options = BitmapFactory.Options()
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
?: throw IOException("Cannot open input stream for uri: $uri")
|
||||
return inputStream.use {
|
||||
BitmapFactory.decodeStream(it, null, options)
|
||||
?: throw IOException("Failed to decode bitmap for uri: $uri")
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeScale(width: Int, height: Int, maxPixels: Int): Float {
|
||||
val pixels = width * height
|
||||
return if (pixels > maxPixels) {
|
||||
maxPixels.toFloat() / pixels
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureArgb8888(bitmap: Bitmap): Bitmap {
|
||||
return if (bitmap.config != Bitmap.Config.ARGB_8888) {
|
||||
bitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,30 +14,283 @@
|
||||
*/
|
||||
package org.fairscan.app.platform
|
||||
|
||||
import android.content.res.AssetManager
|
||||
import android.util.Log
|
||||
import com.tom_roush.pdfbox.cos.COSArray
|
||||
import com.tom_roush.pdfbox.cos.COSDictionary
|
||||
import com.tom_roush.pdfbox.cos.COSName
|
||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPage
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream
|
||||
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream.AppendMode
|
||||
import com.tom_roush.pdfbox.pdmodel.PDResources
|
||||
import com.tom_roush.pdfbox.pdmodel.common.PDRectangle
|
||||
import com.tom_roush.pdfbox.pdmodel.common.PDStream
|
||||
import com.tom_roush.pdfbox.pdmodel.font.PDFontDescriptor
|
||||
import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory
|
||||
import org.fairscan.app.BuildConfig
|
||||
import org.fairscan.app.data.PdfWriter
|
||||
import org.fairscan.app.domain.OcrService
|
||||
import org.fairscan.app.domain.PageToExport
|
||||
import org.fairscan.imageprocessing.EstimatedDimensions
|
||||
import org.fairscan.imageprocessing.OcrTextBox
|
||||
import org.fairscan.imageprocessing.PaperFormats
|
||||
import java.io.OutputStream
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
class AndroidPdfWriter : PdfWriter {
|
||||
override fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, outputStream: OutputStream): Int {
|
||||
class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : PdfWriter {
|
||||
|
||||
override suspend fun writePdfFromJpegs(
|
||||
pages: List<PageToExport>,
|
||||
outputStream: OutputStream,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
val doc = PDDocument()
|
||||
doc.documentInformation.creationDate = Calendar.getInstance()
|
||||
doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}"
|
||||
doc.use { document ->
|
||||
for (jpegBytes in jpegs) {
|
||||
val image = JPEGFactory.createFromByteArray(document, jpegBytes)
|
||||
val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat()))
|
||||
val ocrDocument = OcrDocument(document, assets)
|
||||
for ((index, page) in pages.withIndex()) {
|
||||
val jpeg = page.jpeg.get()
|
||||
val image = JPEGFactory.createFromByteArray(document, jpeg.bytes)
|
||||
|
||||
// PDF has 72 points (units) per inch, 1 inch = 25.4 mm
|
||||
val pointsPerMm = 72f / 25.4f
|
||||
|
||||
val widthPx = image.width.toFloat()
|
||||
val heightPx = image.height.toFloat()
|
||||
|
||||
val dimensions = page.estimatedDimensions()
|
||||
val (widthMm, heightMm) = when (dimensions) {
|
||||
is EstimatedDimensions.Physical ->
|
||||
constrainToMaxFormat(dimensions.widthMm, dimensions.heightMm)
|
||||
else -> {
|
||||
// No physical dimensions available
|
||||
val maxDimMm = PaperFormats.A4.heightMm
|
||||
val scalePxToMm = maxDimMm / maxOf(widthPx, heightPx)
|
||||
constrainToMaxFormat(widthPx * scalePxToMm, heightPx * scalePxToMm)
|
||||
}
|
||||
}
|
||||
val widthPoints = widthMm.toFloat() * pointsPerMm
|
||||
val heightPoints = heightMm.toFloat() * pointsPerMm
|
||||
|
||||
val page = PDPage(PDRectangle(widthPoints, heightPoints))
|
||||
document.addPage(page)
|
||||
|
||||
val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false)
|
||||
contentStream.drawImage(image, 0f, 0f)
|
||||
contentStream.drawImage(image, 0f, 0f, widthPoints, heightPoints)
|
||||
|
||||
if (!disableOcr) {
|
||||
try {
|
||||
val bitmap = jpeg.toBitmap()
|
||||
val ocrTextBoxes = ocrService.runOcr(bitmap)
|
||||
val pdfPageDimensions = PageDimensions(
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
widthPoints,
|
||||
heightPoints
|
||||
)
|
||||
ocrDocument.addPage(page, ocrTextBoxes, pdfPageDimensions)
|
||||
} catch (e: Exception) {
|
||||
Log.e("AndroidPdfWriter", "Failed to run OCR on page $index", e)
|
||||
}
|
||||
}
|
||||
contentStream.close()
|
||||
|
||||
onProgress(index + 1)
|
||||
}
|
||||
// TODO So the whole document is in memory before this line...
|
||||
document.save(outputStream)
|
||||
}
|
||||
return doc.numberOfPages
|
||||
}
|
||||
}
|
||||
|
||||
fun constrainToMaxFormat(widthMm: Double, heightMm: Double): Pair<Double, Double> {
|
||||
val maxDim = 297.0 // A4 height
|
||||
val minDim = 215.9 // Letter width
|
||||
|
||||
val scale = minOf(
|
||||
maxDim / maxOf(widthMm, heightMm),
|
||||
minDim / minOf(widthMm, heightMm),
|
||||
1.0
|
||||
)
|
||||
return widthMm * scale to heightMm * scale
|
||||
}
|
||||
|
||||
data class PageDimensions(
|
||||
val imageWidth: Int,
|
||||
val imageHeight: Int,
|
||||
val pageWidth: Float,
|
||||
val pageHeight: Float,
|
||||
)
|
||||
|
||||
// Adapted from https://github.com/tesseract-ocr/tesseract/blob/main/src/api/pdfrenderer.cpp
|
||||
class OcrDocument(
|
||||
private val document: PDDocument,
|
||||
private val assets: AssetManager,
|
||||
) {
|
||||
private val fontBytes: ByteArray by lazy {
|
||||
assets.open("fonts/TesseractGlyphLessFont.ttf").readBytes()
|
||||
}
|
||||
|
||||
private val cidToGidMap: ByteArray by lazy {
|
||||
ByteArray(65536 * 2) { i -> if (i % 2 == 0) 0x00.toByte() else 0x01.toByte() }
|
||||
}
|
||||
|
||||
private var fontDict: COSDictionary? = null
|
||||
|
||||
fun ensureResourcesAreCreated() {
|
||||
if (fontDict != null)
|
||||
return
|
||||
|
||||
// -- Object 1 : embedded TTF --
|
||||
val fontFileStream = PDStream(document)
|
||||
fontFileStream.createOutputStream().use { it.write(fontBytes) }
|
||||
fontFileStream.cosObject.setInt(COSName.LENGTH1, fontBytes.size)
|
||||
|
||||
// -- Object 2 : CIDToGIDMap stream --
|
||||
val cidToGidStream = PDStream(document)
|
||||
cidToGidStream.createOutputStream(COSName.FLATE_DECODE).use {
|
||||
it.write(cidToGidMap)
|
||||
}
|
||||
|
||||
// -- Object 3 : FontDescriptor --
|
||||
val fontDescriptor = PDFontDescriptor(COSDictionary())
|
||||
fontDescriptor.fontName = "GlyphLessFont"
|
||||
fontDescriptor.flags = 5
|
||||
fontDescriptor.fontBoundingBox = PDRectangle(0f, 0f, 500f, 750f)
|
||||
fontDescriptor.italicAngle = 0f
|
||||
fontDescriptor.ascent = 750f
|
||||
fontDescriptor.descent = 0f
|
||||
fontDescriptor.capHeight = 750f
|
||||
fontDescriptor.stemV = 80f
|
||||
fontDescriptor.cosObject.setItem(COSName.FONT_FILE2, fontFileStream)
|
||||
|
||||
// -- Object 4 : CIDFont descendant --
|
||||
val cidFont = COSDictionary()
|
||||
cidFont.setName(COSName.TYPE, "Font")
|
||||
cidFont.setName(COSName.SUBTYPE, "CIDFontType2")
|
||||
cidFont.setName(COSName.BASE_FONT, "GlyphLessFont")
|
||||
val cidSystemInfo = COSDictionary()
|
||||
cidSystemInfo.setString(COSName.getPDFName("Registry"), "Adobe")
|
||||
cidSystemInfo.setString(COSName.getPDFName("Ordering"), "Identity")
|
||||
cidSystemInfo.setInt(COSName.getPDFName("Supplement"), 0)
|
||||
cidFont.setItem(COSName.getPDFName("CIDSystemInfo"), cidSystemInfo)
|
||||
cidFont.setItem(COSName.FONT_DESC, fontDescriptor)
|
||||
cidFont.setInt(COSName.getPDFName("DW"), 500)
|
||||
cidFont.setItem(COSName.getPDFName("CIDToGIDMap"), cidToGidStream)
|
||||
|
||||
// -- Object 5 : ToUnicode CMap --
|
||||
val toUnicode = buildToUnicodeCMap()
|
||||
val toUnicodeStream = PDStream(document)
|
||||
toUnicodeStream.createOutputStream().use {
|
||||
it.write(toUnicode.toByteArray(Charsets.US_ASCII))
|
||||
}
|
||||
|
||||
// -- Object 6 : Font Type0 --
|
||||
val fontDict = COSDictionary()
|
||||
fontDict.setName(COSName.TYPE, "Font")
|
||||
fontDict.setName(COSName.SUBTYPE, "Type0")
|
||||
fontDict.setName(COSName.BASE_FONT, "GlyphLessFont")
|
||||
fontDict.setName(COSName.ENCODING, "Identity-H")
|
||||
val descendants = COSArray()
|
||||
descendants.add(cidFont)
|
||||
fontDict.setItem(COSName.DESCENDANT_FONTS, descendants)
|
||||
fontDict.setItem(COSName.TO_UNICODE, toUnicodeStream)
|
||||
this.fontDict = fontDict
|
||||
}
|
||||
|
||||
private fun buildToUnicodeCMap(): String = buildString {
|
||||
append("/CIDInit /ProcSet findresource begin\n")
|
||||
append("12 dict begin\n")
|
||||
append("begincmap\n")
|
||||
append("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n")
|
||||
append("/CMapName /Adobe-Identify-UCS def\n")
|
||||
append("/CMapType 2 def\n")
|
||||
append("1 begincodespacerange\n")
|
||||
append("<0000> <FFFF>\n")
|
||||
append("endcodespacerange\n")
|
||||
append("1 beginbfrange\n")
|
||||
append("<0000> <FFFF> <0000>\n")
|
||||
append("endbfrange\n")
|
||||
append("endcmap\n")
|
||||
append("CMapName currentdict /CMap defineresource pop\n")
|
||||
append("end\nend\n")
|
||||
}
|
||||
|
||||
fun addPage(
|
||||
page: PDPage,
|
||||
ocrTextBoxes: List<OcrTextBox>,
|
||||
dimensions: PageDimensions,
|
||||
) {
|
||||
if (ocrTextBoxes.isEmpty()) return
|
||||
|
||||
ensureResourcesAreCreated()
|
||||
val resources = page.resources ?: PDResources().also { page.resources = it }
|
||||
val fontResources = resources.cosObject
|
||||
.getDictionaryObject(COSName.FONT) as? COSDictionary
|
||||
?: COSDictionary().also {
|
||||
resources.cosObject.setItem(COSName.FONT, it)
|
||||
}
|
||||
fontResources.setItem(COSName.getPDFName("F1"), fontDict)
|
||||
|
||||
val textStream = buildTextStream(ocrTextBoxes, dimensions)
|
||||
val pdTextStream = PDStream(document)
|
||||
pdTextStream.createOutputStream(COSName.FLATE_DECODE).use {
|
||||
it.write(textStream.toByteArray(Charsets.US_ASCII))
|
||||
}
|
||||
|
||||
val contentsArray = COSArray()
|
||||
val iter = page.contentStreams
|
||||
while (iter.hasNext()) contentsArray.add(iter.next().cosObject)
|
||||
contentsArray.add(pdTextStream.cosObject)
|
||||
page.cosObject.setItem(COSName.CONTENTS, contentsArray)
|
||||
}
|
||||
|
||||
private fun buildTextStream(
|
||||
ocrTextBoxes: List<OcrTextBox>,
|
||||
dimensions: PageDimensions,
|
||||
): String {
|
||||
val scaleX = dimensions.pageWidth / dimensions.imageWidth
|
||||
val scaleY = dimensions.pageHeight / dimensions.imageHeight
|
||||
val sb = StringBuilder()
|
||||
|
||||
for (textBox in ocrTextBoxes) {
|
||||
val x = textBox.box.left * scaleX
|
||||
val wordWidth = textBox.box.width * scaleX
|
||||
val fontSize = textBox.lineHeight * scaleY * 0.8f
|
||||
// nominal width: fontSize / kCharWidth (Tesseract convention)
|
||||
val nominalWidth = textBox.text.length * fontSize / 2f
|
||||
val hScale = if (nominalWidth > 0f) (wordWidth / nominalWidth) * 100f else 100f
|
||||
val baselineY = dimensions.pageHeight - (textBox.lineBottom * scaleY) + fontSize * 0.2f
|
||||
|
||||
val utf16hex = buildString {
|
||||
textBox.text.codePoints().forEach { cp ->
|
||||
append(codepointToUtf16beHex(cp))
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("BT\n")
|
||||
sb.append(String.format(Locale.US, "/F1 %.3f Tf\n", fontSize))
|
||||
sb.append(String.format(Locale.US,"%.3f Tz\n", hScale))
|
||||
sb.append("3 Tr\n")
|
||||
sb.append(String.format(Locale.US,"%.3f %.3f Td\n", x, baselineY))
|
||||
sb.append("<${utf16hex}0020> Tj\n")
|
||||
sb.append("ET\n")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun codepointToUtf16beHex(cp: Int): String {
|
||||
return if (cp < 0x10000) {
|
||||
"%04X".format(cp)
|
||||
} else {
|
||||
val a = cp - 0x10000
|
||||
val high = (a shr 10) + 0xD800
|
||||
val low = (a and 0x3FF) + 0xDC00
|
||||
"%04X%04X".format(high, low)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.platform
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import org.fairscan.app.data.ImageTransformations
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.ui.screens.settings.DefaultColorMode
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Mask
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.autoColorMode
|
||||
import org.fairscan.imageprocessing.createQuad
|
||||
import org.fairscan.imageprocessing.extractDocument
|
||||
import org.fairscan.imageprocessing.resizeForMaxPixels
|
||||
import org.fairscan.imageprocessing.rotate
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import org.opencv.android.Utils
|
||||
import org.opencv.core.CvException
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.core.Size
|
||||
import org.opencv.imgproc.Imgproc
|
||||
import kotlin.math.min
|
||||
|
||||
class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations {
|
||||
|
||||
override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg {
|
||||
return transform(input, ExportQuality.BALANCED.jpegQuality) {
|
||||
rotate(it, rotationDegrees)
|
||||
}
|
||||
}
|
||||
|
||||
override fun resizeToThumbnail(input: Jpeg): Jpeg {
|
||||
val maxSize = thumbnailSizePx.toFloat()
|
||||
return transform(input, 85) { src ->
|
||||
val ratio = min(maxSize / src.width(), maxSize / src.height())
|
||||
val newW = (src.width() * ratio).toDouble()
|
||||
val newH = (src.height() * ratio).toDouble()
|
||||
val scaled = Mat()
|
||||
try {
|
||||
Imgproc.resize(src, scaled, Size(newW, newH))
|
||||
} catch (e: CvException) {
|
||||
val msg = "Resize failed. src=${src.width()}x${src.height()} dst=${newW}x${newH}"
|
||||
throw IllegalStateException(msg, e)
|
||||
}
|
||||
scaled
|
||||
}
|
||||
}
|
||||
|
||||
private fun transform(
|
||||
inJpeg: Jpeg,
|
||||
jpegQuality: Int,
|
||||
transform: (Mat) -> Mat,
|
||||
): Jpeg {
|
||||
val input = inJpeg.toMat()
|
||||
var output: Mat? = null
|
||||
try {
|
||||
output = transform.invoke(input)
|
||||
return Jpeg.fromMat(output, jpegQuality)
|
||||
} finally {
|
||||
input.release()
|
||||
output?.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun process(
|
||||
source: Jpeg,
|
||||
metadata: PageMetadata,
|
||||
colorMode: ColorMode
|
||||
): Jpeg {
|
||||
val baseRotation = metadata.baseRotation
|
||||
return processedImage(source, metadata, baseRotation, colorMode, ExportQuality.BALANCED)
|
||||
}
|
||||
}
|
||||
|
||||
fun processedImage(
|
||||
source: Jpeg,
|
||||
metadata: PageMetadata,
|
||||
rotation: Rotation,
|
||||
colorMode: ColorMode,
|
||||
exportQuality: ExportQuality,
|
||||
): Jpeg {
|
||||
val rotationDegrees = rotation.degrees
|
||||
var sourceMat: Mat? = null
|
||||
var page: Mat? = null
|
||||
try {
|
||||
sourceMat = source.toMat()
|
||||
val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height())
|
||||
page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels,
|
||||
metadata.opticalMeasures)
|
||||
return Jpeg.fromMat(page, exportQuality.jpegQuality)
|
||||
} finally {
|
||||
sourceMat?.release()
|
||||
page?.release()
|
||||
}
|
||||
}
|
||||
|
||||
fun extractDocumentFromBitmap(
|
||||
source: Bitmap,
|
||||
quadInMask: Quad?,
|
||||
rotationDegrees: Int,
|
||||
mask: Mask?,
|
||||
viewModelScope: CoroutineScope,
|
||||
defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO,
|
||||
opticalMeasures: OpticalMeasures?,
|
||||
): CapturedPage {
|
||||
val exportQuality = ExportQuality.BALANCED
|
||||
var colorMode = ColorMode.COLOR
|
||||
var autoColorMode = colorMode
|
||||
var normalizedQuad = createQuad(listOf(
|
||||
Point(0.0, 0.0), Point(0.0, 1.0), Point(1.0, 1.0), Point(1.0, 0.0))
|
||||
)
|
||||
var page: Mat
|
||||
|
||||
val rgba = Mat()
|
||||
Utils.bitmapToMat(source, rgba)
|
||||
val bgr = Mat()
|
||||
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
|
||||
rgba.release()
|
||||
|
||||
if (mask == null || quadInMask == null) {
|
||||
// No document detected
|
||||
val resized = resizeForMaxPixels(bgr, exportQuality.maxPixels.toDouble())
|
||||
page = rotate(resized, rotationDegrees)
|
||||
resized.release()
|
||||
} else {
|
||||
val quad = quadInMask.scaledTo(mask.width, mask.height, source.width, source.height)
|
||||
normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1)
|
||||
autoColorMode = autoColorMode(bgr, mask, quad)
|
||||
colorMode = defaultColorMode.colorMode ?: autoColorMode
|
||||
page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels,
|
||||
opticalMeasures)
|
||||
}
|
||||
|
||||
val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality)
|
||||
bgr.release()
|
||||
page.release()
|
||||
|
||||
val baseRotation = Rotation.fromDegrees(rotationDegrees)
|
||||
val sourceSize = ImageSize(source.width, source.height)
|
||||
val metadata =
|
||||
PageMetadata(normalizedQuad, baseRotation, autoColorMode, sourceSize, opticalMeasures)
|
||||
val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) {
|
||||
compressSource(source)
|
||||
}
|
||||
return CapturedPage(pageJpeg, sourceJpegDeferred, metadata, colorMode)
|
||||
}
|
||||
|
||||
private fun compressSource(source: Bitmap): Jpeg {
|
||||
val rgba = Mat()
|
||||
Utils.bitmapToMat(source, rgba)
|
||||
val bgr = Mat()
|
||||
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
|
||||
rgba.release()
|
||||
return try {
|
||||
Jpeg.fromMat(bgr, 90)
|
||||
} finally {
|
||||
bgr.release()
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025-2026 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.platform
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.core.graphics.scale
|
||||
import org.fairscan.app.data.ImageTransformations
|
||||
import org.opencv.core.MatOfInt
|
||||
import org.opencv.imgcodecs.Imgcodecs
|
||||
import java.io.File
|
||||
import kotlin.math.min
|
||||
|
||||
class OpenCvTransformations : ImageTransformations {
|
||||
override fun rotate(
|
||||
inputFile: File,
|
||||
outputFile: File,
|
||||
rotationDegrees: Int,
|
||||
jpegQuality: Int
|
||||
) {
|
||||
val src = Imgcodecs.imread(inputFile.absolutePath)
|
||||
require(!src.empty()) { "Could not load image from ${inputFile.absolutePath}" }
|
||||
|
||||
val dst = org.fairscan.imageprocessing.rotate(src, rotationDegrees)
|
||||
|
||||
val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, jpegQuality)
|
||||
if (!Imgcodecs.imwrite(outputFile.absolutePath, dst, params)) {
|
||||
throw RuntimeException("Could not write image to ${outputFile.absolutePath}")
|
||||
}
|
||||
|
||||
params.release()
|
||||
src.release()
|
||||
dst.release()
|
||||
}
|
||||
|
||||
override fun resize(inputFile: File, outputFile: File, maxSize: Int) {
|
||||
val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath)
|
||||
val ratio = min(maxSize.toFloat() / bitmap.width, maxSize.toFloat() / bitmap.height)
|
||||
val newW = (bitmap.width * ratio).toInt()
|
||||
val newH = (bitmap.height * ratio).toInt()
|
||||
val scaled = bitmap.scale(newW, newH)
|
||||
outputFile.outputStream().use {
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, 85, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,45 +14,41 @@
|
||||
*/
|
||||
package org.fairscan.app.ui
|
||||
|
||||
import org.fairscan.app.LaunchMode
|
||||
|
||||
sealed class Screen {
|
||||
sealed class Main : Screen() {
|
||||
object Home : Main()
|
||||
object Camera : Main()
|
||||
object EditImage : Main()
|
||||
data class Document(val initialPage: Int = 0) : Main()
|
||||
object Export : Main()
|
||||
object ResumeScan : Main()
|
||||
}
|
||||
sealed class Overlay : Screen() {
|
||||
object About : Overlay()
|
||||
object Libraries : Overlay()
|
||||
object Settings : Overlay()
|
||||
object OcrLanguages : Overlay()
|
||||
}
|
||||
}
|
||||
|
||||
data class Navigation(
|
||||
val toHomeScreen: () -> Unit,
|
||||
val toCameraScreen: () -> Unit,
|
||||
val toEditImageScreen: () -> Unit,
|
||||
val toDocumentScreen: () -> Unit,
|
||||
val toExportScreen: () -> Unit,
|
||||
val toAboutScreen: () -> Unit,
|
||||
val toLibrariesScreen: () -> Unit,
|
||||
val toSettingsScreen: (() -> Unit)?,
|
||||
val toOcrLanguagesScreen: () -> Unit,
|
||||
val back: () -> Unit,
|
||||
val shouldDisplayBackButton: () -> Boolean,
|
||||
)
|
||||
|
||||
fun startScreenFor(mode: LaunchMode): Screen.Main =
|
||||
when (mode) {
|
||||
LaunchMode.NORMAL -> Screen.Main.Home
|
||||
LaunchMode.EXTERNAL_SCAN_TO_PDF -> Screen.Main.Camera
|
||||
}
|
||||
|
||||
@ConsistentCopyVisibility
|
||||
data class NavigationState private constructor(val stack: List<Screen>, val root: Screen.Main) {
|
||||
|
||||
companion object {
|
||||
fun initial(mode: LaunchMode): NavigationState {
|
||||
val root = startScreenFor(mode)
|
||||
fun initial(): NavigationState {
|
||||
val root = Screen.Main.Camera
|
||||
return NavigationState(listOf(root), root)
|
||||
}
|
||||
}
|
||||
@@ -70,10 +66,11 @@ data class NavigationState private constructor(val stack: List<Screen>, val root
|
||||
fun navigateBack(): NavigationState {
|
||||
return when (current) {
|
||||
root -> this // Back handled by system
|
||||
is Screen.Main.Home -> this // Back handled by system
|
||||
is Screen.Main.Camera -> copy(stack = listOf(Screen.Main.Home))
|
||||
is Screen.Main.ResumeScan -> this // Back handled by system
|
||||
is Screen.Main.Camera -> this // Back handled by system
|
||||
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))
|
||||
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Document()))
|
||||
is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document()))
|
||||
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera))
|
||||
is Screen.Overlay -> copy(stack = stack.dropLast(1))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,28 +15,25 @@
|
||||
package org.fairscan.app.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.state.PageThumbnail
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
fun dummyNavigation(): Navigation {
|
||||
return Navigation({}, {}, {}, {}, {}, {}, {}, {})
|
||||
}
|
||||
|
||||
fun fakeDocument(): DocumentUiModel {
|
||||
return DocumentUiModel(persistentListOf(), { _ -> null }, { _ -> null })
|
||||
return Navigation({}, {}, {}, {}, {}, {}, {}, {}, {}, { -> true})
|
||||
}
|
||||
|
||||
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
|
||||
val loader = { key: PageViewKey ->
|
||||
context.assets.open("${key.pageId}.jpg").use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
}
|
||||
val pageKeys = pageIds.map { PageViewKey(it, Rotation.R0) }.toImmutableList()
|
||||
return DocumentUiModel(pageKeys, loader, loader)
|
||||
val pageKeys = pageIds.map {
|
||||
PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR, 0), fakeImage(it, context))
|
||||
}.toImmutableList()
|
||||
return DocumentUiModel(pageKeys)
|
||||
}
|
||||
|
||||
fun fakeImage(id: String, context: Context): Jpeg =
|
||||
Jpeg(context.assets.open("${id}.jpg").readBytes())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -28,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -56,15 +58,18 @@ fun SecondaryActionButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
FilledIconButton (
|
||||
onClick = onClick,
|
||||
colors = IconButtonDefaults.outlinedIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
disabledContainerColor = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
|
||||
),
|
||||
modifier = modifier.size(40.dp)
|
||||
modifier = modifier.size(40.dp),
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,12 +16,14 @@ package org.fairscan.app.ui.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -33,6 +35,8 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -44,8 +48,10 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.fairscan.app.THUMBNAIL_SIZE_DP
|
||||
@@ -60,6 +66,7 @@ data class CommonPageListState(
|
||||
val onPageClick: (Int) -> Unit,
|
||||
val onPageReorder: (String, Int) -> Unit,
|
||||
val listState: LazyListState,
|
||||
val showPageNumbers: Boolean,
|
||||
val currentPageIndex: Int? = null,
|
||||
val onLastItemPosition: ((Offset) -> Unit)? = null,
|
||||
)
|
||||
@@ -72,12 +79,12 @@ fun CommonPageList(
|
||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val reorderableLazyListState = rememberReorderableLazyListState(state.listState) { from, to ->
|
||||
val pageId = state.document.pageKeys[from.index].pageId
|
||||
val pageId = state.document.pages[from.index].key.pageId
|
||||
state.onPageReorder(pageId, to.index)
|
||||
}
|
||||
val content: LazyListScope.() -> Unit = {
|
||||
itemsIndexed(state.document.pageKeys, key = { _, item -> item.saveKey}) { index, item ->
|
||||
ReorderableItem(reorderableLazyListState, key = item.saveKey) { isDragging ->
|
||||
itemsIndexed(state.document.pages.map { it.key }, key = { _, item -> item.pageId}) { index, item ->
|
||||
ReorderableItem(reorderableLazyListState, key = item.pageId) { isDragging ->
|
||||
val borderColor =
|
||||
if (isDragging) MaterialTheme.colorScheme.primary else Color.Transparent
|
||||
val modifier = Modifier
|
||||
@@ -87,7 +94,7 @@ fun CommonPageList(
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
)
|
||||
val image = state.document.loadThumbnail(index)
|
||||
val image = state.document.thumbnail(index)
|
||||
if (image != null) {
|
||||
PageThumbnail(image, index, state, modifier)
|
||||
}
|
||||
@@ -111,10 +118,12 @@ fun CommonPageList(
|
||||
)
|
||||
}
|
||||
if (state.document.isEmpty()) {
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(THUMBNAIL_SIZE_DP.dp)
|
||||
.addPositionCallback(state.onLastItemPosition, LocalDensity.current, 0.5f)
|
||||
.addPositionCallback(
|
||||
state.onLastItemPosition, LocalDensity.current, 0.5f, layoutDirection)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +138,7 @@ private fun PageThumbnail(
|
||||
val bitmap = image.asImageBitmap()
|
||||
val isSelected = index == state.currentPageIndex
|
||||
val borderColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.secondary else Color.Transparent
|
||||
if (isSelected) MaterialTheme.colorScheme.primary else Color.LightGray
|
||||
val maxImageSize = PAGE_LIST_ELEMENT_SIZE_DP.dp
|
||||
var imageModifier =
|
||||
if (bitmap.height > bitmap.width)
|
||||
@@ -138,45 +147,69 @@ private fun PageThumbnail(
|
||||
Modifier.width(maxImageSize)
|
||||
if (index == state.document.lastIndex()) {
|
||||
val density = LocalDensity.current
|
||||
imageModifier = imageModifier.addPositionCallback(state.onLastItemPosition, density, 1.0f)
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
imageModifier = imageModifier.addPositionCallback(
|
||||
state.onLastItemPosition, density, 1.0f, layoutDirection)
|
||||
}
|
||||
Box (modifier = modifier.height(PAGE_LIST_ELEMENT_SIZE_DP.dp)) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = null,
|
||||
modifier = imageModifier
|
||||
.align(Alignment.Center)
|
||||
.padding(4.dp)
|
||||
.border(2.dp, borderColor)
|
||||
.clickable { state.onPageClick(index) }
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(4.dp))
|
||||
.padding(vertical = 0.dp, horizontal = 8.dp)
|
||||
Box(modifier = modifier.height(PAGE_LIST_ELEMENT_SIZE_DP.dp)) {
|
||||
Card(
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
modifier = Modifier.padding(4.dp).align(Alignment.Center)
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = "${index + 1}",
|
||||
modifier = imageModifier.clickable { state.onPageClick(index) }
|
||||
)
|
||||
}
|
||||
if (state.showPageNumbers) {
|
||||
PageNumberBadge(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.addPositionCallback(callback: ((Offset) -> Unit)?, density: Density, xFactor: Float): Modifier {
|
||||
@Composable
|
||||
private fun BoxScope.PageNumberBadge(index: Int) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(4.dp))
|
||||
.padding(vertical = 0.dp, horizontal = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.addPositionCallback(
|
||||
callback: ((Offset) -> Unit)?,
|
||||
density: Density,
|
||||
xFactor: Float,
|
||||
layoutDirection: LayoutDirection
|
||||
): Modifier {
|
||||
if (callback == null) {
|
||||
return this
|
||||
}
|
||||
|
||||
return this.onGloballyPositioned { coordinates ->
|
||||
with(density) {
|
||||
callback(coordinates.localToWindow(
|
||||
Offset(
|
||||
x = PAGE_LIST_ELEMENT_SIZE_DP.dp.toPx() * xFactor,
|
||||
y = PAGE_LIST_ELEMENT_SIZE_DP.dp.toPx() / 2)))
|
||||
val width = PAGE_LIST_ELEMENT_SIZE_DP.dp.toPx()
|
||||
|
||||
val x = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> width * xFactor
|
||||
LayoutDirection.Rtl -> width * (0.5f - xFactor)
|
||||
}
|
||||
|
||||
callback(
|
||||
coordinates.localToWindow(Offset(x = x, y = width / 2))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -54,16 +54,20 @@ import org.fairscan.app.ui.Navigation
|
||||
@Composable
|
||||
fun MyScaffold(
|
||||
navigation: Navigation,
|
||||
pageListState: CommonPageListState,
|
||||
pageListButton: (@Composable () -> Unit)? = null,
|
||||
pageListState: CommonPageListState? = null,
|
||||
bottomBar: @Composable () -> Unit,
|
||||
onBack: (() -> Unit)? = null,
|
||||
content: @Composable (Modifier) -> Unit,
|
||||
) {
|
||||
Box {
|
||||
if (!isLandscape(LocalConfiguration.current)) {
|
||||
Scaffold(
|
||||
bottomBar = { DocumentBar(pageListState, bottomBar, Modifier, pageListButton) }
|
||||
bottomBar = {
|
||||
if (pageListState == null) {
|
||||
bottomBar
|
||||
} else {
|
||||
DocumentBar(pageListState, bottomBar, Modifier)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
content(Modifier.padding(innerPadding).fillMaxSize())
|
||||
}
|
||||
@@ -73,13 +77,17 @@ fun MyScaffold(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
) {
|
||||
content(Modifier.weight(2f))
|
||||
DocumentBar(pageListState, bottomBar, Modifier.weight(1f), pageListButton)
|
||||
if (pageListState == null) {
|
||||
bottomBar
|
||||
} else {
|
||||
DocumentBar(pageListState, bottomBar, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onBack != null) {
|
||||
if (navigation.shouldDisplayBackButton()) {
|
||||
BackButton(
|
||||
onBack,
|
||||
navigation.back,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
@@ -99,7 +107,6 @@ fun DocumentBar(
|
||||
pageListState: CommonPageListState,
|
||||
buttonBar: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
pageListButton: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val isLandscape = isLandscape(LocalConfiguration.current)
|
||||
Column(
|
||||
@@ -115,17 +122,6 @@ fun DocumentBar(
|
||||
Modifier
|
||||
) {
|
||||
CommonPageList(pageListState, modifier = Modifier.fillMaxWidth())
|
||||
|
||||
if (pageListButton != null) {
|
||||
val alignment = if (isLandscape) Alignment.BottomEnd else Alignment.CenterEnd
|
||||
Box(
|
||||
Modifier
|
||||
.align(alignment)
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp)
|
||||
) {
|
||||
pageListButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val color = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -25,9 +25,3 @@ import java.util.Date
|
||||
fun pageCountText(quantity: Int): String {
|
||||
return LocalResources.current.getQuantityString(R.plurals.page_count, quantity, quantity)
|
||||
}
|
||||
|
||||
fun formatDate(timestamp: Long, context: Context): String {
|
||||
val date = Date(timestamp)
|
||||
return DateFormat.getMediumDateFormat(context).format(date) + " " +
|
||||
DateFormat.getTimeFormat(context).format(date)
|
||||
}
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025-2026 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
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.RotateLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.RotateRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableIntState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import net.engawapg.lib.zoomable.ZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.CommonPageListState
|
||||
import org.fairscan.app.ui.components.ConfirmationDialog
|
||||
import org.fairscan.app.ui.components.MainActionButton
|
||||
import org.fairscan.app.ui.components.MyScaffold
|
||||
import org.fairscan.app.ui.components.SecondaryActionButton
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentScreen(
|
||||
document: DocumentUiModel,
|
||||
initialPage: Int,
|
||||
navigation: Navigation,
|
||||
onExportClick: () -> Unit,
|
||||
onDeleteImage: (String) -> Unit,
|
||||
onRotateImage: (String, Boolean) -> Unit,
|
||||
onPageReorder: (String, Int) -> Unit,
|
||||
) {
|
||||
// TODO Check how often images are loaded
|
||||
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
|
||||
val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) }
|
||||
if (currentPageIndex.intValue >= document.pageCount()) {
|
||||
currentPageIndex.intValue = document.pageCount() - 1
|
||||
}
|
||||
if (currentPageIndex.intValue < 0) {
|
||||
navigation.toCameraScreen()
|
||||
return
|
||||
}
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(initialPage) {
|
||||
listState.scrollToItem(initialPage)
|
||||
}
|
||||
|
||||
MyScaffold(
|
||||
navigation = navigation,
|
||||
pageListState = CommonPageListState(
|
||||
document,
|
||||
onPageClick = { index -> currentPageIndex.intValue = index },
|
||||
onPageReorder = onPageReorder,
|
||||
currentPageIndex = currentPageIndex.intValue,
|
||||
listState = listState,
|
||||
),
|
||||
onBack = navigation.back,
|
||||
bottomBar = {
|
||||
BottomBar(onExportClick)
|
||||
},
|
||||
pageListButton = {
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.Add,
|
||||
onClick = navigation.toCameraScreen,
|
||||
contentDescription = stringResource(R.string.add_page),
|
||||
)
|
||||
},
|
||||
) { modifier ->
|
||||
DocumentPreview(
|
||||
document,
|
||||
currentPageIndex,
|
||||
{ showDeletePageDialog.value = true },
|
||||
onRotateImage,
|
||||
modifier
|
||||
)
|
||||
if (showDeletePageDialog.value) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.delete_page),
|
||||
message = stringResource(R.string.delete_page_warning),
|
||||
showDialog = showDeletePageDialog
|
||||
) { onDeleteImage(document.pageId(currentPageIndex.intValue)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocumentPreview(
|
||||
document: DocumentUiModel,
|
||||
currentPageIndex: MutableIntState,
|
||||
onDeleteImage: (String) -> Unit,
|
||||
onRotateImage: (String, Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val imageId = document.pageId(currentPageIndex.intValue)
|
||||
Column (
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val bitmap = document.load(currentPageIndex.intValue)
|
||||
if (bitmap != null) {
|
||||
val imageBitmap = bitmap.asImageBitmap()
|
||||
val zoomState = remember(imageId) {
|
||||
ZoomState(
|
||||
contentSize = Size(bitmap.width.toFloat(), bitmap.height.toFloat())
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize(0.92f)
|
||||
.align(Alignment.Center)) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.align(Alignment.Center)
|
||||
.zoomable(zoomState)
|
||||
)
|
||||
}
|
||||
}
|
||||
RotationButtons(imageId, onRotateImage, Modifier.align(Alignment.BottomCenter))
|
||||
SecondaryActionButton(
|
||||
Icons.Outlined.Delete,
|
||||
contentDescription = stringResource(R.string.delete_page),
|
||||
onClick = { onDeleteImage(imageId) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
Text("${currentPageIndex.intValue + 1} / ${document.pageCount()}",
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(all = 16.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RotationButtons(
|
||||
imageId: String,
|
||||
onRotateImage: (String, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(modifier = modifier.padding(4.dp)) {
|
||||
SecondaryActionButton(
|
||||
icon = Icons.AutoMirrored.Default.RotateLeft,
|
||||
contentDescription = "Rotate left",
|
||||
onClick = { onRotateImage(imageId, false) }
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
SecondaryActionButton(
|
||||
icon = Icons.AutoMirrored.Default.RotateRight,
|
||||
contentDescription = "Rotate right",
|
||||
onClick = { onRotateImage(imageId, true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomBar(
|
||||
onExportClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
MainActionButton(
|
||||
onClick = onExportClick,
|
||||
icon = Icons.Default.Description,
|
||||
text = stringResource(R.string.export),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun DocumentScreenPreview() {
|
||||
FairScanTheme {
|
||||
DocumentScreen(
|
||||
fakeDocument(
|
||||
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }.toImmutableList(),
|
||||
LocalContext.current
|
||||
),
|
||||
initialPage = 1,
|
||||
navigation = dummyNavigation(),
|
||||
onExportClick = {},
|
||||
onDeleteImage = { _ -> },
|
||||
onRotateImage = { _,_ -> },
|
||||
onPageReorder = { _,_ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
|
||||
@Composable
|
||||
fun DocumentScreenPreviewInLandscapeMode() {
|
||||
DocumentScreenPreview()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.ui.components.isLandscape
|
||||
import org.fairscan.app.ui.components.pageCountText
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.fakeImage
|
||||
import org.fairscan.app.ui.screens.document.CurrentPageUiState
|
||||
import org.fairscan.app.ui.screens.document.DocumentUiState
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
|
||||
@Composable
|
||||
fun ResumeScanScreen(
|
||||
currentDocument: DocumentUiState,
|
||||
onResumeScan: () -> Unit,
|
||||
onStartNewScan: () -> Unit,
|
||||
) {
|
||||
val pageCount = currentDocument.document.pageCount()
|
||||
val firstPageThumbnail = currentDocument.currentPage?.bitmap
|
||||
|
||||
val resumeScanModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onResumeScan)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp)
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
if (!isLandscape(LocalConfiguration.current)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
Column(
|
||||
modifier = resumeScanModifier.weight(2f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
FirstPageThumbnail(firstPageThumbnail, Modifier.weight(1f))
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
ResumeScanActions(pageCount, onResumeScan)
|
||||
}
|
||||
HorizontalDivider()
|
||||
NewScanArea(onStartNewScan, Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
Row(
|
||||
modifier = resumeScanModifier.weight(1.8f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
FirstPageThumbnail(firstPageThumbnail, Modifier.weight(1f))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
ResumeScanActions(pageCount, onResumeScan)
|
||||
}
|
||||
}
|
||||
VerticalDivider()
|
||||
NewScanArea(onStartNewScan, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun FirstPageThumbnail(firstPageThumbnail: Bitmap?, modifier: Modifier) {
|
||||
firstPageThumbnail?.let { bmp ->
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, Color.Gray, RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResumeScanActions(pageCount: Int, onResumeScan: () -> Unit) {
|
||||
|
||||
FlowRow {
|
||||
val style = MaterialTheme.typography.bodyMedium
|
||||
Text(stringResource(R.string.scan_current), style = style)
|
||||
Text(" • ", style = style)
|
||||
Text(pageCountText(pageCount), style = style)
|
||||
}
|
||||
BigButton(onClick = onResumeScan, text = stringResource(R.string.scan_resume))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewScanArea(onStartNewScan: () -> Unit, modifier: Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.clickable(onClick = onStartNewScan)
|
||||
.padding(horizontal = 16.dp, vertical = 32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_discard_current),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
BigButton(onClick = onStartNewScan, text = stringResource(R.string.scan_new))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BigButton(onClick: () -> Unit, text: String) {
|
||||
Button(onClick = onClick, modifier = Modifier.padding(vertical = 16.dp)) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
@Preview(showBackground = true, widthDp = 320, heightDp = 640)
|
||||
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
|
||||
@Composable
|
||||
fun ResumeScanScreenPreview() {
|
||||
val image = fakeImage("gallica.bnf.fr-bpt6k5530456s-1", LocalContext.current).toBitmap()
|
||||
val document = fakeDocument(
|
||||
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
|
||||
LocalContext.current
|
||||
)
|
||||
val key = PageViewKey("123", Rotation.R0, null, 0)
|
||||
FairScanTheme {
|
||||
ResumeScanScreen(
|
||||
DocumentUiState(1, CurrentPageUiState(key,image, ColorMode.COLOR, true), document),
|
||||
{}, {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -81,6 +81,7 @@ fun AboutScreen(
|
||||
onBack: () -> Unit,
|
||||
onCopyLogs: () -> Unit,
|
||||
onContactWithLastImageClicked: () -> Unit,
|
||||
onStartActivity: (Intent) -> Unit,
|
||||
onViewLibraries: () -> Unit,
|
||||
) {
|
||||
val showLicenseDialog = rememberSaveable { mutableStateOf(false) }
|
||||
@@ -99,6 +100,7 @@ fun AboutScreen(
|
||||
aboutUiState,
|
||||
onCopyLogs,
|
||||
onContactWithLastImageClicked,
|
||||
onStartActivity,
|
||||
showLicenseDialog,
|
||||
onViewLibraries)
|
||||
}
|
||||
@@ -113,6 +115,7 @@ fun AboutContent(
|
||||
aboutUiState: AboutUiState,
|
||||
onCopyLogs: () -> Unit,
|
||||
onContactWithLastImageClicked: () -> Unit,
|
||||
onStartActivity: (Intent) -> Unit,
|
||||
showLicenseDialog: MutableState<Boolean>,
|
||||
onViewLibraries: () -> Unit,
|
||||
) {
|
||||
@@ -155,7 +158,7 @@ fun AboutContent(
|
||||
ContactLink(
|
||||
icon = Icons.Default.Email,
|
||||
text = EMAIL_ADDRESS,
|
||||
onClick = { context.startActivity(createContactEmailIntent()) }
|
||||
onClick = { onStartActivity(createContactEmailIntent()) }
|
||||
)
|
||||
val websiteUrl = "https://fairscan.org"
|
||||
ContactLink(
|
||||
@@ -163,7 +166,7 @@ fun AboutContent(
|
||||
text = websiteUrl,
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, websiteUrl.toUri())
|
||||
context.startActivity(intent)
|
||||
onStartActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -327,6 +330,6 @@ fun EmailImageButton(
|
||||
fun AboutScreenPreview() {
|
||||
FairScanTheme {
|
||||
val state = AboutUiState(true)
|
||||
AboutScreen(state, {}, {}, {}, {})
|
||||
AboutScreen(state, {}, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,10 +15,19 @@
|
||||
package org.fairscan.app.ui.screens.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.hardware.camera2.CameraCaptureSession
|
||||
import android.hardware.camera2.CameraMetadata
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.hardware.camera2.CaptureResult
|
||||
import android.hardware.camera2.TotalCaptureResult
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.camera2.interop.Camera2Interop
|
||||
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
|
||||
import androidx.camera.core.CameraControl
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.FocusMeteringAction
|
||||
@@ -65,12 +74,16 @@ import androidx.core.graphics.scale
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import org.fairscan.app.ui.components.CameraPermissionState
|
||||
import org.fairscan.imageprocessing.CameraIntrinsics
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.cameraIntrinsics
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun CameraPreview(
|
||||
@@ -162,6 +175,7 @@ fun CameraPreview(
|
||||
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCamera2Interop::class)
|
||||
fun bindCameraUseCases(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
cameraProvider: ProcessCameraProvider,
|
||||
@@ -186,7 +200,7 @@ fun bindCameraUseCases(
|
||||
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888).build()
|
||||
imageAnalysis.setAnalyzer(executor, onImageAnalyzed)
|
||||
|
||||
val imageCapture = ImageCapture.Builder()
|
||||
val imageCaptureBuilder = ImageCapture.Builder()
|
||||
.setResolutionSelector(
|
||||
ResolutionSelector.Builder()
|
||||
.setResolutionStrategy(
|
||||
@@ -201,17 +215,32 @@ fun bindCameraUseCases(
|
||||
.build()
|
||||
)
|
||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||
.build()
|
||||
|
||||
Camera2Interop.Extender(imageCaptureBuilder)
|
||||
.setSessionCaptureCallback(object : CameraCaptureSession.CaptureCallback() {
|
||||
override fun onCaptureCompleted(
|
||||
session: CameraCaptureSession,
|
||||
request: CaptureRequest,
|
||||
result: TotalCaptureResult
|
||||
) {
|
||||
result.get(CaptureResult.LENS_FOCUS_DISTANCE)?.let {
|
||||
captureController.lastFocusDistanceDiopters = it
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
val imageCapture = imageCaptureBuilder.build()
|
||||
captureController.imageCapture = imageCapture
|
||||
|
||||
val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector,
|
||||
imageAnalysis, preview, imageCapture)
|
||||
captureController.cameraControl = camera.cameraControl
|
||||
captureController.setCameraCharacteristics(Camera2CameraInfo.from(camera.cameraInfo))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnalysisOverlay(liveAnalysisState: LiveAnalysisState, debugMode: Boolean) {
|
||||
val binaryMask = liveAnalysisState.binaryMask ?: return
|
||||
val maskSize = liveAnalysisState.maskSize ?: return
|
||||
val targetQuad = liveAnalysisState.stableQuad
|
||||
var displayedQuad by remember { mutableStateOf<Quad?>(null) }
|
||||
val quadColor = MaterialTheme.colorScheme.primary
|
||||
@@ -233,14 +262,15 @@ fun AnalysisOverlay(liveAnalysisState: LiveAnalysisState, debugMode: Boolean) {
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
if (debugMode) {
|
||||
drawMask(this, binaryMask)
|
||||
val binaryMask = liveAnalysisState.binaryMaskProvider.invoke()
|
||||
binaryMask?.let { drawMask(this, it) }
|
||||
}
|
||||
displayedQuad?.let { quad ->
|
||||
val scaledQuad = quad.scaledTo(
|
||||
fromWidth = binaryMask.width,
|
||||
fromHeight = binaryMask.height,
|
||||
toWidth = size.width.toInt(),
|
||||
toHeight = size.height.toInt()
|
||||
fromWidth = maskSize.width,
|
||||
fromHeight = maskSize.height,
|
||||
toWidth = size.width.toDouble(),
|
||||
toHeight = size.height.toDouble()
|
||||
)
|
||||
scaledQuad.edges().forEach {
|
||||
drawLine(quadColor, it.from.toOffset(), it.to.toOffset(), 10.0f)
|
||||
@@ -286,21 +316,35 @@ class CameraCaptureController {
|
||||
var imageCapture: ImageCapture? = null
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
var previewView: PreviewView? = null
|
||||
var cameraIntrinsics: CameraIntrinsics? = null
|
||||
var canUseFocusDistance = false
|
||||
|
||||
@Volatile
|
||||
var lastFocusDistanceDiopters: Float? = null
|
||||
|
||||
fun shutdown() {
|
||||
executor.shutdown()
|
||||
}
|
||||
|
||||
fun takePicture(onImageCaptured: (ImageProxy?) -> Unit) {
|
||||
fun takePicture(onImageCaptured: (ImageProxy?, OpticalMeasures?) -> Unit) {
|
||||
imageCapture?.takePicture(
|
||||
executor,
|
||||
object : ImageCapture.OnImageCapturedCallback() {
|
||||
override fun onCaptureSuccess(imageProxy: ImageProxy) {
|
||||
onImageCaptured(imageProxy)
|
||||
val diopters = lastFocusDistanceDiopters
|
||||
val subjectDistanceInMm =
|
||||
if (canUseFocusDistance && diopters != null && diopters != 0.0f) {
|
||||
1000 / diopters
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onImageCaptured(
|
||||
imageProxy,
|
||||
cameraIntrinsics?.let { OpticalMeasures(it, subjectDistanceInMm) })
|
||||
}
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
Log.e("CameraCapture", "Image capture failed: ${exception.message}", exception)
|
||||
onImageCaptured(null)
|
||||
onImageCaptured(null, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -319,6 +363,28 @@ class CameraCaptureController {
|
||||
|
||||
control.startFocusAndMetering(action)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCamera2Interop::class)
|
||||
fun setCameraCharacteristics(cameraInfo: Camera2CameraInfo) {
|
||||
val focalLengths = cameraInfo.getCameraCharacteristic(
|
||||
android.hardware.camera2.CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS
|
||||
)
|
||||
val sensorSize = cameraInfo.getCameraCharacteristic(
|
||||
android.hardware.camera2.CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE
|
||||
)
|
||||
cameraIntrinsics =
|
||||
if (focalLengths == null || focalLengths.size != 1 || sensorSize == null) {
|
||||
null
|
||||
} else {
|
||||
cameraIntrinsics(focalLengths[0], max(sensorSize.width, sensorSize.height))
|
||||
}
|
||||
val calibration = cameraInfo.getCameraCharacteristic(
|
||||
android.hardware.camera2.CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION
|
||||
)
|
||||
canUseFocusDistance =
|
||||
calibration == CameraMetadata.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_CALIBRATED
|
||||
|| calibration == CameraMetadata.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_APPROXIMATE
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface CameraBindState {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,15 +15,13 @@
|
||||
package org.fairscan.app.ui.screens.camera
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
@@ -34,23 +32,33 @@ import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Highlight
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -79,16 +87,21 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.delay
|
||||
import org.fairscan.app.MainViewModel
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.app.domain.Rotation.R0
|
||||
import org.fairscan.app.ui.Navigation
|
||||
@@ -101,6 +114,7 @@ import org.fairscan.app.ui.components.pageCountText
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
@@ -113,21 +127,25 @@ fun CameraScreen(
|
||||
cameraViewModel: CameraViewModel,
|
||||
navigation: Navigation,
|
||||
liveAnalysisState: LiveAnalysisState,
|
||||
importState: ImportState,
|
||||
onImageAnalyzed: (ImageProxy) -> Unit,
|
||||
onFinalizePressed: () -> Unit,
|
||||
cameraPermission: CameraPermissionState,
|
||||
onImportClicked: () -> Unit,
|
||||
) {
|
||||
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
|
||||
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
|
||||
var isDebugMode by remember { mutableStateOf(false) }
|
||||
val isTorchEnabled by cameraViewModel.isTorchEnabled.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler { navigation.back() }
|
||||
var torchReapplied by remember { mutableStateOf(false) }
|
||||
|
||||
val captureController = remember { CameraCaptureController() }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { captureController.shutdown() }
|
||||
onDispose {
|
||||
captureController.shutdown()
|
||||
torchReapplied = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(captureController.cameraControl, isTorchEnabled) {
|
||||
captureController.cameraControl?.enableTorch(isTorchEnabled)
|
||||
@@ -151,17 +169,52 @@ fun CameraScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
cameraViewModel.resetLiveAnalysis()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
cameraViewModel.cancelImport()
|
||||
}
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(document.pageCount()) {
|
||||
if (!document.isEmpty()) {
|
||||
listState.animateScrollToItem(document.lastIndex())
|
||||
}
|
||||
}
|
||||
|
||||
val onCapture = {
|
||||
previewView?.bitmap?.let {
|
||||
Log.i("FairScan", "Pressed <Capture>")
|
||||
cameraViewModel.onCapturePressed(it)
|
||||
captureController.takePicture(
|
||||
onImageCaptured = { imageProxy, opticalMeasures ->
|
||||
cameraViewModel.onImageCaptured(imageProxy, opticalMeasures)
|
||||
}
|
||||
)
|
||||
}?: Unit
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
cameraViewModel.volumeKeyEvent.collect {
|
||||
onCapture()
|
||||
}
|
||||
}
|
||||
|
||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
CameraScreenScaffold(
|
||||
cameraPreview = {
|
||||
CameraPreview(
|
||||
onImageAnalyzed = onImageAnalyzed,
|
||||
onImageAnalyzed = {
|
||||
onImageAnalyzed(it)
|
||||
// Re-apply torch once the camera session is actually active (first analysis)
|
||||
if (!torchReapplied) {
|
||||
captureController.cameraControl?.enableTorch(isTorchEnabled)
|
||||
torchReapplied = true
|
||||
}
|
||||
},
|
||||
captureController = captureController,
|
||||
onPreviewViewReady = { view ->
|
||||
previewView = view
|
||||
@@ -177,25 +230,19 @@ fun CameraScreen(
|
||||
onPageClick = { index -> viewModel.navigateTo(Screen.Main.Document(index)) },
|
||||
onPageReorder = { id, index -> viewModel.movePage(id, index) },
|
||||
listState = listState,
|
||||
showPageNumbers = false,
|
||||
onLastItemPosition = { offset -> thumbnailCoords.value = offset },
|
||||
),
|
||||
cameraUiState = CameraUiState(
|
||||
document.pageCount(),
|
||||
liveAnalysisState,
|
||||
captureState,
|
||||
importState,
|
||||
showDetectionError,
|
||||
isLandscape = isLandscape,
|
||||
isDebugMode,
|
||||
isTorchEnabled),
|
||||
onCapture = {
|
||||
previewView?.bitmap?.let {
|
||||
Log.i("FairScan", "Pressed <Capture>")
|
||||
cameraViewModel.onCapturePressed(it)
|
||||
captureController.takePicture(
|
||||
onImageCaptured = { imageProxy -> cameraViewModel.onImageCaptured(imageProxy) }
|
||||
)
|
||||
}
|
||||
},
|
||||
onCapture = onCapture,
|
||||
onFinalizePressed = onFinalizePressed,
|
||||
onDebugModeSwitched = { isDebugMode = !isDebugMode },
|
||||
onTorchSwitched = {
|
||||
@@ -203,7 +250,10 @@ fun CameraScreen(
|
||||
},
|
||||
thumbnailCoords = thumbnailCoords,
|
||||
navigation = navigation,
|
||||
captureController
|
||||
captureController,
|
||||
isCameraPermissionGranted = cameraPermission.isGranted,
|
||||
onRequestCameraPermission = { cameraPermission.request() },
|
||||
onImportClicked = onImportClicked,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,6 +269,9 @@ private fun CameraScreenScaffold(
|
||||
thumbnailCoords: MutableState<Offset>,
|
||||
navigation: Navigation,
|
||||
captureController: CameraCaptureController,
|
||||
isCameraPermissionGranted: Boolean,
|
||||
onRequestCameraPermission: () -> Unit,
|
||||
onImportClicked: () -> Unit,
|
||||
) {
|
||||
var focusPoint by remember { mutableStateOf<Offset?>(null) }
|
||||
LaunchedEffect(focusPoint) {
|
||||
@@ -249,31 +302,75 @@ private fun CameraScreenScaffold(
|
||||
MyScaffold(
|
||||
navigation = navigation,
|
||||
pageListState = pageListState,
|
||||
onBack = navigation.back,
|
||||
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed) }
|
||||
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed, onImportClicked) }
|
||||
) { modifier ->
|
||||
CameraPreviewBox(
|
||||
cameraPreview,
|
||||
cameraUiState,
|
||||
focusPoint,
|
||||
onCapture,
|
||||
onTorchSwitched,
|
||||
modifier.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
focusPoint = offset
|
||||
captureController.tapToFocus(offset)
|
||||
onPageCountClick()
|
||||
if (cameraUiState.importState is ImportState.Selecting) {
|
||||
// display nothing: photo picker is active
|
||||
} else if (cameraUiState.importState is ImportState.Importing) {
|
||||
ImportInProgress(cameraUiState.importState, modifier)
|
||||
} else if (!isCameraPermissionGranted) {
|
||||
CameraPermissionRationale(onRequestCameraPermission, modifier)
|
||||
} else {
|
||||
CameraPreviewBox(
|
||||
cameraPreview,
|
||||
cameraUiState,
|
||||
focusPoint,
|
||||
onCapture,
|
||||
onTorchSwitched,
|
||||
modifier.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
focusPoint = offset
|
||||
captureController.tapToFocus(offset)
|
||||
onPageCountClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (cameraUiState.captureState is CaptureState.CapturePreview) {
|
||||
val page = cameraUiState.captureState.capturedPage.page
|
||||
val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap()
|
||||
CapturedImage(page.asImageBitmap(), thumbnailCoords)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImportInProgress(state: ImportState.Importing, modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = LocalResources.current.getQuantityString(
|
||||
R.plurals.importing_photos,
|
||||
state.total,
|
||||
state.total
|
||||
),
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (state.total > 1) {
|
||||
LinearProgressIndicator(
|
||||
progress = { state.processed.toFloat() / state.total },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraPreviewBox(
|
||||
cameraPreview: @Composable (() -> Unit),
|
||||
@@ -438,7 +535,7 @@ private fun CameraPreviewWithOverlay(
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
)
|
||||
}
|
||||
if (cameraUiState.showDetectionError) {
|
||||
if (cameraUiState.showCaptureError) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
@@ -446,7 +543,7 @@ private fun CameraPreviewWithOverlay(
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.error_no_document),
|
||||
text = stringResource(R.string.error_occurred),
|
||||
color = Color.White,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
@@ -487,12 +584,27 @@ fun MessageBox(inferenceTime: Long) {
|
||||
private fun Bar(
|
||||
pageCount: Int,
|
||||
onFinalizePressed: () -> Unit,
|
||||
onImportClicked: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onImportClicked,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Icon(Icons.Default.AddPhotoAlternate, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.import_photos),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
MainActionButton(
|
||||
onClick = onFinalizePressed,
|
||||
enabled = pageCount > 0,
|
||||
@@ -502,7 +614,34 @@ private fun Bar(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun CameraPermissionRationale(onRequestCameraPermission: () -> Unit, modifier:Modifier) {
|
||||
Box (modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.camera_permission_rationale),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onRequestCameraPermission,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text(stringResource(R.string.grant_permission))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name="Pixel 1", showSystemUi = true, device = Devices.PIXEL)
|
||||
@Preview(name="Pixel 4", showSystemUi = true, device = Devices.PIXEL_4)
|
||||
@Preview(name="Pixel 9 pro XL", showSystemUi = true, device = Devices.PIXEL_9_PRO_XL)
|
||||
@Composable
|
||||
fun CameraScreenPreview() {
|
||||
ScreenPreview(CaptureState.Idle)
|
||||
@@ -514,11 +653,12 @@ fun CameraScreenPreviewWithProcessedImage() {
|
||||
val p = Point(0 , 0)
|
||||
val quad = Quad(p, p, p, p)
|
||||
ScreenPreview(CaptureState.CapturePreview(
|
||||
debugImage("uncropped/img01.jpg"),
|
||||
debugImage("uncropped/img01.jpg").toBitmap(),
|
||||
CapturedPage(
|
||||
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
||||
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
||||
PageMetadata(quad, R0, false))))
|
||||
CompletableDeferred(Jpeg(ByteArray(0))),
|
||||
PageMetadata(quad, R0, ColorMode.COLOR, null, null),
|
||||
ColorMode.COLOR)))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
|
||||
@@ -527,8 +667,25 @@ fun CameraScreenPreviewInLandscapeMode() {
|
||||
ScreenPreview(CaptureState.Idle, rotationDegrees = 90f)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0f) {
|
||||
fun CameraScreenPreviewWithNoPermission() {
|
||||
ScreenPreview(CaptureState.Idle, isCameraPermissionGranted = false)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun CameraScreenPreviewImporting() {
|
||||
ScreenPreview(CaptureState.Idle, ImportState.Importing(2, 3))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenPreview(
|
||||
captureState: CaptureState,
|
||||
importState: ImportState = ImportState.Idle,
|
||||
rotationDegrees: Float = 0f,
|
||||
isCameraPermissionGranted: Boolean = true,
|
||||
) {
|
||||
FairScanTheme {
|
||||
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
|
||||
CameraScreenScaffold(
|
||||
@@ -540,7 +697,7 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Image(
|
||||
debugImage("uncropped/img01.jpg").asImageBitmap(),
|
||||
debugImage("uncropped/img01.jpg").toBitmap().asImageBitmap(),
|
||||
modifier=Modifier.rotate(rotationDegrees),
|
||||
contentDescription = null
|
||||
)
|
||||
@@ -550,30 +707,32 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
|
||||
CommonPageListState(
|
||||
document = fakeDocument(
|
||||
listOf(1, 2)
|
||||
.map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }
|
||||
.map { "gallica.bnf.fr-bpt6k5530456s-$it" }
|
||||
.toImmutableList(),
|
||||
LocalContext.current),
|
||||
onPageClick = {},
|
||||
onPageReorder = { _, _ -> },
|
||||
listState = LazyListState(),
|
||||
showPageNumbers = false,
|
||||
),
|
||||
cameraUiState = CameraUiState(pageCount = 4, LiveAnalysisState(), captureState,
|
||||
false, rotationDegrees > 0, false, false),
|
||||
importState, false, rotationDegrees > 0, false, false),
|
||||
onCapture = {},
|
||||
onFinalizePressed = {},
|
||||
onDebugModeSwitched = {},
|
||||
onTorchSwitched = {},
|
||||
thumbnailCoords = thumbnailCoords,
|
||||
navigation = dummyNavigation(),
|
||||
captureController = CameraCaptureController()
|
||||
captureController = CameraCaptureController(),
|
||||
isCameraPermissionGranted = isCameraPermissionGranted,
|
||||
onRequestCameraPermission = {},
|
||||
onImportClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun debugImage(imgName: String): Bitmap {
|
||||
private fun debugImage(imgName: String): Jpeg {
|
||||
val context = LocalContext.current
|
||||
return context.assets.open(imgName).use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
return Jpeg(context.assets.open(imgName).readBytes())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,21 +16,29 @@ package org.fairscan.app.ui.screens.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
@Immutable
|
||||
data class LiveAnalysisState(
|
||||
val inferenceTime: Long = 0L,
|
||||
val binaryMask: Bitmap? = null,
|
||||
val documentQuad: Quad? = null,
|
||||
val maskSize: ImageSize? = null,
|
||||
val binaryMaskProvider: () -> Bitmap? = { -> null },
|
||||
val stableQuad: Quad? = null,
|
||||
)
|
||||
|
||||
sealed class ImportState {
|
||||
object Idle : ImportState()
|
||||
object Selecting : ImportState()
|
||||
data class Importing(val processed: Int, val total: Int) : ImportState()
|
||||
}
|
||||
|
||||
data class CameraUiState(
|
||||
val pageCount: Int,
|
||||
val liveAnalysisState: LiveAnalysisState,
|
||||
val captureState: CaptureState,
|
||||
val showDetectionError: Boolean,
|
||||
val importState: ImportState,
|
||||
val showCaptureError: Boolean,
|
||||
val isLandscape: Boolean,
|
||||
val isDebugMode: Boolean,
|
||||
val isTorchEnabled: Boolean,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,34 +15,30 @@
|
||||
package org.fairscan.app.ui.screens.camera
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import android.net.Uri
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.imageprocessing.Mask
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.app.platform.extractDocumentFromBitmap
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Mode
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
import org.fairscan.imageprocessing.detectDocumentQuad
|
||||
import org.fairscan.imageprocessing.extractDocument
|
||||
import org.fairscan.imageprocessing.isColoredDocument
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import org.opencv.android.Utils
|
||||
import org.opencv.core.CvType
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.imgproc.Imgproc
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
sealed interface CameraEvent {
|
||||
data class ImageCaptured(val page: CapturedPage) : CameraEvent
|
||||
@@ -51,6 +47,8 @@ sealed interface CameraEvent {
|
||||
class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
|
||||
private val imageSegmentationService = appContainer.imageSegmentationService
|
||||
private val settingsRepository = appContainer.settingsRepository
|
||||
private val imageLoader = appContainer.imageLoader
|
||||
private val logger = appContainer.logger
|
||||
|
||||
private val _events = MutableSharedFlow<CameraEvent>()
|
||||
@@ -63,34 +61,28 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
|
||||
val captureState: StateFlow<CaptureState> = _captureState
|
||||
|
||||
private val _importState = MutableStateFlow<ImportState>(ImportState.Idle)
|
||||
val importState: StateFlow<ImportState> = _importState
|
||||
private var importJob: Job? = null
|
||||
|
||||
private val _isTorchEnabled = MutableStateFlow(false)
|
||||
val isTorchEnabled: StateFlow<Boolean> = _isTorchEnabled
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
imageSegmentationService.initialize()
|
||||
imageSegmentationService.segmentation
|
||||
.filterNotNull()
|
||||
.collect { result ->
|
||||
// TODO Should we really call toBinaryMask if it's used only in debug mode?
|
||||
val binaryMask = result.segmentation.toBinaryMask()
|
||||
val rawQuad = detectDocumentQuad(
|
||||
result.segmentation,
|
||||
isLiveAnalysis = true
|
||||
)
|
||||
val stableQuad = quadStabilizer.update(rawQuad)
|
||||
_liveAnalysisState.value = LiveAnalysisState(
|
||||
inferenceTime = result.inferenceTime,
|
||||
binaryMask = binaryMask,
|
||||
documentQuad = rawQuad,
|
||||
stableQuad = stableQuad,
|
||||
)
|
||||
}
|
||||
}
|
||||
private val _volumeKeyEvent = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val volumeKeyEvent = _volumeKeyEvent.asSharedFlow()
|
||||
|
||||
fun onVolumeKeyPressed() {
|
||||
_volumeKeyEvent.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun resetLiveAnalysis() {
|
||||
quadStabilizer = QuadStabilizer()
|
||||
_liveAnalysisState.value = LiveAnalysisState()
|
||||
}
|
||||
|
||||
fun onCapturePressed(frozenImage: Bitmap) {
|
||||
_captureState.value = CaptureState.Capturing(frozenImage)
|
||||
resetLiveAnalysis()
|
||||
}
|
||||
|
||||
private fun onCaptureProcessed(captured: CapturedPage?) {
|
||||
@@ -105,26 +97,53 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
}
|
||||
|
||||
fun liveAnalysis(imageProxy: ImageProxy) {
|
||||
if (_captureState.value !is CaptureState.Idle) {
|
||||
if (_captureState.value !is CaptureState.Idle || _importState.value !is ImportState.Idle) {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
imageSegmentationService.runSegmentationAndEmit(
|
||||
imageProxy.toBitmap(),
|
||||
imageProxy.imageInfo.rotationDegrees,
|
||||
)
|
||||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
imageSegmentationService.runSegmentationAndReturn(imageProxy.toBitmap())
|
||||
}
|
||||
|
||||
result?.let {
|
||||
val segmentation = result.segmentation
|
||||
val maskSize = segmentation.maskSize()
|
||||
val originalSize = ImageSize(imageProxy.width, imageProxy.height)
|
||||
val rawQuad = withContext(Dispatchers.Default) {
|
||||
detectDocumentQuad(segmentation, originalSize, Mode.LIVE_ANALYSIS)
|
||||
?.rotate90(rotationDegrees / 90, maskSize)
|
||||
}
|
||||
val binaryMaskProvider = { ->
|
||||
var binaryMask: Bitmap = segmentation.toBinaryMask()
|
||||
if (rotationDegrees != 0) {
|
||||
binaryMask = rotateBitmap(binaryMask, rotationDegrees.toFloat())
|
||||
}
|
||||
binaryMask
|
||||
}
|
||||
val stableQuad = quadStabilizer.update(rawQuad)
|
||||
_liveAnalysisState.value = LiveAnalysisState(
|
||||
inferenceTime = result.inferenceTime,
|
||||
binaryMaskProvider = binaryMaskProvider,
|
||||
maskSize = maskSize,
|
||||
stableQuad = stableQuad,
|
||||
)
|
||||
}
|
||||
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun onImageCaptured(imageProxy: ImageProxy?) {
|
||||
fun onImageCaptured(imageProxy: ImageProxy?, opticalMeasures: OpticalMeasures?) {
|
||||
if (imageProxy != null) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val source = imageProxy.toBitmap()
|
||||
val page = processCapturedImage(source, imageProxy.imageInfo.rotationDegrees)
|
||||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||||
val page =
|
||||
processCapturedImage(source, rotationDegrees, opticalMeasures, Mode.CAPTURE)
|
||||
imageProxy.close()
|
||||
onCaptureProcessed(page)
|
||||
} catch (e: RuntimeException) {
|
||||
@@ -139,18 +158,17 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
|
||||
private suspend fun processCapturedImage(
|
||||
source: Bitmap,
|
||||
rotationDegrees: Int
|
||||
): CapturedPage? = withContext(Dispatchers.IO) {
|
||||
var result: CapturedPage? = null
|
||||
val segmentation = imageSegmentationService.runSegmentationAndReturn(source, 0)
|
||||
if (segmentation != null) {
|
||||
val mask = segmentation.segmentation
|
||||
val quad = detectDocumentQuad(mask, isLiveAnalysis = false)
|
||||
if (quad != null) {
|
||||
val resizedQuad = quad.scaledTo(mask.width, mask.height, source.width, source.height)
|
||||
result = extractDocumentFromBitmap(source, resizedQuad, rotationDegrees, mask)
|
||||
}
|
||||
}
|
||||
rotationDegrees: Int,
|
||||
opticalMeasures: OpticalMeasures?,
|
||||
mode: Mode,
|
||||
): CapturedPage = withContext(Dispatchers.IO) {
|
||||
val segmentation = imageSegmentationService.runSegmentationAndReturn(source)
|
||||
val mask = segmentation?.segmentation
|
||||
val originalSize = ImageSize(source.width, source.height)
|
||||
val quad = mask?.let { detectDocumentQuad(mask, originalSize, mode) }
|
||||
val defaultColorMode = settingsRepository.defaultColorMode.first()
|
||||
val result = extractDocumentFromBitmap(
|
||||
source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode, opticalMeasures)
|
||||
return@withContext result
|
||||
}
|
||||
|
||||
@@ -177,6 +195,44 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
fun setTorchEnabled(enabled: Boolean) {
|
||||
_isTorchEnabled.value = enabled
|
||||
}
|
||||
|
||||
fun importPhotos(uris: List<Uri>) {
|
||||
importJob?.cancel()
|
||||
if (uris.isEmpty()) {
|
||||
_importState.value = ImportState.Idle
|
||||
return
|
||||
}
|
||||
importJob = viewModelScope.launch {
|
||||
_importState.value = ImportState.Importing(0, uris.size)
|
||||
uris.forEachIndexed { index, uri ->
|
||||
ensureActive()
|
||||
try {
|
||||
val photoToImport = imageLoader.load(uri)
|
||||
ensureActive()
|
||||
val page = processCapturedImage(photoToImport, 0, null, Mode.IMPORT)
|
||||
ensureActive()
|
||||
_events.emit(CameraEvent.ImageCaptured(page))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("Import", "Failed to import image: $uri", e)
|
||||
}
|
||||
_importState.value = ImportState.Importing(index + 1, uris.size)
|
||||
}
|
||||
_importState.value = ImportState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
fun onImportClicked() {
|
||||
_importState.value = ImportState.Selecting
|
||||
resetLiveAnalysis()
|
||||
}
|
||||
|
||||
fun cancelImport() {
|
||||
importJob?.cancel()
|
||||
importJob = null
|
||||
_importState.value = ImportState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CaptureState {
|
||||
@@ -191,36 +247,8 @@ sealed class CaptureState {
|
||||
) : CaptureState()
|
||||
}
|
||||
|
||||
fun extractDocumentFromBitmap(
|
||||
source: Bitmap, quad: Quad, rotationDegrees: Int, mask: Mask
|
||||
): CapturedPage {
|
||||
val rgba = Mat()
|
||||
Utils.bitmapToMat(source, rgba)
|
||||
val bgr = Mat()
|
||||
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) // CV_8UC4 → CV_8UC3
|
||||
rgba.release()
|
||||
val isColored = isColoredDocument(bgr, mask, quad)
|
||||
val maxPixels = ExportQuality.BALANCED.maxPixels
|
||||
val page = extractDocument(bgr, quad, rotationDegrees, isColored, maxPixels)
|
||||
val outBgr = page
|
||||
bgr.release()
|
||||
val outBitmap = toBitmap(outBgr)
|
||||
outBgr.release()
|
||||
val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1)
|
||||
val baseRotation = Rotation.fromDegrees(rotationDegrees)
|
||||
val metadata = PageMetadata(normalizedQuad, baseRotation, isColored)
|
||||
return CapturedPage(outBitmap, source, metadata)
|
||||
}
|
||||
|
||||
fun toBitmap(bgr: Mat): Bitmap {
|
||||
require(bgr.type() == CvType.CV_8UC3)
|
||||
|
||||
val rgba = Mat()
|
||||
Imgproc.cvtColor(bgr, rgba, Imgproc.COLOR_BGR2RGBA)
|
||||
|
||||
val bmp = createBitmap(bgr.cols(), bgr.rows(), Bitmap.Config.ARGB_8888)
|
||||
Utils.matToBitmap(rgba, bmp)
|
||||
|
||||
rgba.release()
|
||||
return bmp
|
||||
fun rotateBitmap(source: Bitmap, angle: Float): Bitmap {
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(angle)
|
||||
return Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(), matrix, true)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -24,6 +24,7 @@ class QuadStabilizer {
|
||||
private var lastRawQuad: Quad? = null
|
||||
|
||||
fun update(rawQuad: Quad?): Quad? {
|
||||
val previousQuad = lastRawQuad
|
||||
lastRawQuad = rawQuad
|
||||
|
||||
if (rawQuad == null) {
|
||||
@@ -31,13 +32,12 @@ class QuadStabilizer {
|
||||
return null
|
||||
}
|
||||
|
||||
val lastRaw = lastRawQuad
|
||||
if (lastRaw == null) {
|
||||
if (previousQuad == null) {
|
||||
stableCount = 1
|
||||
return null
|
||||
}
|
||||
|
||||
val dist = lastRaw.maxCornerDistanceTo(rawQuad)
|
||||
val dist = previousQuad.maxCornerDistanceTo(rawQuad)
|
||||
// 20f is based on the assumption that the preview has a size of 640×480
|
||||
if (dist < 20f) {
|
||||
stableCount++
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.crop
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.mandatorySystemGestures
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.MainActionButton
|
||||
import org.fairscan.app.ui.components.MyScaffold
|
||||
import org.fairscan.app.ui.components.isLandscape
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CropScreen(
|
||||
pageId: String,
|
||||
initState: CropInitState,
|
||||
navigation: Navigation,
|
||||
onUpdatePageQuad: (Quad) -> Unit,
|
||||
) {
|
||||
val quadHandler = remember { QuadEditingHandler() }
|
||||
|
||||
val (bitmap, initialQuad) = if (initState is CropInitState.Ready && initState.pageId == pageId) {
|
||||
Pair(initState.bitmap, initState.quad)
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
val editableQuad = rememberSaveable(pageId, saver = QuadSaver) {
|
||||
mutableStateOf(initialQuad)
|
||||
}
|
||||
val state = remember(pageId) {
|
||||
CropScreenState(editableQuad)
|
||||
}
|
||||
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
val isLandscape = isLandscape(LocalConfiguration.current)
|
||||
val density = LocalDensity.current
|
||||
val bottomInsetForSystemGestures = with(density) {
|
||||
WindowInsets.mandatorySystemGestures.getBottom(density).toDp()
|
||||
}
|
||||
|
||||
MyScaffold(
|
||||
navigation = navigation,
|
||||
bottomBar = {},
|
||||
) { modifier ->
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
bitmap?.let { bmp ->
|
||||
val imageBitmap = remember(bmp) { bmp.asImageBitmap() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = "Image to edit",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = bottomInsetForSystemGestures)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
state.containerSize = coordinates.size
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
|
||||
DragQuadOverlay(state, quadHandler, bmp)
|
||||
}
|
||||
}
|
||||
|
||||
DragMagnifyingGlass(state, bitmap)
|
||||
|
||||
ActionButtons(
|
||||
modifier = Modifier
|
||||
.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
onConfirm = {
|
||||
state.editableQuad?.let { onUpdatePageQuad(it) }
|
||||
navigation.back()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButtons(
|
||||
modifier: Modifier,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
MainActionButton(
|
||||
onClick = onConfirm,
|
||||
text = stringResource(R.string.apply),
|
||||
icon = Icons.Filled.Check,
|
||||
iconDescription = stringResource(R.string.apply),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun DragQuadOverlay(
|
||||
state: CropScreenState,
|
||||
quadHandler: QuadEditingHandler,
|
||||
bmp: Bitmap
|
||||
) {
|
||||
if (state.editableQuad == null || state.containerSize == null) return
|
||||
|
||||
val containerSize = state.containerSize!!
|
||||
val displaySize = QuadCoordinateUtils.calculateDisplaySize(bmp.width, bmp.height, containerSize)
|
||||
val liftWiggleThresholdPx = with(LocalDensity.current) {
|
||||
CropScreenState.LIFT_WIGGLE_MAX_DISTANCE.toPx()
|
||||
}
|
||||
|
||||
QuadOverlay(
|
||||
quad = state.editableQuad!!,
|
||||
containerSize = containerSize,
|
||||
displaySize = displaySize,
|
||||
modifier = Modifier.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragStart = { startPos ->
|
||||
val quad = state.editableQuad ?: return@detectDragGestures
|
||||
state.dragPosition = startPos
|
||||
|
||||
// Prefer the index stored at raw touch-down (exact touch position,
|
||||
// before slop). Fall back to re-detecting at the slop position only
|
||||
// when the raw-touch handler missed the down event.
|
||||
val cornerIndex = if (state.touchDownCornerIndex >= 0) {
|
||||
state.touchDownCornerIndex
|
||||
} else {
|
||||
quadHandler.findTouchedCorner(startPos, quad, containerSize, displaySize)
|
||||
}
|
||||
|
||||
if (cornerIndex >= 0) {
|
||||
state.startCornerDrag(cornerIndex)
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
|
||||
state.endDrag()
|
||||
state.onTouchUp()
|
||||
},
|
||||
onDragCancel = {
|
||||
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
|
||||
state.endDrag()
|
||||
state.onTouchUp()
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
// change.consume() is intentionally omitted: detectDragGestures
|
||||
// already calls it.consume() internally after this callback returns.
|
||||
state.dragPosition = change.position
|
||||
val quad = state.editableQuad ?: return@detectDragGestures
|
||||
state.recordDragStep(quad, dragAmount)
|
||||
val normalizedDelta = QuadCoordinateUtils.screenDeltaToNormalized(
|
||||
dragAmount, displaySize
|
||||
)
|
||||
|
||||
when {
|
||||
state.draggedCornerIndex >= 0 -> {
|
||||
state.updateQuad(
|
||||
quadHandler.updateQuadCorner(
|
||||
quad, state.draggedCornerIndex, normalizedDelta
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Second pointer-input: fires immediately on press (before touch slop)
|
||||
// so the loupe appears as soon as the finger touches a handle.
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val quad = state.editableQuad
|
||||
if (quad != null) {
|
||||
val cIdx = quadHandler.findTouchedCorner(down.position, quad, containerSize, displaySize)
|
||||
if (cIdx >= 0) {
|
||||
state.onTouchDown(down.position, cIdx)
|
||||
}
|
||||
}
|
||||
// For a tap (no drag): waitForUpOrCancellation() sees the UP event and
|
||||
// returns it, so we call onTouchUp() here.
|
||||
// For a drag: detectDragGestures consumes move events, causing
|
||||
// waitForUpOrCancellation() to return null. We do NOT call onTouchUp()
|
||||
// here; onDragEnd / onDragCancel above handle that instead.
|
||||
if (waitForUpOrCancellation() != null) {
|
||||
state.onTouchUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DragMagnifyingGlass(state: CropScreenState, bitmap: Bitmap?) {
|
||||
// showLoupe becomes true immediately on touch-down and stays true for
|
||||
// one additional second after the finger is lifted.
|
||||
val showLoupe = remember { mutableStateOf(false) }
|
||||
// Remember the last valid focus position so the loupe keeps rendering
|
||||
// correctly during the 1-second fade-out (when dragged indices are reset).
|
||||
val lastKnownFocusPosition = remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
LaunchedEffect(state.isTouching) {
|
||||
if (state.isTouching) {
|
||||
showLoupe.value = true
|
||||
} else {
|
||||
delay(1_000)
|
||||
showLoupe.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!showLoupe.value || state.dragPosition == null || state.containerSize == null) return
|
||||
|
||||
val bmp = bitmap ?: return
|
||||
val containerSize = state.containerSize!!
|
||||
val displaySize = QuadCoordinateUtils.calculateDisplaySize(
|
||||
bmp.width, bmp.height, containerSize
|
||||
)
|
||||
val quad = state.editableQuad
|
||||
|
||||
// Resolve which corner index to focus on.
|
||||
// Priority: active drag > pre-drag touch-down > nothing (fade-out phase).
|
||||
val activeCornerIndex = state.draggedCornerIndex.takeIf { it >= 0 }
|
||||
?: state.touchDownCornerIndex.takeIf { it >= 0 }
|
||||
|
||||
val focusPosition = if (quad != null) {
|
||||
when {
|
||||
activeCornerIndex != null -> {
|
||||
val corner = when (activeCornerIndex) {
|
||||
0 -> quad.topLeft
|
||||
1 -> quad.topRight
|
||||
2 -> quad.bottomRight
|
||||
3 -> quad.bottomLeft
|
||||
else -> null
|
||||
}
|
||||
corner?.let {
|
||||
QuadCoordinateUtils.normalizedToScreen(it, containerSize, displaySize)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} else null
|
||||
|
||||
// Keep the last known focus position so it's still valid after endDrag() resets the indices.
|
||||
if (focusPosition != null) lastKnownFocusPosition.value = focusPosition
|
||||
// On the very first touch the drag indices are not set yet and lastKnownFocusPosition
|
||||
// has never been populated, so fall back to dragPosition (the finger is on the handle).
|
||||
val effectiveFocusPosition = focusPosition ?: lastKnownFocusPosition.value ?: state.dragPosition ?: return
|
||||
|
||||
MagnifyingGlass(
|
||||
bitmap = bmp,
|
||||
fingerPosition = state.dragPosition!!,
|
||||
focusPosition = effectiveFocusPosition,
|
||||
containerSize = containerSize,
|
||||
displaySize = displaySize,
|
||||
quad = state.editableQuad,
|
||||
)
|
||||
}
|
||||
|
||||
val QuadSaver: Saver<MutableState<Quad?>, *> = listSaver(
|
||||
save = { state ->
|
||||
state.value?.let {
|
||||
listOf(
|
||||
it.topLeft.x, it.topLeft.y,
|
||||
it.topRight.x, it.topRight.y,
|
||||
it.bottomRight.x, it.bottomRight.y,
|
||||
it.bottomLeft.x, it.bottomLeft.y,
|
||||
)
|
||||
} ?: listOf()
|
||||
},
|
||||
restore = { list ->
|
||||
val quad = if (list.size == 8) {
|
||||
Quad(
|
||||
topLeft = Point(list[0], list[1]),
|
||||
topRight = Point(list[2], list[3]),
|
||||
bottomRight = Point(list[4], list[5]),
|
||||
bottomLeft = Point(list[6], list[7]),
|
||||
)
|
||||
} else null
|
||||
mutableStateOf(quad)
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
@Preview(showSystemUi = true)
|
||||
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, showSystemUi = true)
|
||||
@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320)
|
||||
@Preview(name = "RTL", locale = "ar", showSystemUi = true)
|
||||
fun EditPageScreenPreview() {
|
||||
FairScanTheme {
|
||||
val dummyImage = LocalContext.current.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg").use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
val quad = Quad(Point(.1, .1), Point(.9, .1), Point(.9, .9), Point(.1, .9))
|
||||
CropScreen(
|
||||
pageId = "123",
|
||||
initState = CropInitState.Ready("123",dummyImage, quad),
|
||||
navigation = dummyNavigation(),
|
||||
onUpdatePageQuad = { _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.crop
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
sealed interface CropInitState {
|
||||
object Loading : CropInitState
|
||||
object Error : CropInitState
|
||||
data class Ready(
|
||||
val pageId: String,
|
||||
val bitmap: Bitmap,
|
||||
val quad: Quad
|
||||
) : CropInitState
|
||||
}
|
||||
|
||||
class CropScreenState(editableQuad: MutableState<Quad?> = mutableStateOf(null)) {
|
||||
companion object {
|
||||
val LIFT_WIGGLE_MAX_DISTANCE = 8.dp
|
||||
const val LIFT_WIGGLE_WINDOW_MS = 70L
|
||||
}
|
||||
|
||||
var containerSize by mutableStateOf<IntSize?>(null)
|
||||
var editableQuad: Quad? by editableQuad
|
||||
private set
|
||||
var draggedCornerIndex by mutableIntStateOf(-1)
|
||||
var dragPosition by mutableStateOf<Offset?>(null)
|
||||
/** True from the moment the finger touches a drag handle until it is lifted. */
|
||||
var isTouching by mutableStateOf(false)
|
||||
/**
|
||||
* Corner / edge index detected at the raw touch-down (before touch-slop).
|
||||
* Carried into [startCornerDrag] so that the slop-adjusted
|
||||
* position in onDragStart cannot miss the handle.
|
||||
*/
|
||||
var touchDownCornerIndex by mutableIntStateOf(-1)
|
||||
|
||||
private var quadBeforeDrag: Quad? = null
|
||||
private var quadBeforeLastDragStep: Quad? = null
|
||||
private var lastDragStepDistancePx: Float = Float.MAX_VALUE
|
||||
private var lastDragStepAtMs: Long = 0L
|
||||
|
||||
fun updateQuad(newQuad: Quad) {
|
||||
editableQuad = newQuad
|
||||
}
|
||||
|
||||
fun startCornerDrag(cornerIndex: Int) {
|
||||
quadBeforeDrag = editableQuad
|
||||
draggedCornerIndex = cornerIndex
|
||||
clearLastDragStep()
|
||||
}
|
||||
|
||||
fun recordDragStep(previousQuad: Quad, dragAmount: Offset, eventTimeMs: Long = System.currentTimeMillis()) {
|
||||
quadBeforeLastDragStep = previousQuad
|
||||
lastDragStepDistancePx = dragAmount.getDistance()
|
||||
lastDragStepAtMs = eventTimeMs
|
||||
}
|
||||
|
||||
fun rollbackLastDragStepIfLikelyLiftWiggle(
|
||||
maxDistancePx: Float,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
) {
|
||||
if (quadBeforeLastDragStep == null) return
|
||||
val isRecent = nowMs - lastDragStepAtMs <= LIFT_WIGGLE_WINDOW_MS
|
||||
val isSmall = lastDragStepDistancePx <= maxDistancePx
|
||||
if (isRecent && isSmall) {
|
||||
editableQuad = quadBeforeLastDragStep
|
||||
}
|
||||
}
|
||||
|
||||
fun endDrag() {
|
||||
quadBeforeDrag = null
|
||||
clearLastDragStep()
|
||||
draggedCornerIndex = -1
|
||||
// dragPosition is intentionally kept so the loupe can still render
|
||||
// during its 1-second fade-out after the finger is lifted.
|
||||
}
|
||||
|
||||
private fun clearLastDragStep() {
|
||||
quadBeforeLastDragStep = null
|
||||
lastDragStepDistancePx = Float.MAX_VALUE
|
||||
lastDragStepAtMs = 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Called as soon as the finger touches a drag handle (before touch-slop),
|
||||
* so the loupe is shown immediately.
|
||||
* [cornerIndex] is the handle index found at the exact
|
||||
* touch position; it is stored so that drag start handling can use it even
|
||||
* if the slop-adjusted position drifts outside the hit-test radius.
|
||||
*/
|
||||
fun onTouchDown(position: Offset, cornerIndex: Int = -1) {
|
||||
isTouching = true
|
||||
dragPosition = position
|
||||
touchDownCornerIndex = cornerIndex
|
||||
}
|
||||
|
||||
/** Called when the finger is lifted; triggers the loupe fade-out delay. */
|
||||
fun onTouchUp() {
|
||||
isTouching = false
|
||||
touchDownCornerIndex = -1
|
||||
}
|
||||
|
||||
fun isDragging(): Boolean = draggedCornerIndex >= 0
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.crop
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.ClipOp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val LOUPE_BORDER_WIDTH = 3.dp
|
||||
private const val ZOOM_FACTOR = 3f
|
||||
|
||||
/**
|
||||
* Layout parameters for the magnifying-glass loupe.
|
||||
*
|
||||
* Use `LoupeLayoutConfig<Dp>` in Compose UI code and [toPx] to convert to
|
||||
* `LoupeLayoutConfig<Float>` for pixel-level calculations.
|
||||
*/
|
||||
data class LoupeLayoutConfig<T>(
|
||||
/** Radius of the loupe circle */
|
||||
val loupeRadius: T,
|
||||
/** Gap between the finger and the nearest edge of the loupe */
|
||||
val verticalOffset: T,
|
||||
/** Minimum space between the loupe and the screen edge */
|
||||
val screenMargin: T,
|
||||
) {
|
||||
companion object {
|
||||
val Default = LoupeLayoutConfig(
|
||||
loupeRadius = 60.dp,
|
||||
verticalOffset = 40.dp,
|
||||
screenMargin = 8.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a dp-valued config to pixels using the current [LocalDensity]. */
|
||||
@Composable
|
||||
internal fun LoupeLayoutConfig<Dp>.toPx(): LoupeLayoutConfig<Float> {
|
||||
val density = LocalDensity.current
|
||||
return with(density) {
|
||||
LoupeLayoutConfig(
|
||||
loupeRadius = loupeRadius.toPx(),
|
||||
verticalOffset = verticalOffset.toPx(),
|
||||
screenMargin = screenMargin.toPx(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a magnifying glass / loupe showing a zoomed-in patch of [bitmap]
|
||||
* centred around [focusPosition].
|
||||
*
|
||||
* Positioning rules:
|
||||
* 1. By default, the loupe is placed **above** the finger.
|
||||
* 2. If there is not enough room above, it moves to the **left** of the finger.
|
||||
* 3. If there is not enough room on the left either, it moves to the **right**.
|
||||
*
|
||||
* @param bitmap The full source bitmap (original image).
|
||||
* @param fingerPosition Current finger position in screen (container) coordinates, used for loupe placement.
|
||||
* @param focusPosition The exact point to zoom into (e.g. corner or edge midpoint) in screen coordinates.
|
||||
* @param containerSize Size of the full-screen container.
|
||||
* @param displaySize Size of the image as rendered (letterboxed inside the container).
|
||||
*/
|
||||
@Composable
|
||||
fun MagnifyingGlass(
|
||||
bitmap: Bitmap,
|
||||
fingerPosition: Offset,
|
||||
focusPosition: Offset,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize,
|
||||
quad: Quad? = null,
|
||||
configDp: LoupeLayoutConfig<Dp> = LoupeLayoutConfig.Default,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val configPx = configDp.toPx()
|
||||
val borderWidth = with(density) { LOUPE_BORDER_WIDTH.toPx() }
|
||||
|
||||
// compute loupe centre position
|
||||
val loupeCenter = computeLoupeCenter(
|
||||
dragPosition = fingerPosition,
|
||||
configPx = configPx,
|
||||
containerWidth = containerSize.width.toFloat(),
|
||||
)
|
||||
|
||||
// compute the bitmap region to sample
|
||||
val imageOffset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
|
||||
|
||||
// Focus position mapped to bitmap pixel coordinates
|
||||
val bitmapX = ((focusPosition.x - imageOffset.width) / displaySize.width * bitmap.width)
|
||||
.coerceIn(0f, (bitmap.width - 1).toFloat())
|
||||
val bitmapY = ((focusPosition.y - imageOffset.height) / displaySize.height * bitmap.height)
|
||||
.coerceIn(0f, (bitmap.height - 1).toFloat())
|
||||
|
||||
// How many bitmap pixels the loupe shows in each direction
|
||||
val bitmapRegionHalf = (bitmap.width / displaySize.width.toFloat()) * configPx.loupeRadius / ZOOM_FACTOR
|
||||
|
||||
val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() }
|
||||
|
||||
val borderColor = MaterialTheme.colorScheme.primary
|
||||
val quadLineColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(configDp.loupeRadius * 2)
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height) {
|
||||
placeable.placeRelative(
|
||||
IntOffset(
|
||||
(loupeCenter.x - configPx.loupeRadius).roundToInt(),
|
||||
(loupeCenter.y - configPx.loupeRadius).roundToInt()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
val loupeDiameter = configPx.loupeRadius * 2
|
||||
val circlePath = Path().apply {
|
||||
addOval(
|
||||
androidx.compose.ui.geometry.Rect(0f, 0f, loupeDiameter, loupeDiameter)
|
||||
)
|
||||
}
|
||||
|
||||
clipPath(circlePath) {
|
||||
// Fill background so areas outside the image are opaque
|
||||
drawRect(color = backgroundColor)
|
||||
|
||||
// Source rect in bitmap coordinates
|
||||
val srcLeft = (bitmapX - bitmapRegionHalf).toInt().coerceAtLeast(0)
|
||||
val srcTop = (bitmapY - bitmapRegionHalf).toInt().coerceAtLeast(0)
|
||||
val srcRight = (bitmapX + bitmapRegionHalf).toInt().coerceAtMost(bitmap.width)
|
||||
val srcBottom = (bitmapY + bitmapRegionHalf).toInt().coerceAtMost(bitmap.height)
|
||||
|
||||
if (srcRight > srcLeft && srcBottom > srcTop) {
|
||||
|
||||
// Destination offset – compensate when the source rect was clamped
|
||||
val dstOffsetX = ((srcLeft - (bitmapX - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
|
||||
val dstOffsetY = ((srcTop - (bitmapY - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
|
||||
|
||||
val dstWidth = ((srcRight - srcLeft) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
|
||||
val dstHeight = ((srcBottom - srcTop) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
|
||||
|
||||
drawImage(
|
||||
image = imageBitmap,
|
||||
srcOffset = IntOffset(srcLeft, srcTop),
|
||||
srcSize = IntSize(srcRight - srcLeft, srcBottom - srcTop),
|
||||
dstOffset = IntOffset(dstOffsetX.toInt(), dstOffsetY.toInt()),
|
||||
dstSize = IntSize(dstWidth, dstHeight),
|
||||
)
|
||||
}
|
||||
|
||||
// Draw quad overlay and edges inside the loupe
|
||||
// Convert each normalized quad corner to bitmap-pixel coords,
|
||||
// then to loupe-local coords using the same mapping as the bitmap sampling.
|
||||
if (quad != null) {
|
||||
val bitmapOriginX = bitmapX - bitmapRegionHalf
|
||||
val bitmapOriginY = bitmapY - bitmapRegionHalf
|
||||
val scale = loupeDiameter / (2 * bitmapRegionHalf)
|
||||
|
||||
fun normalizedToLoupe(nx: Double, ny: Double): Offset {
|
||||
val bx = (nx * bitmap.width).toFloat()
|
||||
val by = (ny * bitmap.height).toFloat()
|
||||
return Offset(
|
||||
(bx - bitmapOriginX) * scale,
|
||||
(by - bitmapOriginY) * scale
|
||||
)
|
||||
}
|
||||
|
||||
val loupeCorners = listOf(
|
||||
normalizedToLoupe(quad.topLeft.x, quad.topLeft.y),
|
||||
normalizedToLoupe(quad.topRight.x, quad.topRight.y),
|
||||
normalizedToLoupe(quad.bottomRight.x, quad.bottomRight.y),
|
||||
normalizedToLoupe(quad.bottomLeft.x, quad.bottomLeft.y),
|
||||
)
|
||||
|
||||
// Draw quad edge lines on top
|
||||
for (i in 0 until 4) {
|
||||
drawLine(
|
||||
color = quadLineColor,
|
||||
start = loupeCorners[i],
|
||||
end = loupeCorners[(i + 1) % 4],
|
||||
strokeWidth = 3f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Border
|
||||
drawCircle(
|
||||
color = borderColor,
|
||||
radius = configPx.loupeRadius - borderWidth / 2,
|
||||
center = Offset(configPx.loupeRadius, configPx.loupeRadius),
|
||||
style = Stroke(width = borderWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides where the loupe centre should be.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Above the finger (centred horizontally, clamped to screen edges).
|
||||
* 2. If no vertical room -> to the left.
|
||||
* 3. If no room on the left -> to the right.
|
||||
*/
|
||||
internal fun computeLoupeCenter(
|
||||
dragPosition: Offset,
|
||||
configPx: LoupeLayoutConfig<Float>,
|
||||
containerWidth: Float,
|
||||
): Offset {
|
||||
val loupeRadius = configPx.loupeRadius
|
||||
val verticalOffset = configPx.verticalOffset
|
||||
val screenMargin = configPx.screenMargin
|
||||
|
||||
// Try above
|
||||
val aboveCenterY = dragPosition.y - verticalOffset - loupeRadius
|
||||
if (aboveCenterY - loupeRadius >= screenMargin) {
|
||||
// Enough room above -> place centred horizontally on the finger, clamped to screen edges
|
||||
val cx = dragPosition.x.coerceIn(screenMargin + loupeRadius, containerWidth - screenMargin - loupeRadius)
|
||||
return Offset(cx, aboveCenterY)
|
||||
}
|
||||
|
||||
// Not enough room above -> try left
|
||||
val leftCenterX = dragPosition.x - verticalOffset - loupeRadius
|
||||
if (leftCenterX - loupeRadius >= screenMargin) {
|
||||
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
|
||||
return Offset(leftCenterX, cy)
|
||||
}
|
||||
|
||||
// Not enough room on the left -> place right
|
||||
val rightCenterX = dragPosition.x + verticalOffset + loupeRadius
|
||||
val cx = rightCenterX.coerceAtMost(containerWidth - screenMargin - loupeRadius)
|
||||
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
|
||||
return Offset(cx, cy)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.crop
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Point
|
||||
|
||||
object QuadCoordinateUtils {
|
||||
|
||||
fun calculateDisplaySize(
|
||||
bitmapWidth: Int,
|
||||
bitmapHeight: Int,
|
||||
containerSize: IntSize
|
||||
): IntSize {
|
||||
val imageAspectRatio = bitmapWidth.toFloat() / bitmapHeight.toFloat()
|
||||
val containerAspectRatio = containerSize.width / containerSize.height.toFloat()
|
||||
|
||||
return if (imageAspectRatio > containerAspectRatio) {
|
||||
IntSize(containerSize.width, (containerSize.width / imageAspectRatio).toInt())
|
||||
} else {
|
||||
IntSize((containerSize.height * imageAspectRatio).toInt(), containerSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
fun normalizedToScreen(point: Point, containerSize: IntSize, displaySize: IntSize): Offset {
|
||||
val offsetX = (containerSize.width - displaySize.width) / 2
|
||||
val offsetY = (containerSize.height - displaySize.height) / 2
|
||||
return Offset(
|
||||
x = (point.x * displaySize.width).toFloat() + offsetX,
|
||||
y = (point.y * displaySize.height).toFloat() + offsetY
|
||||
)
|
||||
}
|
||||
|
||||
fun screenDeltaToNormalized(delta: Offset, displaySize: IntSize): Offset {
|
||||
return Offset(
|
||||
x = delta.x / displaySize.width,
|
||||
y = delta.y / displaySize.height
|
||||
)
|
||||
}
|
||||
|
||||
fun getImageOffset(containerSize: IntSize, displaySize: IntSize): IntSize {
|
||||
return IntSize(
|
||||
(containerSize.width - displaySize.width) / 2,
|
||||
(containerSize.height - displaySize.height) / 2
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.crop
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
class QuadEditingHandler {
|
||||
|
||||
companion object {
|
||||
const val CORNER_RADIUS = 40f
|
||||
const val CORNER_TOUCH_RADIUS = 90f
|
||||
}
|
||||
|
||||
fun findTouchedCorner(
|
||||
touchPos: Offset,
|
||||
quad: Quad,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize
|
||||
): Int {
|
||||
return findTouchedCornerCandidates(touchPos, quad, containerSize, displaySize)
|
||||
.firstOrNull() ?: -1
|
||||
}
|
||||
|
||||
fun findTouchedCornerCandidates(
|
||||
touchPos: Offset,
|
||||
quad: Quad,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize
|
||||
): List<Int> {
|
||||
val corners = getCornerPositions(quad, containerSize, displaySize)
|
||||
return corners
|
||||
.mapIndexed { index, corner -> index to (touchPos - corner).getDistance() }
|
||||
.filter { (_, distance) -> distance < CORNER_TOUCH_RADIUS }
|
||||
.sortedBy { (_, distance) -> distance }
|
||||
.map { (index, _) -> index }
|
||||
}
|
||||
|
||||
fun updateQuadCorner(quad: Quad, cornerIndex: Int, delta: Offset): Quad {
|
||||
val normalizedDelta = Point(delta.x.toDouble(), delta.y.toDouble())
|
||||
val candidate = when (cornerIndex) {
|
||||
0 -> quad.copy(topLeft = clampPoint(quad.topLeft + normalizedDelta))
|
||||
1 -> quad.copy(topRight = clampPoint(quad.topRight + normalizedDelta))
|
||||
2 -> quad.copy(bottomRight = clampPoint(quad.bottomRight + normalizedDelta))
|
||||
3 -> quad.copy(bottomLeft = clampPoint(quad.bottomLeft + normalizedDelta))
|
||||
else -> quad
|
||||
}
|
||||
return if (candidate.isConvex()) candidate else quad
|
||||
}
|
||||
|
||||
private fun getCornerPositions(quad: Quad, containerSize: IntSize, displaySize: IntSize): List<Offset> {
|
||||
return listOf(
|
||||
QuadCoordinateUtils.normalizedToScreen(quad.topLeft, containerSize, displaySize),
|
||||
QuadCoordinateUtils.normalizedToScreen(quad.topRight, containerSize, displaySize),
|
||||
QuadCoordinateUtils.normalizedToScreen(quad.bottomRight, containerSize, displaySize),
|
||||
QuadCoordinateUtils.normalizedToScreen(quad.bottomLeft, containerSize, displaySize)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun clampPoint(point: Point): Point {
|
||||
return Point(
|
||||
point.x.coerceIn(0.0, 1.0),
|
||||
point.y.coerceIn(0.0, 1.0)
|
||||
)
|
||||
}
|
||||
|
||||
private operator fun Point.plus(other: Point): Point {
|
||||
return Point(this.x + other.x, this.y + other.y)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.crop
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.systemGestureExclusion
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import org.fairscan.imageprocessing.scaledTo
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun QuadOverlay(
|
||||
quad: Quad,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val quadColor = MaterialTheme.colorScheme.primary
|
||||
val handleColor = quadColor.copy(alpha = 0.5f)
|
||||
|
||||
Canvas(modifier = modifier
|
||||
.fillMaxSize()
|
||||
// Android limits the exclusion areas, so we cannot simply exclude the whole quad/image.
|
||||
// Instead, the corners must get excluded individually.
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.topLeft, containerSize, displaySize) }
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.topRight, containerSize, displaySize) }
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.bottomRight, containerSize, displaySize) }
|
||||
.systemGestureExclusion { cornerExclusionRect(quad.bottomLeft, containerSize, displaySize) }
|
||||
) {
|
||||
val scaledQuad = quad.scaledTo(
|
||||
fromWidth = 1,
|
||||
fromHeight = 1,
|
||||
toWidth = displaySize.width,
|
||||
toHeight = displaySize.height
|
||||
)
|
||||
|
||||
val offset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
|
||||
val corners = listOf(
|
||||
scaledQuad.topLeft.toOffset(),
|
||||
scaledQuad.topRight.toOffset(),
|
||||
scaledQuad.bottomRight.toOffset(),
|
||||
scaledQuad.bottomLeft.toOffset()
|
||||
).map { it.copy(x = it.x + offset.width, y = it.y + offset.height) }
|
||||
|
||||
// Draw edges
|
||||
for (i in 0 until 4) {
|
||||
drawLine(
|
||||
color = quadColor,
|
||||
start = corners[i],
|
||||
end = corners[(i + 1) % 4],
|
||||
strokeWidth = 10.0f
|
||||
)
|
||||
}
|
||||
|
||||
// Draw corner handles
|
||||
corners.forEach { corner ->
|
||||
drawCircle(
|
||||
color = handleColor,
|
||||
radius = QuadEditingHandler.CORNER_RADIUS,
|
||||
center = corner
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun Point.toOffset() = Offset(x.toFloat(), y.toFloat())
|
||||
|
||||
private fun cornerExclusionRect(
|
||||
corner: Point,
|
||||
containerSize: IntSize,
|
||||
displaySize: IntSize
|
||||
): Rect {
|
||||
val pos = QuadCoordinateUtils.normalizedToScreen(corner, containerSize, displaySize)
|
||||
val r = QuadEditingHandler.CORNER_TOUCH_RADIUS
|
||||
return Rect(max(.0f, pos.x - r),
|
||||
max(.0f, pos.y - r),
|
||||
min(containerSize.width.toFloat(), pos.x + r),
|
||||
min(containerSize.height.toFloat(), pos.y + r)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.document
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Contrast
|
||||
import androidx.compose.material.icons.filled.Crop
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.RotateLeft
|
||||
import androidx.compose.material.icons.filled.RotateRight
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import net.engawapg.lib.zoomable.ZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.CommonPageListState
|
||||
import org.fairscan.app.ui.components.ConfirmationDialog
|
||||
import org.fairscan.app.ui.components.MainActionButton
|
||||
import org.fairscan.app.ui.components.MyScaffold
|
||||
import org.fairscan.app.ui.components.SecondaryActionButton
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.fakeImage
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ColorMode.COLOR
|
||||
import org.fairscan.imageprocessing.ColorMode.GRAYSCALE
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DocumentScreen(
|
||||
uiState: DocumentUiState,
|
||||
navigation: Navigation,
|
||||
onExportClick: () -> Unit,
|
||||
onDeleteImage: () -> Unit,
|
||||
onRotateImage: (Boolean) -> Unit,
|
||||
onToggleColorMode: () -> Unit,
|
||||
onCropClick: () -> Unit,
|
||||
onPageReorder: (String, Int) -> Unit,
|
||||
onPageSelected: (Int) -> Unit,
|
||||
) {
|
||||
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val document = uiState.document
|
||||
val currentPageIndex = uiState.currentPageIndex
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(currentPageIndex) {
|
||||
listState.scrollToItem(currentPageIndex)
|
||||
}
|
||||
|
||||
MyScaffold(
|
||||
navigation = navigation,
|
||||
pageListState = CommonPageListState(
|
||||
document,
|
||||
onPageClick = { index -> onPageSelected(index) },
|
||||
onPageReorder = onPageReorder,
|
||||
currentPageIndex = currentPageIndex,
|
||||
listState = listState,
|
||||
showPageNumbers = true,
|
||||
),
|
||||
bottomBar = {
|
||||
BottomBar(onExportClick, navigation.toCameraScreen)
|
||||
},
|
||||
) { modifier ->
|
||||
DocumentPreview(
|
||||
uiState,
|
||||
{ showDeletePageDialog.value = true },
|
||||
onRotateImage,
|
||||
onToggleColorMode,
|
||||
onCropClick,
|
||||
modifier
|
||||
)
|
||||
if (showDeletePageDialog.value) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.delete_page),
|
||||
message = stringResource(R.string.delete_page_warning),
|
||||
showDialog = showDeletePageDialog
|
||||
) { onDeleteImage() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocumentPreview(
|
||||
uiState: DocumentUiState,
|
||||
onDeleteImage: () -> Unit,
|
||||
onRotateImage: (Boolean) -> Unit,
|
||||
onToggleColorMode: () -> Unit,
|
||||
onCropClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val currentPageIndex = uiState.currentPageIndex
|
||||
val document = uiState.document
|
||||
Column (
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val bitmap = uiState.currentPage?.bitmap
|
||||
val pageKey = uiState.currentPage?.key
|
||||
if (bitmap != null && pageKey != null) {
|
||||
val imageBitmap = bitmap.asImageBitmap()
|
||||
val zoomState = remember(pageKey) {
|
||||
ZoomState(
|
||||
contentSize = Size(bitmap.width.toFloat(), bitmap.height.toFloat())
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize(0.92f)
|
||||
.align(Alignment.Center)) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.align(Alignment.Center)
|
||||
.zoomable(zoomState)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (uiState.currentPage?.isLoading ?: false) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.4f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
EditButtons(
|
||||
uiState,
|
||||
onToggleColorMode,
|
||||
onCropClick,
|
||||
modifier = Modifier.align(Alignment.BottomStart)
|
||||
)
|
||||
RotationButtons(onRotateImage, Modifier.align(Alignment.BottomCenter))
|
||||
SecondaryActionButton(
|
||||
Icons.Outlined.Delete,
|
||||
contentDescription = stringResource(R.string.delete_page),
|
||||
onClick = { onDeleteImage() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
Text("${currentPageIndex + 1} / ${document.pageCount()}",
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(all = 16.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.7f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RotationButtons(
|
||||
onRotateImage: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// RotateLeft on the left, RotateRight on the right: for both LTR and RTL languages
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
Row(modifier = modifier.padding(8.dp)) {
|
||||
// Using AutoMirrored icons would lead to an opposite rotation in RTL languages
|
||||
@Suppress("DEPRECATION")
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.RotateLeft,
|
||||
contentDescription = stringResource(R.string.rotate_left),
|
||||
onClick = { onRotateImage(false) }
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
@Suppress("DEPRECATION")
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.RotateRight,
|
||||
contentDescription = stringResource(R.string.rotate_right),
|
||||
onClick = { onRotateImage(true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditButtons(
|
||||
uiState: DocumentUiState,
|
||||
onToggleColorMode: () -> Unit,
|
||||
onCropClick: () -> Unit,
|
||||
modifier: Modifier
|
||||
) {
|
||||
Row(modifier = modifier.padding(8.dp)) {
|
||||
uiState.currentPage?.colorMode?.let {
|
||||
ColorModeButton(
|
||||
currentColorMode = it,
|
||||
onToggle = { onToggleColorMode() },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (uiState.currentPage?.canBeCropped ?: false) {
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.Crop,
|
||||
contentDescription = stringResource(R.string.crop),
|
||||
onClick = onCropClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorModeButton(
|
||||
currentColorMode: ColorMode,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.AutoFixHigh,
|
||||
contentDescription = stringResource(R.string.color_mode),
|
||||
onClick = { expanded = true },
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.color_mode_color)) },
|
||||
leadingIcon = { Icon(Icons.Default.Palette, contentDescription = null) },
|
||||
onClick = {
|
||||
if (currentColorMode != COLOR) onToggle()
|
||||
expanded = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (currentColorMode == COLOR) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.color_mode_grayscale)) },
|
||||
leadingIcon = { Icon(Icons.Default.Contrast, contentDescription = null) },
|
||||
onClick = {
|
||||
if (currentColorMode != GRAYSCALE) onToggle()
|
||||
expanded = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (currentColorMode == GRAYSCALE) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomBar(
|
||||
onExportClick: () -> Unit,
|
||||
onAddPageClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onAddPageClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
Icon(Icons.Outlined.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.add_page),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
MainActionButton(
|
||||
onClick = onExportClick,
|
||||
icon = Icons.Default.Done,
|
||||
text = stringResource(R.string.export),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
@Preview(locale = "ar")
|
||||
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
|
||||
@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320)
|
||||
fun DocumentScreenPreview() {
|
||||
FairScanTheme {
|
||||
val image = fakeImage("gallica.bnf.fr-bpt6k5530456s-1", LocalContext.current).toBitmap()
|
||||
val document = fakeDocument(
|
||||
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
|
||||
LocalContext.current
|
||||
)
|
||||
val key = PageViewKey("123", Rotation.R0, null, 0)
|
||||
DocumentScreen(
|
||||
uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR, true), document),
|
||||
navigation = dummyNavigation(),
|
||||
onExportClick = {},
|
||||
onDeleteImage = { },
|
||||
onRotateImage = { _ -> },
|
||||
onToggleColorMode = { },
|
||||
onCropClick = { },
|
||||
onPageReorder = { _,_ -> },
|
||||
onPageSelected = { _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -12,13 +12,23 @@
|
||||
* 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.home
|
||||
package org.fairscan.app.ui.screens.document
|
||||
|
||||
import android.net.Uri
|
||||
import android.graphics.Bitmap
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
data class RecentDocumentUiState(
|
||||
val fileUri: Uri,
|
||||
val fileName: String,
|
||||
val saveTimestamp: Long,
|
||||
val pageCount: Int,
|
||||
data class DocumentUiState(
|
||||
val currentPageIndex: Int,
|
||||
val currentPage: CurrentPageUiState?,
|
||||
val document: DocumentUiModel,
|
||||
)
|
||||
|
||||
data class CurrentPageUiState(
|
||||
val key: PageViewKey,
|
||||
val bitmap: Bitmap?,
|
||||
val colorMode: ColorMode?,
|
||||
val canBeCropped: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,51 +14,69 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.screens.export
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -68,7 +86,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 kotlinx.collections.immutable.persistentListOf
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.THUMBNAIL_SIZE_DP
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.AppOverflowMenu
|
||||
import org.fairscan.app.ui.components.BackButton
|
||||
@@ -77,73 +97,75 @@ 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.fakeDocument
|
||||
import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.io.IOException
|
||||
|
||||
@Composable
|
||||
fun ExportScreenWrapper(
|
||||
navigation: Navigation,
|
||||
uiState: ExportUiState,
|
||||
pdfActions: ExportActions,
|
||||
currentDocument: DocumentUiModel,
|
||||
exportActions: ExportActions,
|
||||
onCloseScan: () -> Unit,
|
||||
) {
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
val showConfirmationDialog = rememberSaveable { mutableStateOf(false) }
|
||||
val filename = remember { mutableStateOf(defaultFilename()) }
|
||||
LaunchedEffect(Unit) {
|
||||
pdfActions.setFilename(filename.value)
|
||||
pdfActions.initializeExportScreen()
|
||||
}
|
||||
|
||||
val onFilenameChange = { newName:String ->
|
||||
filename.value = newName
|
||||
pdfActions.setFilename(newName)
|
||||
LaunchedEffect(Unit) {
|
||||
exportActions.prepareExportIfNeeded()
|
||||
}
|
||||
val ensureCorrectFileName = {
|
||||
val value = filename.value.trim().ifEmpty { defaultFilename() }
|
||||
if (value != filename.value) {
|
||||
onFilenameChange(value)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exportActions.cancelPreparationJob()
|
||||
}
|
||||
}
|
||||
|
||||
val onFilenameChange = { newName:String ->
|
||||
exportActions.setFilename(newName)
|
||||
}
|
||||
|
||||
ExportScreen(
|
||||
filename = filename,
|
||||
onFilenameChange = onFilenameChange,
|
||||
uiState = uiState,
|
||||
currentDocument = currentDocument,
|
||||
navigation = navigation,
|
||||
onShare = {
|
||||
ensureCorrectFileName()
|
||||
pdfActions.share()
|
||||
if (!uiState.isSaving) {
|
||||
exportActions.share()
|
||||
}
|
||||
},
|
||||
onSave = {
|
||||
ensureCorrectFileName()
|
||||
pdfActions.save()
|
||||
if (!uiState.isSaving) {
|
||||
exportActions.save()
|
||||
}
|
||||
},
|
||||
onOpen = pdfActions.open,
|
||||
onOpen = exportActions.open,
|
||||
onCloseScan = {
|
||||
if (uiState.hasSavedOrShared)
|
||||
onCloseScan()
|
||||
else
|
||||
showConfirmationDialog.value = true
|
||||
if (!uiState.isSaving) {
|
||||
if (uiState.hasSavedOrShared)
|
||||
onCloseScan()
|
||||
else
|
||||
showConfirmationDialog.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (showConfirmationDialog.value) {
|
||||
NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.end_scan))
|
||||
NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.scan_new))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExportScreen(
|
||||
filename: MutableState<String>,
|
||||
onFilenameChange: (String) -> Unit,
|
||||
uiState: ExportUiState,
|
||||
currentDocument: DocumentUiModel,
|
||||
navigation: Navigation,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
@@ -163,15 +185,16 @@ fun ExportScreen(
|
||||
) { innerPadding ->
|
||||
val containerModifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
val onThumbnailClick = navigation.toDocumentScreen
|
||||
if (!isLandscape(LocalConfiguration.current)) {
|
||||
Column(
|
||||
modifier = containerModifier.fillMaxSize(),
|
||||
modifier = containerModifier.fillMaxSize().padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
TextFieldAndPdfInfos(filename, onFilenameChange, uiState, onOpen)
|
||||
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
|
||||
Spacer(Modifier.weight(1f)) // push buttons down
|
||||
MainActions(uiState, onShare, onSave, onCloseScan)
|
||||
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
@@ -179,13 +202,14 @@ fun ExportScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
TextFieldAndPdfInfos(filename, onFilenameChange, uiState, onOpen)
|
||||
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MainActions(uiState, onShare, onSave, onCloseScan)
|
||||
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,64 +218,143 @@ fun ExportScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextFieldAndPdfInfos(
|
||||
filename: MutableState<String>,
|
||||
onFilenameChange: (String) -> Unit,
|
||||
private fun PdfInfosAndResultBar(
|
||||
uiState: ExportUiState,
|
||||
currentDocument: DocumentUiModel,
|
||||
onOpen: (SavedItem) -> Unit,
|
||||
onThumbnailClick: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
|
||||
PdfInfoCard {
|
||||
PdfInfos(uiState, currentDocument, onThumbnailClick)
|
||||
SaveStatusBar(uiState, onOpen)
|
||||
}
|
||||
|
||||
uiState.error?.let {
|
||||
ErrorBar(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfInfoCard(
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PdfInfos(
|
||||
uiState: ExportUiState,
|
||||
currentDocument: DocumentUiModel,
|
||||
onThumbnailClick: () -> Unit,
|
||||
) {
|
||||
val result = uiState.result
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
val thumbnail = currentDocument.thumbnail(0)
|
||||
thumbnail?.let {
|
||||
Card(
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.heightIn(max = THUMBNAIL_SIZE_DP.dp)
|
||||
.widthIn(max = THUMBNAIL_SIZE_DP.dp)
|
||||
) {
|
||||
Image(
|
||||
bitmap = thumbnail.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable { onThumbnailClick() }
|
||||
)
|
||||
}
|
||||
}
|
||||
// PDF infos
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
val pageCount = result?.pageCount ?: uiState.progress?.totalPages
|
||||
pageCount?.let { count -> Text(text = pageCountText(count)) }
|
||||
|
||||
uiState.ocrActivation?.let { activated ->
|
||||
Text(
|
||||
if (activated)
|
||||
stringResource(R.string.text_recognition_enabled)
|
||||
else
|
||||
stringResource(R.string.text_recognition_disabled),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.isGenerating) {
|
||||
Text(
|
||||
text = stringResource(R.string.creating_export),
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
uiState.progress?.let{ p ->
|
||||
if (p.totalPages == 1) {
|
||||
LinearProgressIndicator()
|
||||
} else {
|
||||
LinearProgressIndicator({ p.progress })
|
||||
}
|
||||
}
|
||||
} else if (result != null) {
|
||||
val context = LocalContext.current
|
||||
val formattedFileSize = formatFileSize(result.sizeInBytes, context)
|
||||
val sizeMessageKey =
|
||||
if (result.files.size == 1) R.string.file_size else R.string.file_size_total
|
||||
Text(
|
||||
text = stringResource(sizeMessageKey, formattedFileSize),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveStatusBar(
|
||||
uiState: ExportUiState,
|
||||
onOpen: (SavedItem) -> Unit,
|
||||
) {
|
||||
FilenameTextField(filename, onFilenameChange)
|
||||
|
||||
val result = uiState.result
|
||||
|
||||
// PDF infos
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
|
||||
if (uiState.isGenerating) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
when {
|
||||
uiState.isSaving -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.creating_export),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
} else if (result != null) {
|
||||
val context = LocalContext.current
|
||||
val formattedFileSize = formatFileSize(result.sizeInBytes, context)
|
||||
Text(text = pageCountText(result.pageCount))
|
||||
val sizeMessageKey =
|
||||
if (result.files.size == 1) R.string.file_size else R.string.file_size_total
|
||||
Text(
|
||||
text = stringResource(sizeMessageKey, formattedFileSize),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.savedBundle != null) {
|
||||
SaveInfoBar(uiState.savedBundle, onOpen)
|
||||
}
|
||||
if (uiState.errorMessage != null) {
|
||||
ErrorBar(uiState.errorMessage)
|
||||
uiState.savedBundle != null -> {
|
||||
SaveInfoBar(uiState.savedBundle, onOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilenameTextField(
|
||||
filename: MutableState<String>,
|
||||
filename: String,
|
||||
onFilenameChange: (String) -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
OutlinedTextField(
|
||||
value = filename.value,
|
||||
value = filename,
|
||||
onValueChange = onFilenameChange,
|
||||
label = { Text(stringResource(R.string.filename)) },
|
||||
singleLine = true,
|
||||
@@ -259,9 +362,9 @@ private fun FilenameTextField(
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
trailingIcon = {
|
||||
if (filename.value.isNotEmpty()) {
|
||||
if (filename.isNotEmpty()) {
|
||||
IconButton(onClick = {
|
||||
filename.value = ""
|
||||
onFilenameChange("")
|
||||
focusRequester.requestFocus()
|
||||
}) {
|
||||
Icon(Icons.Default.Clear, stringResource(R.string.clear_text))
|
||||
@@ -273,6 +376,7 @@ private fun FilenameTextField(
|
||||
|
||||
@Composable
|
||||
private fun MainActions(
|
||||
onFilenameChange: (String) -> Unit,
|
||||
uiState: ExportUiState,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
@@ -281,30 +385,35 @@ private fun MainActions(
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ExportButton(
|
||||
onClick = onShare,
|
||||
enabled = uiState.result != null,
|
||||
isPrimary = !uiState.hasSavedOrShared,
|
||||
icon = Icons.Default.Share,
|
||||
text = stringResource(R.string.share),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ExportButton(
|
||||
onClick = onSave,
|
||||
enabled = uiState.result != null,
|
||||
isPrimary = !uiState.hasSavedOrShared,
|
||||
icon = Icons.Default.Download,
|
||||
text = stringResource(R.string.save),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ActionSurface {
|
||||
FilenameTextField(uiState.filename, onFilenameChange)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ExportButton(
|
||||
onClick = onShare,
|
||||
enabled = uiState.result != null,
|
||||
isPrimary = !uiState.hasSavedOrShared,
|
||||
icon = Icons.Default.Share,
|
||||
text = stringResource(R.string.share),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ExportButton(
|
||||
onClick = onSave,
|
||||
enabled = uiState.result != null,
|
||||
isPrimary = !uiState.hasSavedOrShared,
|
||||
icon = Icons.Default.Download,
|
||||
text = stringResource(R.string.save),
|
||||
modifier = Modifier.weight(1f)
|
||||
.alpha(if (uiState.isSaving) 0.6f else 1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
ExportButton(
|
||||
icon = Icons.Default.Done,
|
||||
text = stringResource(R.string.end_scan),
|
||||
text = stringResource(R.string.scan_new),
|
||||
onClick = onCloseScan,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isPrimary = uiState.hasSavedOrShared,
|
||||
@@ -312,6 +421,27 @@ private fun MainActions(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
tonalElevation = 1.dp,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ExportButton(
|
||||
icon: ImageVector,
|
||||
@@ -345,63 +475,189 @@ fun ExportButton(
|
||||
modifier = modifier
|
||||
) {
|
||||
Icon(icon, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveInfoBar(savedBundle: SavedBundle, onOpen: (SavedItem) -> Unit) {
|
||||
val dirName = savedBundle.exportDirName?:stringResource(R.string.download_dirname)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Absolute.SpaceBetween,
|
||||
private fun SaveInfoBar(
|
||||
savedBundle: SavedBundle,
|
||||
onOpen: (SavedItem) -> Unit,
|
||||
) {
|
||||
val dirName = savedBundle.saveDir?.name ?: stringResource(R.string.download_dirname)
|
||||
val items = savedBundle.items
|
||||
val nbFiles = items.size
|
||||
val firstFileName = items[0].fileName
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = LocalResources.current.getQuantityString(
|
||||
R.plurals.files_saved_to,
|
||||
nbFiles,
|
||||
nbFiles, firstFileName, dirName
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
if (nbFiles == 1) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
MainActionButton(
|
||||
onClick = { onOpen(items[0]) },
|
||||
text = stringResource(R.string.open),
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBar(error: ExportError) {
|
||||
val (summary, details) = error.toDisplayText()
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.error,
|
||||
RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val items = savedBundle.items
|
||||
val nbFiles = items.size
|
||||
val firstFileName = items[0].fileName
|
||||
Text(
|
||||
text = LocalResources.current.getQuantityString(
|
||||
R.plurals.files_saved_to,
|
||||
nbFiles,
|
||||
nbFiles, firstFileName, dirName
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (nbFiles == 1) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
MainActionButton(
|
||||
onClick = { onOpen(items[0]) },
|
||||
text = stringResource(R.string.open),
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (details != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val text = buildString {
|
||||
append(summary)
|
||||
append("\n\n")
|
||||
append(details)
|
||||
}
|
||||
clipboard.setPrimaryClip(
|
||||
ClipData.newPlainText("Export error", text)
|
||||
)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = stringResource(R.string.copy_logs),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (details != null) {
|
||||
Text(
|
||||
text = details,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBar(errorMessage: String) {
|
||||
Text(
|
||||
text = stringResource(R.string.error, errorMessage),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(16.dp),
|
||||
)
|
||||
private fun ExportError.toDisplayText(): Pair<String, String?> {
|
||||
return when (this) {
|
||||
is ExportError.OnPrepareOrShare -> {
|
||||
val summary = message
|
||||
val details = throwable.message
|
||||
summary to details
|
||||
}
|
||||
|
||||
is ExportError.OnSave -> {
|
||||
val summary = stringResource(messageRes)
|
||||
val contextLines = buildErrorContextLines(saveDir)
|
||||
val details = buildString {
|
||||
if (contextLines.isNotEmpty()) {
|
||||
append(contextLines.joinToString("\n"))
|
||||
}
|
||||
throwable?.message?.let {
|
||||
if (isNotEmpty()) append("\n\n")
|
||||
append(it)
|
||||
}
|
||||
}.ifEmpty { null }
|
||||
|
||||
summary to details
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultFilename(): String {
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date())
|
||||
return "Scan $timestamp"
|
||||
@Composable
|
||||
private fun buildErrorContextLines(
|
||||
saveDir: SaveDir?,
|
||||
): List<String> {
|
||||
val defaultDirName = stringResource(R.string.download_dirname)
|
||||
|
||||
val folderLine = when {
|
||||
saveDir == null ->
|
||||
stringResource(R.string.error_context_folder, defaultDirName)
|
||||
|
||||
saveDir.name != null ->
|
||||
stringResource(R.string.error_context_folder, saveDir.name)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
val providerLine = saveDir?.uri?.authority
|
||||
?.let(::providerLabel)
|
||||
?.let { stringResource(R.string.error_context_provider, it) }
|
||||
|
||||
return listOfNotNull(folderLine, providerLine)
|
||||
}
|
||||
|
||||
fun providerLabel(authority: String): String =
|
||||
when {
|
||||
authority.contains("nextcloud", ignoreCase = true) ->
|
||||
"Nextcloud"
|
||||
authority == "com.android.externalstorage.documents" ->
|
||||
"Local storage"
|
||||
else ->
|
||||
authority
|
||||
}
|
||||
|
||||
fun formatFileSize(sizeInBytes: Long?, context: Context): String {
|
||||
return if (sizeInBytes == null) context.getString(R.string.unknown_size)
|
||||
else Formatter.formatShortFileSize(context, sizeInBytes)
|
||||
@@ -411,7 +667,11 @@ fun formatFileSize(sizeInBytes: Long?, context: Context): String {
|
||||
@Composable
|
||||
fun PreviewExportScreenDuringGeneration() {
|
||||
ExportPreviewToCustomize(
|
||||
uiState = ExportUiState(isGenerating = true)
|
||||
uiState = ExportUiState(
|
||||
isGenerating = true,
|
||||
progress = ExportProgress(1, 3),
|
||||
ocrActivation = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,6 +682,7 @@ fun PreviewExportScreenAfterGeneration() {
|
||||
ExportPreviewToCustomize(
|
||||
uiState = ExportUiState(
|
||||
result = ExportResult.Pdf(file, 442897L, 3),
|
||||
ocrActivation = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -434,7 +695,7 @@ fun PreviewExportScreenAfterSave() {
|
||||
uiState = ExportUiState(
|
||||
result = ExportResult.Pdf(file, 442897L, 3),
|
||||
savedBundle = SavedBundle(
|
||||
listOf(SavedItem(file.toUri(), defaultFilename() + ".pdf", PDF))
|
||||
listOf(SavedItem(file.toUri(), "12345.pdf", PDF))
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -444,7 +705,8 @@ fun PreviewExportScreenAfterSave() {
|
||||
@Composable
|
||||
fun ExportScreenPreviewWithError() {
|
||||
ExportPreviewToCustomize(
|
||||
ExportUiState(errorMessage = "PDF generation failed")
|
||||
ExportUiState(error =
|
||||
ExportError.OnPrepareOrShare("PDF generation failed", IOException("Boom")))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -457,7 +719,7 @@ fun PreviewExportScreenAfterSaveHorizontal() {
|
||||
result = ExportResult.Pdf(file, 442897L, 3),
|
||||
savedBundle = SavedBundle(
|
||||
listOf(SavedItem(file.toUri(), "my_file.pdf", PDF)),
|
||||
exportDirName="MyVeryVeryLongDirectoryName"),
|
||||
SaveDir("fil:///root/dir".toUri() ,"MyVeryVeryLongDirectoryName")),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -466,10 +728,13 @@ fun PreviewExportScreenAfterSaveHorizontal() {
|
||||
fun ExportPreviewToCustomize(uiState: ExportUiState) {
|
||||
FairScanTheme {
|
||||
ExportScreen(
|
||||
filename = remember { mutableStateOf("Scan 2025-07-02 17.40.42") },
|
||||
onFilenameChange = {_->},
|
||||
navigation = dummyNavigation(),
|
||||
uiState = uiState,
|
||||
currentDocument = fakeDocument(
|
||||
persistentListOf("gallica.bnf.fr-bpt6k5530456s-1"),
|
||||
LocalContext.current
|
||||
),
|
||||
navigation = dummyNavigation(),
|
||||
onShare = {},
|
||||
onSave = {},
|
||||
onOpen = {},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -19,15 +19,26 @@ import org.fairscan.app.ui.screens.settings.ExportFormat
|
||||
|
||||
data class ExportUiState(
|
||||
val format: ExportFormat = ExportFormat.PDF,
|
||||
val filename: String = "",
|
||||
val isGenerating: Boolean = false,
|
||||
val progress: ExportProgress? = null,
|
||||
val ocrActivation: Boolean? = null,
|
||||
val isSaving: Boolean = false,
|
||||
val result: ExportResult? = null,
|
||||
val savedBundle: SavedBundle? = null,
|
||||
val hasShared: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val error: ExportError? = null,
|
||||
) {
|
||||
val hasSavedOrShared get() = savedBundle != null || hasShared
|
||||
}
|
||||
|
||||
data class ExportProgress(
|
||||
val completedPages: Int,
|
||||
val totalPages: Int,
|
||||
) {
|
||||
val progress = completedPages.toFloat() / totalPages
|
||||
}
|
||||
|
||||
data class SavedItem(
|
||||
val uri: Uri,
|
||||
val fileName: String,
|
||||
@@ -36,6 +47,24 @@ data class SavedItem(
|
||||
|
||||
data class SavedBundle(
|
||||
val items: List<SavedItem>,
|
||||
val exportDir: Uri? = null,
|
||||
val exportDirName: String? = null,
|
||||
val saveDir: SaveDir? = null,
|
||||
)
|
||||
|
||||
data class SaveDir(
|
||||
val uri: Uri,
|
||||
val name: String?,
|
||||
)
|
||||
|
||||
sealed class ExportError {
|
||||
|
||||
data class OnPrepareOrShare(
|
||||
val message: String,
|
||||
val throwable: Throwable,
|
||||
) : ExportError()
|
||||
|
||||
data class OnSave(
|
||||
val messageRes: Int,
|
||||
val saveDir: SaveDir?,
|
||||
val throwable: Throwable? = null,
|
||||
): ExportError()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -27,6 +27,8 @@ import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -39,21 +41,27 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.RecentDocument
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.data.FileManager
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.domain.jpegsForExport
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.pagesToExport
|
||||
import org.fairscan.app.ui.screens.settings.ExportFormat
|
||||
import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CancellationException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
sealed interface ExportEvent {
|
||||
data object RequestSave : ExportEvent
|
||||
data object SaveError : ExportEvent
|
||||
data class Share(val result: ExportResult) : ExportEvent
|
||||
}
|
||||
|
||||
class ExportViewModel(container: AppContainer, val imageRepository: ImageRepository): ViewModel() {
|
||||
@@ -61,127 +69,204 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
private val preparationDir = container.preparationDir
|
||||
private val fileManager = container.fileManager
|
||||
private val settingsRepository = container.settingsRepository
|
||||
private val recentDocumentsDataStore = container.recentDocumentsDataStore
|
||||
private val ocrService = container.ocrService
|
||||
private val logger = container.logger
|
||||
|
||||
private val _events = MutableSharedFlow<ExportEvent>()
|
||||
val events = _events.asSharedFlow()
|
||||
|
||||
private suspend fun generatePdf(
|
||||
exportQuality: ExportQuality
|
||||
exportQuality: ExportQuality,
|
||||
disableOcr: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
): ExportResult.Pdf = withContext(Dispatchers.IO) {
|
||||
val jpegs = jpegsForExport(imageRepository, exportQuality)
|
||||
val pdf = fileManager.generatePdf(jpegs)
|
||||
val pageToExports = pagesToExport(imageRepository, exportQuality)
|
||||
val pdf = fileManager.generatePdf(pageToExports, disableOcr, onProgress)
|
||||
return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount)
|
||||
}
|
||||
|
||||
suspend fun generatePdfForExternalCall(): ExportResult.Pdf {
|
||||
return generatePdf(ExportQuality.BALANCED)
|
||||
val pdf = generatePdf(ExportQuality.BALANCED, true) {}
|
||||
val sourceFile = pdf.file
|
||||
val targetFile = File(sourceFile.parentFile, defaultFilename() + ".pdf")
|
||||
if (sourceFile.absolutePath == targetFile.absolutePath) return pdf
|
||||
if (targetFile.exists() || !sourceFile.renameTo(targetFile)) return pdf
|
||||
return pdf.copy(file = targetFile)
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(ExportUiState())
|
||||
val uiState: StateFlow<ExportUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var lastPreparationKey: ExportPreparationKey? = null
|
||||
private var preparationJob: Job? = null
|
||||
private var desiredFilename: String = ""
|
||||
private var exportFormat = ExportFormat.PDF
|
||||
|
||||
fun setFilename(name: String) {
|
||||
desiredFilename = name
|
||||
_uiState.update {
|
||||
it.copy(filename = name)
|
||||
}
|
||||
}
|
||||
|
||||
fun initializeExportScreen() {
|
||||
cancelPreparation()
|
||||
fun resetFilename() {
|
||||
_uiState.update {
|
||||
it.copy(filename = "")
|
||||
}
|
||||
}
|
||||
|
||||
preparationJob = viewModelScope.launch {
|
||||
private fun defaultFilename(): String {
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date())
|
||||
return "Scan $timestamp"
|
||||
}
|
||||
|
||||
private fun ensureValidFilename() {
|
||||
_uiState.update {
|
||||
val normalized = it.filename.trim().ifEmpty { defaultFilename() }
|
||||
if (normalized != it.filename) {
|
||||
it.copy(filename = normalized)
|
||||
} else it
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentPageKeys(): ImmutableList<PageViewKey> =
|
||||
imageRepository.pages().map { it.key() }.toImmutableList()
|
||||
|
||||
fun cancelPreparationJob() {
|
||||
preparationJob?.let {
|
||||
// keep result if job is not active
|
||||
if (it.isActive) {
|
||||
_uiState.update { ExportUiState() }
|
||||
}
|
||||
it.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareExportIfNeeded() {
|
||||
ensureValidFilename()
|
||||
|
||||
viewModelScope.launch {
|
||||
val exportQuality = settingsRepository.exportQuality.first()
|
||||
exportFormat = settingsRepository.exportFormat.first()
|
||||
_uiState.update { it.copy(format = exportFormat, isGenerating = true) }
|
||||
try {
|
||||
val t1 = System.currentTimeMillis()
|
||||
val result = if (exportFormat == ExportFormat.JPEG) {
|
||||
generateJpegs(exportQuality)
|
||||
} else {
|
||||
generatePdf(exportQuality)
|
||||
}
|
||||
val exportFormat = settingsRepository.exportFormat.first()
|
||||
val ocrLanguageString = ocrService.languageString()
|
||||
|
||||
val currentPageKeys = currentPageKeys()
|
||||
val key = ExportPreparationKey(
|
||||
currentPageKeys, exportFormat, exportQuality, ocrLanguageString)
|
||||
if (key == lastPreparationKey && _uiState.value.result != null) {
|
||||
return@launch
|
||||
}
|
||||
val pageCount = currentPageKeys.size
|
||||
|
||||
lastPreparationKey = key
|
||||
preparationJob?.cancel()
|
||||
|
||||
preparationJob = launch {
|
||||
val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null
|
||||
_uiState.update {
|
||||
it.copy(isGenerating = false, result = result)
|
||||
}
|
||||
val t2 = System.currentTimeMillis()
|
||||
val pageCount = result.pageCount
|
||||
Log.i("Export", "Generation: $pageCount pages, $exportQuality, ${t2-t1} ms")
|
||||
} catch (e: Exception) {
|
||||
val message = "Failed to prepare $exportFormat export"
|
||||
logger.e("FairScan", message, e)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isGenerating = false,
|
||||
errorMessage = message
|
||||
ExportUiState(
|
||||
filename = it.filename,
|
||||
format = exportFormat,
|
||||
isGenerating = true,
|
||||
progress = ExportProgress(0, pageCount),
|
||||
ocrActivation = ocrActivation,
|
||||
)
|
||||
}
|
||||
val onProgress: (Int) -> Unit = { completedPages ->
|
||||
_uiState.update {
|
||||
it.copy(progress = ExportProgress(completedPages, pageCount))
|
||||
}
|
||||
}
|
||||
try {
|
||||
val t1 = System.currentTimeMillis()
|
||||
val result = if (exportFormat == ExportFormat.JPEG) {
|
||||
generateJpegs(exportQuality, onProgress)
|
||||
} else {
|
||||
generatePdf(exportQuality, false, onProgress)
|
||||
}
|
||||
_uiState.update { it.copy(result = result) }
|
||||
val t2 = System.currentTimeMillis()
|
||||
Log.i("Export", "Generation: $pageCount pages, $exportQuality, ${t2 - t1} ms")
|
||||
} catch (e: CancellationException) {
|
||||
// Preparation cancelled: do nothing
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val message = "Failed to prepare $exportFormat export"
|
||||
logger.e("FairScan", message, e)
|
||||
_uiState.update {
|
||||
it.copy(error = ExportError.OnPrepareOrShare(message, e))
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(isGenerating = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun generateJpegs(
|
||||
exportQuality: ExportQuality
|
||||
exportQuality: ExportQuality,
|
||||
onProgress: (Int) -> Unit,
|
||||
): ExportResult.Jpeg = withContext(Dispatchers.IO) {
|
||||
val jpegs = jpegsForExport(imageRepository, exportQuality)
|
||||
val pageToExports = pagesToExport(imageRepository, exportQuality)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
preparationDir.mkdirs()
|
||||
val files = jpegs.mapIndexed { index, bytes ->
|
||||
val files = pageToExports.mapIndexed { index, page ->
|
||||
val file = File(preparationDir, "$timestamp-${index + 1}.jpg")
|
||||
file.writeBytes(bytes)
|
||||
file.writeBytes(page.jpeg.get().bytes)
|
||||
onProgress(index + 1)
|
||||
file
|
||||
}.toList()
|
||||
val sizeInBytes = files.sumOf { it.length() }
|
||||
ExportResult.Jpeg(files, sizeInBytes)
|
||||
}
|
||||
|
||||
fun cancelPreparation() {
|
||||
preparationJob?.cancel()
|
||||
_uiState.value = ExportUiState()
|
||||
private fun renameFile(source: File, target: File) {
|
||||
if (source.absolutePath == target.absolutePath) return
|
||||
if (target.exists() && !target.delete()) {
|
||||
throw IOException("Cannot delete existing file ${target.absolutePath}")
|
||||
}
|
||||
if (!source.renameTo(target)) {
|
||||
throw IOException("Failed to rename ${source.name} to ${target.name}")
|
||||
}
|
||||
}
|
||||
|
||||
fun setAsShared() {
|
||||
_uiState.update { it.copy(hasShared = true) }
|
||||
}
|
||||
|
||||
fun applyRenaming(): ExportResult? {
|
||||
val result = _uiState.value.result ?: return null
|
||||
when (result) {
|
||||
private fun applyRenaming(): ExportResult {
|
||||
val result = _uiState.value.result
|
||||
?: throw IllegalStateException("Export result missing")
|
||||
ensureValidFilename()
|
||||
val filename = _uiState.value.filename
|
||||
val updated = when (result) {
|
||||
is ExportResult.Pdf -> {
|
||||
val fileName = FileManager.addPdfExtensionIfMissing(desiredFilename)
|
||||
val fileName = FileManager.addPdfExtensionIfMissing(filename)
|
||||
val newFile = File(result.file.parentFile, fileName)
|
||||
val tempFile = result.file
|
||||
if (tempFile.absolutePath != newFile.absolutePath) {
|
||||
if (newFile.exists()) newFile.delete()
|
||||
val success = tempFile.renameTo(newFile)
|
||||
if (!success) return null
|
||||
_uiState.update {
|
||||
it.copy(result = ExportResult.Pdf(
|
||||
newFile, result.sizeInBytes, result.pageCount)
|
||||
)
|
||||
}
|
||||
}
|
||||
renameFile(result.file, newFile)
|
||||
ExportResult.Pdf(newFile, result.sizeInBytes, result.pageCount)
|
||||
}
|
||||
is ExportResult.Jpeg -> {
|
||||
val base = desiredFilename.removeSuffix(".jpg")
|
||||
val base = filename.removeSuffix(".jpg")
|
||||
val files = result.files
|
||||
val renamedFiles = files.mapIndexed { index, file ->
|
||||
val indexSuffix = if (files.size == 1) "" else "_${index + 1}"
|
||||
val newFile = File(file.parentFile, "${base}${indexSuffix}.jpg")
|
||||
if (file.absolutePath != newFile.absolutePath) {
|
||||
if (newFile.exists()) newFile.delete()
|
||||
file.renameTo(newFile)
|
||||
}
|
||||
renameFile(file, newFile)
|
||||
newFile
|
||||
}
|
||||
val updated = result.copy(jpegFiles = renamedFiles)
|
||||
_uiState.update { it.copy(result = updated) }
|
||||
result.copy(jpegFiles = renamedFiles)
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(result = updated) }
|
||||
return updated
|
||||
}
|
||||
|
||||
fun onShareClicked() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = applyRenaming()
|
||||
_events.emit(ExportEvent.Share(result))
|
||||
_uiState.update { it.copy(hasShared = true) }
|
||||
} catch (e: Exception) {
|
||||
val message = "Failed to prepare share"
|
||||
logger.e("FairScan", message, e)
|
||||
_uiState.update { it.copy(error = ExportError.OnPrepareOrShare(message, e)) }
|
||||
}
|
||||
}
|
||||
return _uiState.value.result
|
||||
}
|
||||
|
||||
fun onSaveClicked() {
|
||||
@@ -192,23 +277,45 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
|
||||
fun onRequestSave(context: Context) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update {it.copy(isSaving = true, error = null, savedBundle = null) }
|
||||
val exportFormat = uiState.value.format
|
||||
val saveDir = saveDir(context)
|
||||
try {
|
||||
save(context)
|
||||
// Must not run on the main thread: some SAF providers (e.g. Nextcloud)
|
||||
// may perform network I/O
|
||||
withContext(Dispatchers.IO) {
|
||||
save(context, saveDir, exportFormat)
|
||||
}
|
||||
} catch (e: MissingExportDirPermissionException) {
|
||||
logger.e("FairScan", "Missing export dir permission", e)
|
||||
_uiState.update {
|
||||
it.copy(error =
|
||||
ExportError.OnSave(R.string.error_export_dir_permission_lost, saveDir))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e("FairScan", "Failed to save PDF", e)
|
||||
_events.emit(ExportEvent.SaveError)
|
||||
_uiState.update {
|
||||
it.copy(error = ExportError.OnSave(R.string.error_save, saveDir, e))
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(isSaving = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun save(context:Context) {
|
||||
val result = applyRenaming() ?: return
|
||||
val exportDir = settingsRepository.exportDirUri.first()?.toUri()
|
||||
private suspend fun saveDir(context:Context): SaveDir? {
|
||||
val uri = settingsRepository.exportDirUri.first()?.toUri() ?: return null
|
||||
val name = resolveExportDirName(context, uri)
|
||||
return SaveDir(uri, name)
|
||||
}
|
||||
|
||||
private suspend fun save(context: Context, saveDir: SaveDir?, exportFormat: ExportFormat) {
|
||||
val result = applyRenaming()
|
||||
val savedItems = mutableListOf<SavedItem>()
|
||||
val filesForMediaScan = mutableListOf<File>()
|
||||
|
||||
for (file in result.files) {
|
||||
val saved = if (exportDir == null) {
|
||||
val saved = if (saveDir == null) {
|
||||
// No export dir defined -> save to Downloads
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Android 10+: use MediaStore API
|
||||
@@ -223,22 +330,20 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
}
|
||||
} else {
|
||||
// Use Storage Access Framework to save to the chosen directory
|
||||
val safFile = saveViaSaf(context, file, exportDir, exportFormat)
|
||||
if (!context.contentResolver.persistedUriPermissions.any { perm ->
|
||||
perm.uri == saveDir.uri && perm.isWritePermission
|
||||
}) {
|
||||
throw MissingExportDirPermissionException(saveDir.uri)
|
||||
}
|
||||
val safFile = saveViaSaf(context, file, saveDir.uri, exportFormat)
|
||||
SavedItem(safFile.uri, safFile.name ?: file.name, exportFormat)
|
||||
}
|
||||
savedItems += saved
|
||||
}
|
||||
|
||||
val exportDirName = resolveExportDirName(context, exportDir)
|
||||
val bundle = SavedBundle(savedItems, exportDir, exportDirName)
|
||||
val bundle = SavedBundle(savedItems, saveDir)
|
||||
_uiState.update { it.copy(savedBundle = bundle) }
|
||||
|
||||
if (exportFormat == ExportFormat.PDF) {
|
||||
savedItems.forEach { item ->
|
||||
addRecentDocument(item.uri, item.fileName, result.pageCount)
|
||||
}
|
||||
}
|
||||
|
||||
filesForMediaScan.forEach { f -> mediaScan(context, f, exportFormat.mimeType) }
|
||||
}
|
||||
|
||||
@@ -318,29 +423,15 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
DocumentFile.fromTreeUri(context, exportDirUri)?.name
|
||||
}
|
||||
}
|
||||
|
||||
fun addRecentDocument(fileUri: Uri, fileName: String, pageCount: Int) {
|
||||
viewModelScope.launch {
|
||||
recentDocumentsDataStore.updateData { current ->
|
||||
val newDoc = RecentDocument.newBuilder()
|
||||
.setFileUri(fileUri.toString())
|
||||
.setFileName(fileName)
|
||||
.setPageCount(pageCount)
|
||||
.setCreatedAt(System.currentTimeMillis())
|
||||
.build()
|
||||
current.toBuilder()
|
||||
.addDocuments(0, newDoc)
|
||||
.also { builder ->
|
||||
while (builder.documentsCount > 3) {
|
||||
builder.removeDocuments(builder.documentsCount - 1)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ExportPreparationKey(
|
||||
val pages: ImmutableList<PageViewKey>,
|
||||
val format: ExportFormat,
|
||||
val quality: ExportQuality,
|
||||
val ocrLanguageString: String,
|
||||
)
|
||||
|
||||
sealed class ExportResult {
|
||||
abstract val files: List<File>
|
||||
abstract val sizeInBytes: Long
|
||||
@@ -353,7 +444,7 @@ sealed class ExportResult {
|
||||
override val pageCount: Int,
|
||||
) : ExportResult() {
|
||||
override val files get() = listOf(file)
|
||||
override val format: ExportFormat = ExportFormat.PDF
|
||||
override val format: ExportFormat = PDF
|
||||
}
|
||||
|
||||
data class Jpeg(
|
||||
@@ -367,9 +458,16 @@ sealed class ExportResult {
|
||||
}
|
||||
|
||||
data class ExportActions(
|
||||
val initializeExportScreen: () -> Unit,
|
||||
val prepareExportIfNeeded: () -> Unit,
|
||||
val setFilename: (String) -> Unit,
|
||||
val share: () -> Unit,
|
||||
val save: () -> Unit,
|
||||
val open: (SavedItem) -> Unit,
|
||||
val cancelPreparationJob: () -> Unit,
|
||||
)
|
||||
|
||||
class MissingExportDirPermissionException(
|
||||
val uri: Uri
|
||||
) : IllegalStateException(
|
||||
"Missing persisted write permission for export dir: $uri"
|
||||
)
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025-2026 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.home
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.AppOverflowMenu
|
||||
import org.fairscan.app.ui.components.CameraPermissionState
|
||||
import org.fairscan.app.ui.components.formatDate
|
||||
import org.fairscan.app.ui.components.pageCountText
|
||||
import org.fairscan.app.ui.components.rememberCameraPermissionState
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.fakeDocument
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
cameraPermission: CameraPermissionState,
|
||||
currentDocument: DocumentUiModel,
|
||||
navigation: Navigation,
|
||||
onClearScan: () -> Unit,
|
||||
recentDocuments: List<RecentDocumentUiState>,
|
||||
onOpenPdf: (Uri) -> Unit,
|
||||
) {
|
||||
Scaffold (
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
actions = { AppOverflowMenu(navigation) }
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
if (!cameraPermission.isGranted) {
|
||||
CameraPermissionRationale(cameraPermission)
|
||||
} else {
|
||||
ScanButton(
|
||||
onClick = {
|
||||
onClearScan()
|
||||
navigation.toCameraScreen()
|
||||
},
|
||||
Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
if (!currentDocument.isEmpty()) {
|
||||
OngoingScanBanner(
|
||||
currentDocument,
|
||||
onResumeScan = navigation.toDocumentScreen,
|
||||
onClearScan = onClearScan,
|
||||
)
|
||||
} else if (recentDocuments.isNotEmpty()) {
|
||||
RecentDocumentList(recentDocuments, onOpenPdf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraPermissionRationale(cameraPermission: CameraPermissionState) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.camera_permission_rationale),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { cameraPermission.request() },
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text(stringResource(R.string.grant_permission))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScanButton(onClick: () -> Unit, modifier: Modifier) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.padding(32.dp),
|
||||
elevation = ButtonDefaults.buttonElevation(defaultElevation = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PhotoCamera,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.scan_button),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OngoingScanBanner(
|
||||
currentDocument: DocumentUiModel,
|
||||
onResumeScan: () -> Unit,
|
||||
onClearScan: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
currentDocument.load(0)?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_in_progress),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = pageCountText(currentDocument.pageCount()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onClearScan,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeleteOutline,
|
||||
contentDescription = stringResource(R.string.discard_scan),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Button(onClick = onResumeScan) {
|
||||
Text(stringResource(R.string.resume))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentDocumentList(
|
||||
recentDocuments: List<RecentDocumentUiState>,
|
||||
onOpenPdf: (Uri) -> Unit
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.last_saved_pdf_files),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp, bottom = 4.dp)
|
||||
)
|
||||
Column {
|
||||
recentDocuments.forEach { doc ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
doc.fileName,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = pageCountText(doc.pageCount) + " • " +
|
||||
formatDate(doc.saveTimestamp, LocalContext.current),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.PictureAsPdf,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onOpenPdf(doc.fileUri) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HomeScreenPreviewOnFirstLaunch() {
|
||||
FairScanTheme {
|
||||
HomeScreen(
|
||||
cameraPermission = rememberCameraPermissionState(),
|
||||
currentDocument = fakeDocument(),
|
||||
navigation = dummyNavigation(),
|
||||
onClearScan = {},
|
||||
recentDocuments = listOf(),
|
||||
onOpenPdf = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HomeScreenPreviewWithCurrentDocument() {
|
||||
FairScanTheme {
|
||||
HomeScreen(
|
||||
cameraPermission = rememberCameraPermissionState(),
|
||||
currentDocument = fakeDocument(
|
||||
persistentListOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
|
||||
LocalContext.current
|
||||
),
|
||||
navigation = dummyNavigation(),
|
||||
onClearScan = {},
|
||||
recentDocuments = listOf(),
|
||||
onOpenPdf = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HomeScreenPreviewWithLastSavedFiles() {
|
||||
FairScanTheme {
|
||||
HomeScreen(
|
||||
cameraPermission = rememberCameraPermissionState(),
|
||||
currentDocument = fakeDocument(),
|
||||
navigation = dummyNavigation(),
|
||||
onClearScan = {},
|
||||
recentDocuments = listOf(
|
||||
RecentDocumentUiState(
|
||||
File("/path/my_file.pdf").toUri(), "my_file.pdf", 1755971180000, 3),
|
||||
RecentDocumentUiState(
|
||||
"content:///path/scan2.pdf".toUri(), "scan2.pdf",1755000500000, 1)
|
||||
),
|
||||
onOpenPdf = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025-2026 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.home
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.fairscan.app.AppContainer
|
||||
import java.io.File
|
||||
|
||||
class HomeViewModel(appContainer: AppContainer, appContext: Context): ViewModel() {
|
||||
|
||||
private val recentDocumentsDataStore = appContainer.recentDocumentsDataStore
|
||||
|
||||
val recentDocuments: StateFlow<List<RecentDocumentUiState>> =
|
||||
recentDocumentsDataStore.data.map {
|
||||
it.documentsList.mapNotNull { doc ->
|
||||
var fileName = doc.fileName
|
||||
var uri: Uri? = null
|
||||
if (doc.fileUri.isNullOrEmpty()) {
|
||||
if (!doc.filePath.isNullOrEmpty()) {
|
||||
val file = File(doc.filePath)
|
||||
uri = file.toUri()
|
||||
fileName = file.name
|
||||
}
|
||||
} else {
|
||||
uri = doc.fileUri.toUri()
|
||||
}
|
||||
if (uri != null) {
|
||||
RecentDocumentUiState(
|
||||
fileUri = uri,
|
||||
fileName = fileName,
|
||||
saveTimestamp = doc.createdAt,
|
||||
pageCount = doc.pageCount,
|
||||
)
|
||||
} else null
|
||||
}.filter { item -> uriExists(appContext, item.fileUri) }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
private fun uriExists(context: Context, uri: Uri): Boolean {
|
||||
return if (uri.scheme == "file") {
|
||||
File(uri.path.orEmpty()).exists()
|
||||
} else {
|
||||
try {
|
||||
DocumentFile.fromSingleUri(context, uri)?.exists() == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.data.OcrLanguage
|
||||
import org.fairscan.app.ui.components.BackButton
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
enum class LanguageState {
|
||||
ACTIVE,
|
||||
INACTIVE,
|
||||
NOT_INSTALLED,
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OcrLanguagesScreen(
|
||||
uiState: SettingsUiState,
|
||||
onBack: () -> Unit,
|
||||
onLanguageClick: (String) -> Unit,
|
||||
onRemoveLanguage: (String) -> Unit,
|
||||
onCancelOcrDownload: () -> Unit,
|
||||
) {
|
||||
BackHandler { onBack() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings_ocr_languages)) },
|
||||
navigationIcon = { BackButton(onBack) },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
OcrLanguagesContent(
|
||||
uiState = uiState,
|
||||
onLanguageClick = onLanguageClick,
|
||||
onCancelOcrDownload = onCancelOcrDownload,
|
||||
onRemoveLanguage = onRemoveLanguage,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OcrLanguagesContent(
|
||||
uiState: SettingsUiState,
|
||||
onLanguageClick: (String) -> Unit,
|
||||
onRemoveLanguage: (String) -> Unit,
|
||||
onCancelOcrDownload: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val locale = Locale.current.platformLocale
|
||||
|
||||
val installed = remember(
|
||||
uiState.installedOcrLanguages,
|
||||
uiState.enabledOcrLanguages,
|
||||
locale,
|
||||
) {
|
||||
uiState.installedOcrLanguages
|
||||
.map { OcrLanguage(it) }
|
||||
.sortedWith(compareBy { it.displayName(locale) })
|
||||
}
|
||||
|
||||
val available = remember(
|
||||
uiState.installedOcrLanguages,
|
||||
locale,
|
||||
) {
|
||||
OcrLanguage.AVAILABLE_LANGUAGE_CODES
|
||||
.filterNot { it in uiState.installedOcrLanguages }
|
||||
.map { OcrLanguage(it) }
|
||||
.sortedBy { it.displayName(locale) }
|
||||
}
|
||||
|
||||
val suggested = available.firstOrNull {
|
||||
it.locale.displayLanguage == locale.displayLanguage
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_ocr_download_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (installed.isNotEmpty()) {
|
||||
item {
|
||||
SectionTitle(
|
||||
stringResource(R.string.settings_ocr_languages_installed)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = installed,
|
||||
key = { it.code }
|
||||
) { lang ->
|
||||
LanguageItem(
|
||||
language = lang,
|
||||
state = if (lang.code in uiState.enabledOcrLanguages) {
|
||||
LanguageState.ACTIVE
|
||||
} else {
|
||||
LanguageState.INACTIVE
|
||||
},
|
||||
onClick = { onLanguageClick(lang.code) },
|
||||
onRemove = { onRemoveLanguage(lang.code) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (suggested != null) {
|
||||
item {
|
||||
SectionTitle(
|
||||
stringResource(R.string.settings_ocr_suggested)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
LanguageItem(
|
||||
language = suggested,
|
||||
state = LanguageState.NOT_INSTALLED,
|
||||
onClick = {
|
||||
onLanguageClick(suggested.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionTitle(
|
||||
stringResource(R.string.settings_ocr_languages_available)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
available.filter { it != suggested },
|
||||
key = { it.code }
|
||||
) { lang ->
|
||||
LanguageItem(
|
||||
language = lang,
|
||||
state = LanguageState.NOT_INSTALLED,
|
||||
onClick = {
|
||||
onLanguageClick(lang.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
uiState.currentDownload?.let { download ->
|
||||
OcrDownloadDialog(
|
||||
state = download,
|
||||
onCancel = onCancelOcrDownload,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageItem(
|
||||
language: OcrLanguage,
|
||||
state: LanguageState,
|
||||
onClick: () -> Unit,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val locale = Locale.current.platformLocale
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(language.displayName(locale))
|
||||
},
|
||||
leadingContent = {
|
||||
when (state) {
|
||||
LanguageState.ACTIVE ->
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
LanguageState.INACTIVE ->
|
||||
Icon(
|
||||
Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
LanguageState.NOT_INSTALLED ->
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (onRemove != null) {
|
||||
Box {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.delete)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onRemove()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(
|
||||
text: String,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 16.dp,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OcrDownloadDialog(
|
||||
state: OcrDownloadUiState,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {}, // tapping outside the dialog should not cancel
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.settings_ocr_downloading),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
if (state.failed) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_ocr_download_error),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
Text(state.language.displayName(Locale.current.platformLocale))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val progress =
|
||||
state.totalBytes?.let { total ->
|
||||
state.downloadedBytes.toFloat() / total
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
LinearProgressIndicator(progress = { progress }, Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
buildProgressText(
|
||||
state.downloadedBytes,
|
||||
state.totalBytes,
|
||||
LocalContext.current
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(stringResource(if (state.failed) R.string.close else R.string.cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildProgressText(
|
||||
downloadedBytes: Long,
|
||||
totalBytes: Long?,
|
||||
context: Context,
|
||||
): String {
|
||||
return if (totalBytes != null) {
|
||||
listOf(
|
||||
Formatter.formatShortFileSize(context, downloadedBytes),
|
||||
Formatter.formatShortFileSize(context, totalBytes),
|
||||
).joinToString(" / ")
|
||||
} else {
|
||||
Formatter.formatShortFileSize(context, downloadedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreviewBasic() {
|
||||
OcrLanguagesScreenPreview(
|
||||
SettingsUiState(
|
||||
installedOcrLanguages = setOf("fra", "deu"),
|
||||
enabledOcrLanguages = setOf("fra")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreviewWithDownloadDialog() {
|
||||
OcrLanguagesScreenPreview(
|
||||
SettingsUiState(currentDownload =
|
||||
OcrDownloadUiState(OcrLanguage("eng"), 500_000, 1_200_000)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreviewWithDownloadError() {
|
||||
OcrLanguagesScreenPreview(
|
||||
SettingsUiState(currentDownload =
|
||||
OcrDownloadUiState(OcrLanguage("eng"), failed = true)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OcrLanguagesScreenPreview(uiState: SettingsUiState) {
|
||||
FairScanTheme {
|
||||
OcrLanguagesScreen(uiState, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -15,28 +15,49 @@
|
||||
package org.fairscan.app.ui.screens.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings")
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
class SettingsRepository(
|
||||
private val context: Context,
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
private val DEFAULT_COLOR_MODE = stringPreferencesKey("default_color_mode")
|
||||
private val EXPORT_DIR_URI = stringPreferencesKey("export_dir_uri")
|
||||
private val EXPORT_FORMAT = stringPreferencesKey("export_format")
|
||||
private val EXPORT_QUALITY = stringPreferencesKey("export_quality")
|
||||
|
||||
val defaultColorMode: Flow<DefaultColorMode> =
|
||||
dataStore.data.map { prefs ->
|
||||
when (prefs[DEFAULT_COLOR_MODE]) {
|
||||
"AUTO" -> DefaultColorMode.AUTO
|
||||
"COLOR" -> DefaultColorMode.COLOR
|
||||
"GRAYSCALE" -> DefaultColorMode.GRAYSCALE
|
||||
else -> DefaultColorMode.AUTO
|
||||
}
|
||||
}
|
||||
|
||||
val exportDirUri: Flow<String?> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
dataStore.data.map { prefs ->
|
||||
prefs[EXPORT_DIR_URI]
|
||||
}
|
||||
|
||||
fun resolveExportDirName(uri: String): String? {
|
||||
return DocumentFile.fromTreeUri(context, uri.toUri())?.name
|
||||
}
|
||||
|
||||
val exportFormat: Flow<ExportFormat> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
dataStore.data.map { prefs ->
|
||||
when (prefs[EXPORT_FORMAT]) {
|
||||
"JPEG" -> ExportFormat.JPEG
|
||||
"PDF", null -> ExportFormat.PDF
|
||||
@@ -45,7 +66,7 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
val exportQuality: Flow<ExportQuality> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
dataStore.data.map { prefs ->
|
||||
when (prefs[EXPORT_QUALITY]) {
|
||||
"LOW" -> ExportQuality.LOW
|
||||
"HIGH" -> ExportQuality.HIGH
|
||||
@@ -54,8 +75,14 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDefaultColorMode(mode: DefaultColorMode) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[DEFAULT_COLOR_MODE] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setExportDirUri(uri: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
dataStore.edit { prefs ->
|
||||
if (uri == null) {
|
||||
prefs.remove(EXPORT_DIR_URI)
|
||||
} else {
|
||||
@@ -65,18 +92,24 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun setExportFormat(format: ExportFormat) {
|
||||
context.dataStore.edit { prefs ->
|
||||
dataStore.edit { prefs ->
|
||||
prefs[EXPORT_FORMAT] = format.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setExportQuality(quality: ExportQuality) {
|
||||
context.dataStore.edit { prefs ->
|
||||
dataStore.edit { prefs ->
|
||||
prefs[EXPORT_QUALITY] = quality.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class DefaultColorMode(val colorMode: ColorMode?, val labelResource: Int) {
|
||||
AUTO(null, R.string.color_mode_auto),
|
||||
COLOR(ColorMode.COLOR, R.string.color_mode_color),
|
||||
GRAYSCALE(ColorMode.GRAYSCALE, R.string.color_mode_grayscale),
|
||||
}
|
||||
|
||||
enum class ExportFormat(val mimeType: String) {
|
||||
PDF("application/pdf"),
|
||||
JPEG("image/jpeg"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -14,8 +14,6 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.screens.settings
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -30,142 +28,276 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
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.OcrLanguage
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.BackButton
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
uiState: SettingsUiState,
|
||||
onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
|
||||
onChooseDirectoryClick: () -> Unit,
|
||||
onResetExportDirClick: () -> Unit,
|
||||
onExportFormatChanged: (ExportFormat) -> Unit,
|
||||
onExportQualityChanged: (ExportQuality) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navigation: Navigation,
|
||||
) {
|
||||
BackHandler { onBack() }
|
||||
BackHandler { navigation.back() }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings)) },
|
||||
navigationIcon = { BackButton(onBack) },
|
||||
navigationIcon = { BackButton(navigation.back) },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
SettingsContent(
|
||||
uiState,
|
||||
onDefaultColorModeChanged,
|
||||
onChooseDirectoryClick,
|
||||
onResetExportDirClick,
|
||||
onExportFormatChanged,
|
||||
onExportQualityChanged,
|
||||
modifier = Modifier.padding(paddingValues))
|
||||
navigation,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsContent(
|
||||
uiState: SettingsUiState,
|
||||
onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
|
||||
onChooseDirectoryClick: () -> Unit,
|
||||
onResetExportDirClick: () -> Unit,
|
||||
onExportFormatChanged: (ExportFormat) -> Unit,
|
||||
onExportQualityChanged: (ExportQuality) -> Unit,
|
||||
navigation: Navigation,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val folderName = remember(uiState.exportDirUri) {
|
||||
extractFolderName(uiState.exportDirUri, context)
|
||||
val displayLocale = Locale.current.platformLocale
|
||||
val export = uiState.export
|
||||
val (folderLabel, folderLabelColor) = when {
|
||||
export.dirUri == null ->
|
||||
stringResource(R.string.download_dirname) to
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
|
||||
export.dirName != null ->
|
||||
export.dirName to
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
|
||||
else ->
|
||||
stringResource(R.string.export_folder_permission_lost) to
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp)
|
||||
.padding(vertical = 8.dp, horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
DirectorySettingItem(
|
||||
label = stringResource(R.string.export_directory),
|
||||
folderName = folderName,
|
||||
onClick = onChooseDirectoryClick
|
||||
val context = LocalResources.current
|
||||
|
||||
Text(stringResource(R.string.settings_section_scan), style = MaterialTheme.typography.titleLarge)
|
||||
|
||||
SingleChoiceSetting(
|
||||
title = stringResource(R.string.color_mode_default),
|
||||
entries = DefaultColorMode.entries,
|
||||
onValueChanged = onDefaultColorModeChanged,
|
||||
label = { t -> context.getString(t.labelResource) },
|
||||
selectedValue = uiState.defaultColorMode,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (uiState.exportDirUri != null) {
|
||||
OutlinedButton(
|
||||
Text(stringResource(R.string.settings_section_export), style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
DirectorySettingItem(
|
||||
label = stringResource(R.string.export_directory),
|
||||
folderLabel,
|
||||
folderLabelColor,
|
||||
onClick = onChooseDirectoryClick,
|
||||
)
|
||||
|
||||
if (export.dirUri != null) {
|
||||
TextButton(
|
||||
onClick = onResetExportDirClick,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.reset_to_default))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(stringResource(R.string.export_quality), style = MaterialTheme.typography.titleLarge)
|
||||
SingleChoiceSetting(
|
||||
title = stringResource(R.string.export_quality),
|
||||
entries = ExportQuality.entries.reversed(),
|
||||
selectedValue = export.quality,
|
||||
onValueChanged = onExportQualityChanged,
|
||||
label = { t -> context.getString(t.labelResource) },
|
||||
)
|
||||
|
||||
ExportQuality.entries.reversed().forEach { quality ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = uiState.exportQuality == quality,
|
||||
onClick = { onExportQualityChanged(quality) },
|
||||
SingleChoiceSetting(
|
||||
title = stringResource(R.string.export_format),
|
||||
entries = ExportFormat.entries,
|
||||
selectedValue = export.format,
|
||||
onValueChanged = onExportFormatChanged,
|
||||
label = { it.name },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(R.string.settings_section_ocr),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_ocr_languages)) },
|
||||
supportingContent = {
|
||||
Text(uiState.enabledOcrLanguages
|
||||
.map { OcrLanguage(it).displayName(displayLocale) }
|
||||
.sorted()
|
||||
.joinToString(" • ")
|
||||
.ifEmpty { stringResource(R.string.settings_ocr_languages_disabled) }
|
||||
)
|
||||
Text(stringResource(quality.labelResource))
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { navigation.toOcrLanguagesScreen() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
@Composable
|
||||
fun <T> SingleChoiceSetting(
|
||||
title: String,
|
||||
entries: List<T>,
|
||||
selectedValue: T,
|
||||
onValueChanged: (T) -> Unit,
|
||||
label: (T) -> String,
|
||||
) {
|
||||
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Text(stringResource(R.string.export_format), style = MaterialTheme.typography.titleLarge)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = uiState.exportFormat == ExportFormat.PDF,
|
||||
onClick = { onExportFormatChanged(ExportFormat.PDF) },
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(title)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(label(selectedValue))
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null
|
||||
)
|
||||
Text("PDF")
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
showDialog = true
|
||||
}
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = uiState.exportFormat == ExportFormat.JPEG,
|
||||
onClick = { onExportFormatChanged(ExportFormat.JPEG) },
|
||||
)
|
||||
Text("JPEG")
|
||||
}
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showDialog = false
|
||||
},
|
||||
title = {
|
||||
Text(title)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
entries.forEach { entry ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onValueChanged(entry)
|
||||
showDialog = false
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedValue == entry,
|
||||
onClick = {
|
||||
onValueChanged(entry)
|
||||
showDialog = false
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label(entry),
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DirectorySettingItem(
|
||||
label: String,
|
||||
folderName: String,
|
||||
folderLabel: String,
|
||||
folderLabelColor: Color,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
|
||||
) {
|
||||
Column (
|
||||
modifier = Modifier.padding(vertical = 0.dp, horizontal = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -174,7 +306,9 @@ fun DirectorySettingItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
|
||||
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -184,8 +318,9 @@ fun DirectorySettingItem(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = folderName,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
text = folderLabel,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = folderLabelColor,
|
||||
)
|
||||
|
||||
Icon(
|
||||
@@ -197,24 +332,22 @@ fun DirectorySettingItem(
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractFolderName(uriString: String?, context: Context): String {
|
||||
if (uriString == null) return context.getString(R.string.download_dirname)
|
||||
return runCatching {
|
||||
val uri = uriString.toUri()
|
||||
uri.lastPathSegment?.substringAfter(':')?.substringAfter('/') ?: uriString
|
||||
}.getOrElse { uriString }
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(heightDp = 780)
|
||||
@Composable
|
||||
fun SettingsScreenPreviewWithoutDir() {
|
||||
SettingsScreenPreview(SettingsUiState(null))
|
||||
SettingsScreenPreview(SettingsUiState(
|
||||
installedOcrLanguages = setOf("fra", "eng", "deu"),
|
||||
enabledOcrLanguages = setOf("fra", "eng")
|
||||
))
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SettingsScreenPreviewWithDir() {
|
||||
SettingsScreenPreview(SettingsUiState("content://root/dir"))
|
||||
SettingsScreenPreview(
|
||||
SettingsUiState(export= ExportSettingsUiState(dirUri = "content://root/dir"))
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -222,11 +355,12 @@ fun SettingsScreenPreview(uiState: SettingsUiState) {
|
||||
FairScanTheme {
|
||||
SettingsScreen(
|
||||
uiState,
|
||||
onDefaultColorModeChanged = {},
|
||||
onChooseDirectoryClick = {},
|
||||
onResetExportDirClick = {},
|
||||
onExportFormatChanged = {},
|
||||
onExportQualityChanged = {},
|
||||
onBack = {}
|
||||
navigation = dummyNavigation(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,30 +16,77 @@ package org.fairscan.app.ui.screens.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.data.OcrLanguage
|
||||
import org.fairscan.app.domain.ExportQuality
|
||||
|
||||
data class SettingsUiState(
|
||||
val exportDirUri: String? = null,
|
||||
val exportFormat: ExportFormat = ExportFormat.PDF,
|
||||
val exportQuality: ExportQuality = ExportQuality.BALANCED,
|
||||
val defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO,
|
||||
val export: ExportSettingsUiState = ExportSettingsUiState(),
|
||||
val installedOcrLanguages: Set<String> = emptySet(),
|
||||
val enabledOcrLanguages: Set<String> = emptySet(),
|
||||
val currentDownload: OcrDownloadUiState? = null
|
||||
)
|
||||
|
||||
data class ExportSettingsUiState(
|
||||
val dirUri: String? = null,
|
||||
val dirName: String? = null,
|
||||
val format: ExportFormat = ExportFormat.PDF,
|
||||
val quality: ExportQuality = ExportQuality.BALANCED,
|
||||
)
|
||||
|
||||
data class OcrDownloadUiState(
|
||||
val language: OcrLanguage,
|
||||
val downloadedBytes: Long = 0,
|
||||
val totalBytes: Long? = null,
|
||||
val failed: Boolean = false,
|
||||
)
|
||||
|
||||
class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
|
||||
private val repo = container.settingsRepository
|
||||
private val ocrLanguageRepo = container.ocrLanguageRepository
|
||||
private val logger = container.logger
|
||||
|
||||
private val _installedLanguages = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val _ocrDownload = MutableStateFlow<OcrDownloadUiState?>(null)
|
||||
private var downloadJob: Job? = null
|
||||
|
||||
private val _dirName = MutableStateFlow<String?>(null)
|
||||
val dirName: StateFlow<String?> = _dirName
|
||||
|
||||
private val exportSettingsState =
|
||||
combine(
|
||||
repo.exportDirUri,
|
||||
dirName,
|
||||
repo.exportFormat,
|
||||
repo.exportQuality,
|
||||
) {
|
||||
dirUri, dirName, format, quality ->
|
||||
ExportSettingsUiState(dirUri, dirName, format, quality)
|
||||
}
|
||||
val uiState = combine(
|
||||
repo.exportDirUri,
|
||||
repo.exportFormat,
|
||||
repo.exportQuality,
|
||||
) { dir, format, quality ->
|
||||
repo.defaultColorMode,
|
||||
exportSettingsState,
|
||||
_installedLanguages,
|
||||
ocrLanguageRepo.enabledLanguages,
|
||||
_ocrDownload,
|
||||
) { colorMode, exportSettings, installed, enabled, download ->
|
||||
SettingsUiState(
|
||||
exportDirUri = dir,
|
||||
exportFormat = format,
|
||||
exportQuality = quality,
|
||||
defaultColorMode = colorMode,
|
||||
export = exportSettings,
|
||||
installedOcrLanguages = installed,
|
||||
enabledOcrLanguages = enabled,
|
||||
currentDownload = download,
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
@@ -47,9 +94,27 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
SettingsUiState()
|
||||
)
|
||||
|
||||
private suspend fun refreshInstalledLanguages() {
|
||||
_installedLanguages.value =
|
||||
ocrLanguageRepo.getInstalledLanguages()
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
refreshInstalledLanguages()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultColorMode(pref: DefaultColorMode) {
|
||||
viewModelScope.launch {
|
||||
repo.setDefaultColorMode(pref)
|
||||
}
|
||||
}
|
||||
|
||||
fun setExportDirUri(uri: String?) {
|
||||
viewModelScope.launch {
|
||||
repo.setExportDirUri(uri)
|
||||
refreshExportDirName()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,4 +129,61 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
repo.setExportQuality(quality)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshExportDirName() {
|
||||
viewModelScope.launch {
|
||||
val uri = repo.exportDirUri.first()
|
||||
_dirName.value = uri?.let { repo.resolveExportDirName(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onLanguageClick(code: String) {
|
||||
if (uiState.value.installedOcrLanguages.contains(code)) {
|
||||
setOcrLanguageEnabled(code, !uiState.value.enabledOcrLanguages.contains(code))
|
||||
} else {
|
||||
installLanguage(code)
|
||||
}
|
||||
}
|
||||
|
||||
fun installLanguage(code: String) {
|
||||
downloadJob?.cancel()
|
||||
downloadJob = viewModelScope.launch {
|
||||
_ocrDownload.value = OcrDownloadUiState(OcrLanguage(code))
|
||||
try {
|
||||
ocrLanguageRepo.downloadLanguage(code) { progress ->
|
||||
_ocrDownload.value =
|
||||
_ocrDownload.value?.copy(
|
||||
downloadedBytes = progress.downloadedBytes,
|
||||
totalBytes = progress.totalBytes,
|
||||
)
|
||||
}
|
||||
ocrLanguageRepo.setLanguageEnabled(code, true)
|
||||
refreshInstalledLanguages()
|
||||
_ocrDownload.value = null
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("OcrDownload", "Download failed for $code", e)
|
||||
_ocrDownload.value = _ocrDownload.value?.copy(failed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelOcrDownload() {
|
||||
downloadJob?.cancel()
|
||||
_ocrDownload.value = null
|
||||
}
|
||||
|
||||
fun setOcrLanguageEnabled(code: String, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
ocrLanguageRepo.setLanguageEnabled(code, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun onRemoveLanguage(code: String) {
|
||||
viewModelScope.launch {
|
||||
ocrLanguageRepo.deleteLanguage(code)
|
||||
refreshInstalledLanguages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
@@ -16,29 +16,28 @@ package org.fairscan.app.ui.state
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
|
||||
data class DocumentUiModel(
|
||||
val pageKeys: ImmutableList<PageViewKey>,
|
||||
private val imageLoader: (PageViewKey) -> Bitmap?,
|
||||
private val thumbnailLoader: (PageViewKey) -> Bitmap?
|
||||
val pages: ImmutableList<PageThumbnail> = persistentListOf(),
|
||||
) {
|
||||
fun pageCount(): Int {
|
||||
return pageKeys.size
|
||||
}
|
||||
fun pageId(index: Int): String {
|
||||
return pageKeys[index].pageId
|
||||
return pages.size
|
||||
}
|
||||
fun isEmpty(): Boolean {
|
||||
return pageKeys.isEmpty()
|
||||
return pages.isEmpty()
|
||||
}
|
||||
fun lastIndex(): Int {
|
||||
return pageKeys.lastIndex
|
||||
return pages.lastIndex
|
||||
}
|
||||
fun load(index: Int): Bitmap? {
|
||||
return imageLoader(pageKeys[index])
|
||||
fun thumbnail(index: Int): Bitmap? {
|
||||
return pages.getOrNull(index)?.thumbnail?.toBitmap()
|
||||
}
|
||||
fun loadThumbnail(index: Int): Bitmap? {
|
||||
return thumbnailLoader(pageKeys[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PageThumbnail(
|
||||
val key: PageViewKey,
|
||||
val thumbnail: Jpeg?,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Pierre-Yves Nicolas
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option java_package = "org.fairscan.app";
|
||||
option java_multiple_files = true;
|
||||
|
||||
message RecentDocument {
|
||||
string file_path = 1;
|
||||
int64 created_at = 2; // timestamp in ms
|
||||
int32 page_count = 3;
|
||||
string file_uri = 4;
|
||||
string file_name = 5;
|
||||
}
|
||||
|
||||
message RecentDocuments {
|
||||
repeated RecentDocument documents = 1;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<resources>
|
||||
<string name="about">عن</string>
|
||||
<string name="add_page">أضف صفحة</string>
|
||||
<string name="app_tagline">تطبيق بسيط ومحترم لمسح مستنداتك ضوئيًا.</string>
|
||||
<string name="apply">تطبيق</string>
|
||||
<string name="back">ارجع</string>
|
||||
<string name="camera_permission_denied">رُفض إذن الوصول إلى الكاميرا</string>
|
||||
<string name="camera_permission_rationale">يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المسح الحالي.</string>
|
||||
<string name="cancel">ألغِ</string>
|
||||
<string name="change_directory">غيّر المجلد</string>
|
||||
<string name="clear_text">امحُ النص</string>
|
||||
<string name="close">إغلاق</string>
|
||||
<string name="color_mode">فلتر</string>
|
||||
<string name="color_mode_auto">تلقائي</string>
|
||||
<string name="color_mode_color">ألوان</string>
|
||||
<string name="color_mode_default">الفلتر الافتراضي</string>
|
||||
<string name="color_mode_grayscale">تدرج الرمادي</string>
|
||||
<string name="contact">تواصل</string>
|
||||
<string name="copied_logs">نُسخت السجلات إلى الحافظة</string>
|
||||
<string name="copy_logs">انسخ السجلات</string>
|
||||
<string name="creating_export">يجهز التصدير…</string>
|
||||
<string name="crop">اقتصاص</string>
|
||||
<string name="delete">حذف</string>
|
||||
<string name="delete_page">احذف الصفحة</string>
|
||||
<string name="delete_page_warning">هل تريد حذف هذه الصفحة؟</string>
|
||||
<string name="developer">مطوّر</string>
|
||||
<string name="download_dirname">التنزيلات</string>
|
||||
<string name="error_context_folder">مجلد التصدير: %1$s</string>
|
||||
<string name="error_context_provider">المزود: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">لم يعد من الممكن الوصول إلى مجلد التصدير المحدّد. يُرجى اختيار مجلد آخر.</string>
|
||||
<string name="error_no_app">لم يُعثر على تطبيق لفتح هذا الملف</string>
|
||||
<string name="error_occurred">حدث خطأ</string>
|
||||
<string name="error_save">فشل حفظ الملف</string>
|
||||
<string name="export">صدِّر</string>
|
||||
<string name="export_as">صدِّر ك %1$s</string>
|
||||
<string name="export_directory">مجلد التصدير</string>
|
||||
<string name="export_folder_permission_lost">فُقدت صلاحية الوصول إلى المجلد</string>
|
||||
<string name="export_format">صيغة التصدير</string>
|
||||
<string name="export_quality">جودة تصدير</string>
|
||||
<string name="export_quality_low">منخفضة</string>
|
||||
<string name="export_quality_balanced">متوازنة</string>
|
||||
<string name="export_quality_high">عالية</string>
|
||||
<string name="file_size">حجم الملف: %1$s</string>
|
||||
<string name="file_size_total">الحجم الكلي: %1$s</string>
|
||||
<string name="filename">اسم الملف</string>
|
||||
<string name="grant_permission">امنح الأذن</string>
|
||||
<string name="import_photos">استيراد</string>
|
||||
<string name="libraries">المكتبات</string>
|
||||
<string name="libraries_open_source">مكتبات مفتوحة المصدر</string>
|
||||
<string name="license">الترخيص</string>
|
||||
<string name="licensed_under">هذا التطبيق مرخص بموجب رخصة جنو العمومية العامة، الإصدار 3.0.</string>
|
||||
<string name="menu">قائمة</string>
|
||||
<string name="new_document_warning">سيتم فقدان عملية المسح الحالية. أتريد الاستمرار؟</string>
|
||||
<string name="open">افتح</string>
|
||||
<string name="open_file">افتح الملف</string>
|
||||
<string name="reset_to_default">صفّر للإعدادات المبدئية</string>
|
||||
<string name="rotate_left">تدوير إلى اليسار</string>
|
||||
<string name="rotate_right">تدوير إلى اليمين</string>
|
||||
<string name="save">احفظ</string>
|
||||
<string name="scan_new">مسح جديد</string>
|
||||
<string name="scan_current">المسح الحالي</string>
|
||||
<string name="scan_discard_current">تجاهل المسح الحالي</string>
|
||||
<string name="scan_resume">متابعة المسح</string>
|
||||
<string name="settings">الإعدادات</string>
|
||||
<string name="settings_ocr_download_error">فشل التنزيل. تحقق من اتصال الإنترنت وتأكد من السماح لهذا التطبيق بالوصول إلى الشبكة.</string>
|
||||
<string name="settings_ocr_download_intro">يتم تنزيل ملفات اللغة من مشروع Tesseract الرسمي. تبقى عملية التعرّف على النصوص دون اتصال بالإنترنت بالكامل.</string>
|
||||
<string name="settings_ocr_downloading">جارٍ تنزيل ملف اللغة</string>
|
||||
<string name="settings_ocr_languages">اللغات</string>
|
||||
<string name="settings_ocr_languages_available">اللغات المتاحة</string>
|
||||
<string name="settings_ocr_languages_disabled">معطّل</string>
|
||||
<string name="settings_ocr_languages_installed">مثبّتة</string>
|
||||
<string name="settings_ocr_suggested">مقترح</string>
|
||||
<string name="settings_section_ocr">التعرّف على النصوص</string>
|
||||
<string name="settings_section_scan">المسح</string>
|
||||
<string name="settings_section_export">التصدير</string>
|
||||
<string name="share">شارك</string>
|
||||
<string name="share_document">شارك المستند</string>
|
||||
<string name="storage_permission_denied">تعذر حفظ الملف: رُفض الإذن</string>
|
||||
<string name="support">المساعدة</string>
|
||||
<string name="support_last_image">أبلِغ عن مشكلة في آخر صورة مُلتقطَة </string>
|
||||
<string name="text_recognition_disabled">التعرّف على النصوص: معطّل</string>
|
||||
<string name="text_recognition_enabled">التعرّف على النصوص: مفعّل</string>
|
||||
<string name="turn_off_torch">أطفئ الكشاف</string>
|
||||
<string name="turn_on_torch">شغّل الكشاف</string>
|
||||
<string name="unknown_size">حجم مجهول</string>
|
||||
<string name="version">الإصدار</string>
|
||||
<string name="view_full_list">اعرض القائمة الكاملة</string>
|
||||
<string name="view_the_full_license">اعرض الترخيص الكامل</string>
|
||||
<string name="yes">نعم</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="zero">لم يُحفظ أي ملفات في %3$s</item>
|
||||
<item quantity="one">حُفظ %2$s في %3$s</item>
|
||||
<item quantity="two">حُفظ ملفين في %3$s</item>
|
||||
<item quantity="few">حُفظ %1$d ملفات في %3$s</item>
|
||||
<item quantity="many">حُفظ %1$d ملفًا في %3$s</item>
|
||||
<item quantity="other">حُفظ %1$d ملف في %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="zero">جارٍ استيراد 0 صورة</item>
|
||||
<item quantity="one">جارٍ استيراد صورة واحدة</item>
|
||||
<item quantity="two">جارٍ استيراد صورتين</item>
|
||||
<item quantity="few">جارٍ استيراد %d صور</item>
|
||||
<item quantity="many">جارٍ استيراد %d صورة</item>
|
||||
<item quantity="other">جارٍ استيراد %d صورة</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="zero">لا صفحات</item>
|
||||
<item quantity="one">%d صفحة</item>
|
||||
<item quantity="two">%d صفحتان</item>
|
||||
<item quantity="few">%d صفحات</item>
|
||||
<item quantity="many">%d صفحة</item>
|
||||
<item quantity="other">%d صفحة</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -2,29 +2,39 @@
|
||||
<string name="about">O aplikaci</string>
|
||||
<string name="add_page">Přidat stránku</string>
|
||||
<string name="app_tagline">Jednoduchá a respektující aplikace pro skenování vašich dokumentů</string>
|
||||
<string name="apply">Použít</string>
|
||||
<string name="back">Zpět</string>
|
||||
<string name="camera_permission_denied">Byl odepřen přístup k fotoaparátu</string>
|
||||
<string name="camera_permission_rationale">Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když zavřete stávající dokument.</string>
|
||||
<string name="camera_permission_rationale">Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když ukončíte aktuální sken.</string>
|
||||
<string name="cancel">Zrušit</string>
|
||||
<string name="change_directory">Změnit složku</string>
|
||||
<string name="clear_text">Smazat text</string>
|
||||
<string name="close">Zavřít</string>
|
||||
<string name="color_mode">Filtr</string>
|
||||
<string name="color_mode_auto">Automaticky</string>
|
||||
<string name="color_mode_color">Barva</string>
|
||||
<string name="color_mode_default">Výchozí filtr</string>
|
||||
<string name="color_mode_grayscale">Odstíny šedi</string>
|
||||
<string name="contact">Kontakt</string>
|
||||
<string name="copied_logs">Protokoly zkopírovány do schránky</string>
|
||||
<string name="copy_logs">Kopírovat protokoly</string>
|
||||
<string name="creating_export">Příprava exportu…</string>
|
||||
<string name="crop">Oříznout</string>
|
||||
<string name="delete">Smazat</string>
|
||||
<string name="delete_page">Smazat stránku</string>
|
||||
<string name="delete_page_warning">Chcete smazat tuto stránku?</string>
|
||||
<string name="developer">Vývojář</string>
|
||||
<string name="discard_scan">Zrušit skenování</string>
|
||||
<string name="download_dirname">stažených</string>
|
||||
<string name="end_scan">Ukončit skenování</string>
|
||||
<string name="error">Chyba: %1$s</string>
|
||||
<string name="error_context_folder">Složka exportu: %1$s</string>
|
||||
<string name="error_context_provider">Poskytovatel: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Vybraná složka pro export již není dostupná. Vyberte prosím jinou složku.</string>
|
||||
<string name="error_no_app">Nebyla nalezena žádná aplikace pro otevření tohoto souboru</string>
|
||||
<string name="error_no_document">Nebyl rozpoznán žádná dokument</string>
|
||||
<string name="error_occurred">Došlo k chybě</string>
|
||||
<string name="error_save">Soubor se nepodařilo uložit</string>
|
||||
<string name="export">Exportovat</string>
|
||||
<string name="export_as">Exportovat jako %1$s</string>
|
||||
<string name="export_directory">Složka pro export</string>
|
||||
<string name="export_folder_permission_lost">Ztracený přístup ke složce</string>
|
||||
<string name="export_format">Formát exportu</string>
|
||||
<string name="export_quality">Kvalita exportu</string>
|
||||
<string name="export_quality_low">Nízká</string>
|
||||
@@ -34,7 +44,7 @@
|
||||
<string name="file_size_total">Celková velikost: %1$s</string>
|
||||
<string name="filename">Název souboru</string>
|
||||
<string name="grant_permission">Povolit přístup</string>
|
||||
<string name="last_saved_pdf_files">Poslední PDF uložené v tomto zařízení:</string>
|
||||
<string name="import_photos">Import</string>
|
||||
<string name="libraries">Kníhovny</string>
|
||||
<string name="libraries_open_source">Open-source knihovny</string>
|
||||
<string name="license">Licence</string>
|
||||
@@ -44,16 +54,32 @@
|
||||
<string name="open">Otevřít</string>
|
||||
<string name="open_file">Otevřít soubor</string>
|
||||
<string name="reset_to_default">Obnovit výchozí</string>
|
||||
<string name="resume">Obnovit</string>
|
||||
<string name="rotate_left">Otočit doleva</string>
|
||||
<string name="rotate_right">Otočit doprava</string>
|
||||
<string name="save">Uložit</string>
|
||||
<string name="scan_button">Nové skenování</string>
|
||||
<string name="scan_in_progress">Probíhá skenování</string>
|
||||
<string name="scan_new">Nové skenování</string>
|
||||
<string name="scan_current">Aktuální sken</string>
|
||||
<string name="scan_discard_current">Zahodit aktuální sken</string>
|
||||
<string name="scan_resume">Pokračovat ve skenování</string>
|
||||
<string name="settings">Nastavení</string>
|
||||
<string name="settings_ocr_download_error">Stahování se nezdařilo. Zkontrolujte připojení k internetu a ujistěte se, že má tato aplikace povolený přístup k síti.</string>
|
||||
<string name="settings_ocr_download_intro">Jazykové soubory jsou stahovány z oficiálního projektu Tesseract. Rozpoznávání textu zůstává zcela offline.</string>
|
||||
<string name="settings_ocr_downloading">Stahování jazykového souboru</string>
|
||||
<string name="settings_ocr_languages">Jazyky</string>
|
||||
<string name="settings_ocr_languages_available">Dostupné jazyky</string>
|
||||
<string name="settings_ocr_languages_disabled">Vypnuto</string>
|
||||
<string name="settings_ocr_languages_installed">Nainstalované</string>
|
||||
<string name="settings_ocr_suggested">Doporučeno</string>
|
||||
<string name="settings_section_ocr">Rozpoznávání textu</string>
|
||||
<string name="settings_section_scan">Skenování</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Sdílet</string>
|
||||
<string name="share_document">Sdílet dokument</string>
|
||||
<string name="storage_permission_denied">Nelze uložit soubor: oprávnění bylo odmítnuto</string>
|
||||
<string name="support">Podpora</string>
|
||||
<string name="support_last_image">Nahlásit problém s posledním zachyceným obrázkem</string>
|
||||
<string name="text_recognition_disabled">Rozpoznávání textu: vypnuto</string>
|
||||
<string name="text_recognition_enabled">Rozpoznávání textu: zapnuto</string>
|
||||
<string name="turn_off_torch">Vypnout svítilnu</string>
|
||||
<string name="turn_on_torch">Zapnout svítilnu</string>
|
||||
<string name="unknown_size">Neznámá velikost</string>
|
||||
@@ -67,6 +93,12 @@
|
||||
<item quantity="many">%1$d souborů uloženo do %3$s</item>
|
||||
<item quantity="other">%1$d souborů uloženo do %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Importování 1 fotografie</item>
|
||||
<item quantity="few">Importování %d fotografií</item>
|
||||
<item quantity="many">Importování %d fotografií</item>
|
||||
<item quantity="other">Importování %d fotografií</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d stránka</item> <!-- 1 -->
|
||||
<item quantity="few">%d stránky</item> <!-- 2–4 -->
|
||||
|
||||
@@ -2,29 +2,39 @@
|
||||
<string name="about">Über</string>
|
||||
<string name="add_page">Seite hinzufügen</string>
|
||||
<string name="app_tagline">Eine einfache und respektvolle App zum Scannen Ihrer Dokumente.</string>
|
||||
<string name="apply">Anwenden</string>
|
||||
<string name="back">Zurück</string>
|
||||
<string name="camera_permission_denied">Kamerazugriff wurde verweigert</string>
|
||||
<string name="camera_permission_rationale">Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Dokuments gelöscht.</string>
|
||||
<string name="camera_permission_rationale">Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Scans gelöscht.</string>
|
||||
<string name="cancel">Abbrechen</string>
|
||||
<string name="change_directory">Ordner ändern</string>
|
||||
<string name="clear_text">Text löschen</string>
|
||||
<string name="close">Schließen</string>
|
||||
<string name="color_mode">Filter</string>
|
||||
<string name="color_mode_auto">Automatisch</string>
|
||||
<string name="color_mode_color">Farbe</string>
|
||||
<string name="color_mode_default">Standardfilter</string>
|
||||
<string name="color_mode_grayscale">Graustufen</string>
|
||||
<string name="contact">Kontakt</string>
|
||||
<string name="copied_logs">Logs in die Zwischenablage kopiert</string>
|
||||
<string name="copy_logs">Logs kopieren</string>
|
||||
<string name="creating_export">Export wird vorbereitet…</string>
|
||||
<string name="crop">Zuschneiden</string>
|
||||
<string name="delete">Löschen</string>
|
||||
<string name="delete_page">Seite löschen</string>
|
||||
<string name="delete_page_warning">Möchten Sie diese Seite löschen?</string>
|
||||
<string name="developer">Entwickler</string>
|
||||
<string name="discard_scan">Löschen</string>
|
||||
<string name="download_dirname">Downloads</string>
|
||||
<string name="end_scan">Scan beenden</string>
|
||||
<string name="error">Fehler: %1$s</string>
|
||||
<string name="error_context_folder">Exportordner: %1$s</string>
|
||||
<string name="error_context_provider">Anbieter: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Der ausgewählte Exportordner ist nicht mehr zugänglich. Bitte wählen Sie einen anderen Ordner.</string>
|
||||
<string name="error_no_app">Keine App zum Öffnen dieser Datei gefunden</string>
|
||||
<string name="error_no_document">Kein Dokument erkannt</string>
|
||||
<string name="error_occurred">Ein Fehler ist aufgetreten</string>
|
||||
<string name="error_save">Datei konnte nicht gespeichert werden</string>
|
||||
<string name="export">Exportieren</string>
|
||||
<string name="export_as">Als %1$s exportieren</string>
|
||||
<string name="export_directory">Exportordner</string>
|
||||
<string name="export_folder_permission_lost">Zugriff auf Ordner verloren</string>
|
||||
<string name="export_format">Exportformat</string>
|
||||
<string name="export_quality">Exportqualität</string>
|
||||
<string name="export_quality_low">Niedrig</string>
|
||||
@@ -34,7 +44,7 @@
|
||||
<string name="file_size_total">Gesamtgröße: %1$s</string>
|
||||
<string name="filename">Dateiname</string>
|
||||
<string name="grant_permission">Berechtigung erteilen</string>
|
||||
<string name="last_saved_pdf_files">Zuletzt auf diesem Gerät gespeicherte PDFs:</string>
|
||||
<string name="import_photos">Importieren</string>
|
||||
<string name="libraries">Bibliotheken</string>
|
||||
<string name="libraries_open_source">Open-Source-Bibliotheken</string>
|
||||
<string name="license">Lizenz</string>
|
||||
@@ -44,16 +54,32 @@
|
||||
<string name="open">Öffnen</string>
|
||||
<string name="open_file">Datei öffnen</string>
|
||||
<string name="reset_to_default">Auf Standard zurücksetzen</string>
|
||||
<string name="resume">Fortsetzen</string>
|
||||
<string name="rotate_left">Nach links drehen</string>
|
||||
<string name="rotate_right">Nach rechts drehen</string>
|
||||
<string name="save">Speichern</string>
|
||||
<string name="scan_button">Neuer Scan</string>
|
||||
<string name="scan_in_progress">Scan läuft</string>
|
||||
<string name="scan_new">Neuer Scan</string>
|
||||
<string name="scan_current">Aktueller Scan</string>
|
||||
<string name="scan_discard_current">Aktuellen Scan verwerfen</string>
|
||||
<string name="scan_resume">Scan fortsetzen</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="settings_ocr_download_error">Download fehlgeschlagen. Überprüfen Sie Ihre Internetverbindung und stellen Sie sicher, dass der Netzwerkzugriff für diese App erlaubt ist.</string>
|
||||
<string name="settings_ocr_download_intro">Sprachdateien werden vom offiziellen Tesseract-Projekt heruntergeladen. Die Texterkennung bleibt vollständig offline.</string>
|
||||
<string name="settings_ocr_downloading">Sprachdatei wird heruntergeladen</string>
|
||||
<string name="settings_ocr_languages">Sprachen</string>
|
||||
<string name="settings_ocr_languages_available">Verfügbare Sprachen</string>
|
||||
<string name="settings_ocr_languages_disabled">Deaktiviert</string>
|
||||
<string name="settings_ocr_languages_installed">Installiert</string>
|
||||
<string name="settings_ocr_suggested">Empfohlen</string>
|
||||
<string name="settings_section_ocr">Texterkennung</string>
|
||||
<string name="settings_section_scan">Scan</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Teilen</string>
|
||||
<string name="share_document">Dokument teilen</string>
|
||||
<string name="storage_permission_denied">Datei kann nicht gespeichert werden: Berechtigung verweigert</string>
|
||||
<string name="support">Support</string>
|
||||
<string name="support_last_image">Problem mit dem zuletzt aufgenommenen Bild melden</string>
|
||||
<string name="text_recognition_disabled">Texterkennung: deaktiviert</string>
|
||||
<string name="text_recognition_enabled">Texterkennung: aktiviert</string>
|
||||
<string name="turn_off_torch">Taschenlampe ausschalten</string>
|
||||
<string name="turn_on_torch">Taschenlampe einschalten</string>
|
||||
<string name="unknown_size">Unbekannte Größe</string>
|
||||
@@ -65,6 +91,10 @@
|
||||
<item quantity="one">%2$s gespeichert in %3$s</item>
|
||||
<item quantity="other">%1$d Dateien gespeichert in %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">1 Foto wird importiert</item>
|
||||
<item quantity="other">%d Fotos werden importiert</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d Seite</item>
|
||||
<item quantity="other">%d Seiten</item>
|
||||
|
||||
@@ -2,29 +2,39 @@
|
||||
<string name="about">Acerca de</string>
|
||||
<string name="add_page">Agregar página</string>
|
||||
<string name="app_tagline">Una aplicación sencilla y respetuosa para escanear tus documentos.</string>
|
||||
<string name="apply">Aplicar</string>
|
||||
<string name="back">Atrás</string>
|
||||
<string name="camera_permission_denied">Permiso de cámara denegado</string>
|
||||
<string name="camera_permission_rationale">La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el documento actual.</string>
|
||||
<string name="camera_permission_rationale">La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el escaneo actual.</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Cambiar carpeta</string>
|
||||
<string name="clear_text">Borrar texto</string>
|
||||
<string name="close">Cerrar</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automático</string>
|
||||
<string name="color_mode_color">Color</string>
|
||||
<string name="color_mode_default">Filtro predeterminado</string>
|
||||
<string name="color_mode_grayscale">Escala de grises</string>
|
||||
<string name="contact">Contacto</string>
|
||||
<string name="copied_logs">Registros copiados al portapapeles</string>
|
||||
<string name="copy_logs">Copiar registros</string>
|
||||
<string name="creating_export">Preparando la exportación…</string>
|
||||
<string name="crop">Recortar</string>
|
||||
<string name="delete">Eliminar</string>
|
||||
<string name="delete_page">Eliminar página</string>
|
||||
<string name="delete_page_warning">¿Quieres eliminar esta página?</string>
|
||||
<string name="developer">Desarrollador</string>
|
||||
<string name="discard_scan">Descartar escaneo</string>
|
||||
<string name="download_dirname">Descargas</string>
|
||||
<string name="end_scan">Finalizar escaneo</string>
|
||||
<string name="error">Error: %1$s</string>
|
||||
<string name="error_context_folder">Carpeta de exportación: %1$s</string>
|
||||
<string name="error_context_provider">Proveedor: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">La carpeta de exportación seleccionada ya no es accesible. Por favor, elija otra carpeta.</string>
|
||||
<string name="error_no_app">No se encontró ninguna aplicación para abrir este archivo</string>
|
||||
<string name="error_no_document">No se detectó ningún documento</string>
|
||||
<string name="error_occurred">Se produjo un error</string>
|
||||
<string name="error_save">No se pudo guardar el archivo</string>
|
||||
<string name="export">Exportar</string>
|
||||
<string name="export_as">Exportar como %1$s</string>
|
||||
<string name="export_directory">Carpeta de exportación</string>
|
||||
<string name="export_folder_permission_lost">Acceso a la carpeta perdido</string>
|
||||
<string name="export_format">Formato de exportación</string>
|
||||
<string name="export_quality">Calidad de exportación</string>
|
||||
<string name="export_quality_low">Baja</string>
|
||||
@@ -34,7 +44,7 @@
|
||||
<string name="file_size_total">Tamaño total: %1$s</string>
|
||||
<string name="filename">Nombre del archivo</string>
|
||||
<string name="grant_permission">Conceder permiso</string>
|
||||
<string name="last_saved_pdf_files">PDF recientes guardados en este dispositivo:</string>
|
||||
<string name="import_photos">Importar</string>
|
||||
<string name="libraries">Bibliotecas</string>
|
||||
<string name="libraries_open_source">Bibliotecas de código abierto</string>
|
||||
<string name="license">Licencia</string>
|
||||
@@ -44,16 +54,32 @@
|
||||
<string name="open">Abrir</string>
|
||||
<string name="open_file">Abrir archivo</string>
|
||||
<string name="reset_to_default">Restablecer valores predeterminados</string>
|
||||
<string name="resume">Reanudar</string>
|
||||
<string name="rotate_left">Girar a la izquierda</string>
|
||||
<string name="rotate_right">Girar a la derecha</string>
|
||||
<string name="save">Guardar</string>
|
||||
<string name="scan_button">Nuevo escaneo</string>
|
||||
<string name="scan_in_progress">Escaneo en curso</string>
|
||||
<string name="scan_new">Nuevo escaneo</string>
|
||||
<string name="scan_current">Escaneo actual</string>
|
||||
<string name="scan_discard_current">Descartar el escaneo actual</string>
|
||||
<string name="scan_resume">Reanudar escaneo</string>
|
||||
<string name="settings">Ajustes</string>
|
||||
<string name="settings_ocr_download_error">Descarga fallida. Comprueba tu conexión a Internet y asegúrate de que esta aplicación tenga permitido el acceso a la red.</string>
|
||||
<string name="settings_ocr_download_intro">Los archivos de idioma se descargan desde el proyecto oficial Tesseract. El reconocimiento de texto sigue siendo completamente local.</string>
|
||||
<string name="settings_ocr_downloading">Descargando archivo de idioma</string>
|
||||
<string name="settings_ocr_languages">Idiomas</string>
|
||||
<string name="settings_ocr_languages_available">Idiomas disponibles</string>
|
||||
<string name="settings_ocr_languages_disabled">Desactivado</string>
|
||||
<string name="settings_ocr_languages_installed">Instalados</string>
|
||||
<string name="settings_ocr_suggested">Sugerido</string>
|
||||
<string name="settings_section_ocr">Reconocimiento de texto</string>
|
||||
<string name="settings_section_scan">Escaneo</string>
|
||||
<string name="settings_section_export">Exportación</string>
|
||||
<string name="share">Compartir</string>
|
||||
<string name="share_document">Compartir documento</string>
|
||||
<string name="storage_permission_denied">No se puede guardar el archivo: permiso denegado</string>
|
||||
<string name="support">Soporte</string>
|
||||
<string name="support_last_image">Informar de un problema con la última imagen capturada</string>
|
||||
<string name="text_recognition_disabled">Reconocimiento de texto: desactivado</string>
|
||||
<string name="text_recognition_enabled">Reconocimiento de texto: activado</string>
|
||||
<string name="turn_off_torch">Apagar linterna</string>
|
||||
<string name="turn_on_torch">Encender linterna</string>
|
||||
<string name="unknown_size">Tamaño desconocido</string>
|
||||
@@ -65,6 +91,10 @@
|
||||
<item quantity="one">%2$s guardado en %3$s</item>
|
||||
<item quantity="other">%1$d archivos guardados en %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">Importando 1 foto</item>
|
||||
<item quantity="other">Importando %d fotos</item>
|
||||
</plurals>
|
||||
<plurals name="page_count" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">%d página</item>
|
||||
<item quantity="other">%d páginas</item>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about">Rakenduse teave</string>
|
||||
<string name="add_page">Lisa leht</string>
|
||||
<string name="app_tagline">Lihtsaltkasutatav ja viisakas rakendus dokumentide skaneerimiseks.</string>
|
||||
<string name="apply">Rakenda</string>
|
||||
<string name="back">Tagasi</string>
|
||||
<string name="camera_permission_denied">Kaamera kasutamise õigused on puudu</string>
|
||||
<string name="camera_permission_rationale">Et dokumentide skaneerimine toimiks, vajab see rakendus õigust kasutada kaamerat. Pildihõive käigus jäädvustatud pildid salvestuvad vaid siin selles seadmes ja kustutatakse töösoleva skaneerimise lõppedes.</string>
|
||||
<string name="cancel">Katkesta</string>
|
||||
<string name="change_directory">Muuda kausta</string>
|
||||
<string name="clear_text">Eemalda tekst</string>
|
||||
<string name="close">Sulge</string>
|
||||
<string name="color_mode">Filtreeri</string>
|
||||
<string name="color_mode_auto">Automaatne</string>
|
||||
<string name="color_mode_color">Värvid</string>
|
||||
<string name="color_mode_default">Vaikimisi filter</string>
|
||||
<string name="color_mode_grayscale">Halltoonides</string>
|
||||
<string name="contact">Kontakt</string>
|
||||
<string name="copied_logs">Logid on kopeeritud lõikelauale</string>
|
||||
<string name="copy_logs">Kopeeri logid</string>
|
||||
<string name="creating_export">Valmistun eksportima…</string>
|
||||
<string name="crop">Kadreeri</string>
|
||||
<string name="delete">Kustuta</string>
|
||||
<string name="delete_page">Lehe kustutamine</string>
|
||||
<string name="delete_page_warning">Kas kindlasti soovid selle lehe kustutada?</string>
|
||||
<string name="developer">Arendaja</string>
|
||||
<string name="download_dirname">Allalaadimised</string>
|
||||
<string name="error_context_folder">Ekspordikaust: %1$s</string>
|
||||
<string name="error_context_provider">Teenusepakkuja: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Valitud ekspordikaust pole enam kättesaadav. Palun vali mõnu muu kaust.</string>
|
||||
<string name="error_no_app">Salle faili avamiseks ei leidu rakendust</string>
|
||||
<string name="error_occurred">Tekkis viga</string>
|
||||
<string name="error_save">Faili salvestamine ei õnnestunud</string>
|
||||
<string name="export">Ekspordi</string>
|
||||
<string name="export_as">Eksportimise vorming: %1$s</string>
|
||||
<string name="export_directory">Ekspordikaust</string>
|
||||
<string name="export_folder_permission_lost">Ligipääs kaustale on kadunud</string>
|
||||
<string name="export_format">Ekspordivorming</string>
|
||||
<string name="export_quality">Ekspordi kvaliteet</string>
|
||||
<string name="export_quality_low">Madal</string>
|
||||
<string name="export_quality_balanced">Tasakaalustatud</string>
|
||||
<string name="export_quality_high">Kõrge</string>
|
||||
<string name="file_size">Faili suurus: %1$s</string>
|
||||
<string name="file_size_total">Maht kokku: %1$s</string>
|
||||
<string name="filename">Failinimi</string>
|
||||
<string name="grant_permission">Anna vastavad õigused</string>
|
||||
<string name="import_photos">Impordi</string>
|
||||
<string name="libraries">Teegid</string>
|
||||
<string name="libraries_open_source">Avatud lähtekoodil põhinevad teegid</string>
|
||||
<string name="license">Litsents</string>
|
||||
<string name="menu">Menüü</string>
|
||||
<string name="new_document_warning">Hetkel skaneeritud sisu läheb kaotsi. Kas kindlasti tahad jätkata?</string>
|
||||
<string name="open">Ava</string>
|
||||
<string name="open_file">Ava fail</string>
|
||||
<string name="reset_to_default">Taasta algväärtused</string>
|
||||
<string name="rotate_left">Pööra vasakule</string>
|
||||
<string name="rotate_right">Pööra paremale</string>
|
||||
<string name="save">Salvesta</string>
|
||||
<string name="scan_new">Uus skaneerimine</string>
|
||||
<string name="scan_current">Praegune skaneerimine</string>
|
||||
<string name="scan_discard_current">Loobu hetkel skaneeritust</string>
|
||||
<string name="licensed_under">Käesolev rakendus on litsentseeritud GNU Üldise Avaliku Litsentsi versiooni 3.0 alusel.</string>
|
||||
<string name="scan_resume">Jätka skaneerimisega</string>
|
||||
<string name="settings">Seadistused</string>
|
||||
<string name="settings_ocr_download_error">Allalaadimine ei õnnestunud. Palun vaata, et sinu nutiseadme internetiühendus toimiks ja ligipääs võrgule on selle rakenduse jaoks lubatud.</string>
|
||||
<string name="settings_ocr_download_intro">Keelefailid on laaditud alla Tesseracti projekti ametlikust veebisaidist. Tekstituvastus toimib täies mahus ilma võrguühendust kasutamata.</string>
|
||||
<string name="settings_ocr_downloading">Keelefaili allalaadimine</string>
|
||||
<string name="settings_ocr_languages">Keeled</string>
|
||||
<string name="settings_ocr_languages_available">Saadaval keeled</string>
|
||||
<string name="settings_ocr_languages_disabled">Pole kasutusel</string>
|
||||
<string name="settings_ocr_languages_installed">Paigaldatud</string>
|
||||
<string name="settings_ocr_suggested">Meie soovitus</string>
|
||||
<string name="settings_section_ocr">Tekstituvastus</string>
|
||||
<string name="settings_section_scan">Skaneerimine</string>
|
||||
<string name="settings_section_export">Eksport</string>
|
||||
<string name="share">Jaga</string>
|
||||
<string name="share_document">Jaga dokumenti</string>
|
||||
<string name="storage_permission_denied">Faili salvestamine ei õnnestu: õigused on puudu</string>
|
||||
<string name="support">Kasutajatugi</string>
|
||||
<string name="support_last_image">Teavita viimase pildihõivega seotud veast või probleemist</string>
|
||||
<string name="text_recognition_disabled">Tekstituvastus: pole kasutusel</string>
|
||||
<string name="text_recognition_enabled">Tekstituvastus: on kasutusel</string>
|
||||
<string name="turn_off_torch">Lülita taskulamp välja</string>
|
||||
<string name="turn_on_torch">Lülita taskulamp sisse</string>
|
||||
<string name="unknown_size">Tundmatu suurus</string>
|
||||
<string name="version">Versioon</string>
|
||||
<string name="view_full_list">Vaata täismahus loendit</string>
|
||||
<string name="view_the_full_license">Vaata täismahus litsentsi</string>
|
||||
<string name="yes">Jah</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s fail on salvestatud kausta %3$s</item>
|
||||
<item quantity="other">%1$d faili on salvestatud kausta %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Importimisel on 1 foto</item>
|
||||
<item quantity="other">Importimisel on %d fotot</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d leht</item>
|
||||
<item quantity="other">%d lehte</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -1,30 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="about">À propos</string>
|
||||
<string name="add_page">Ajouter une page</string>
|
||||
<string name="app_tagline">Une application simple et respectueuse pour scanner vos documents.</string>
|
||||
<string name="apply">Appliquer</string>
|
||||
<string name="back">Retour</string>
|
||||
<string name="camera_permission_denied">L\'autorisation d\'accès à la caméra a été refusée</string>
|
||||
<string name="camera_permission_rationale">L’application a besoin d’accéder à l’appareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le document en cours.</string>
|
||||
<string name="camera_permission_rationale">L’application a besoin d’accéder à l’appareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le scan en cours.</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
<string name="change_directory">Changer de dossier</string>
|
||||
<string name="clear_text">Effacer le text</string>
|
||||
<string name="clear_text">Effacer le texte</string>
|
||||
<string name="close">Fermer</string>
|
||||
<string name="color_mode">Filtre</string>
|
||||
<string name="color_mode_auto">Automatique</string>
|
||||
<string name="color_mode_color">Couleur</string>
|
||||
<string name="color_mode_default">Filtre par défaut</string>
|
||||
<string name="color_mode_grayscale">Niveaux de gris</string>
|
||||
<string name="contact">Contact</string>
|
||||
<string name="copied_logs">Logs copiés dans le presse-papiers</string>
|
||||
<string name="copy_logs">Copier les logs</string>
|
||||
<string name="creating_export">Préparation de l’export…</string>
|
||||
<string name="crop">Recadrer</string>
|
||||
<string name="delete">Supprimer</string>
|
||||
<string name="delete_page">Supprimer la page</string>
|
||||
<string name="delete_page_warning">Voulez-vous supprimer cette page ?</string>
|
||||
<string name="developer">Développeur</string>
|
||||
<string name="discard_scan">Supprimer le scan</string>
|
||||
<string name="download_dirname">Téléchargements</string>
|
||||
<string name="end_scan">Terminer le scan</string>
|
||||
<string name="error">Erreur : %1$s</string>
|
||||
<string name="error_context_folder">Dossier d’export : %1$s</string>
|
||||
<string name="error_context_provider">Fournisseur : %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Le dossier d’export sélectionné n’est plus accessible. Veuillez choisir un autre dossier.</string>
|
||||
<string name="error_no_app">Aucune application trouvée pour ouvrir ce fichier</string>
|
||||
<string name="error_no_document">Aucun document détecté</string>
|
||||
<string name="error_occurred">Une erreur s’est produite</string>
|
||||
<string name="error_save">Échec de l\'enregistrement du fichier</string>
|
||||
<string name="export">Exporter</string>
|
||||
<string name="export_as">Exporter en %1$s</string>
|
||||
<string name="export_directory">Dossier d’export</string>
|
||||
<string name="export_folder_permission_lost">Accès au dossier perdu</string>
|
||||
<string name="export_format">Format d’export</string>
|
||||
<string name="export_quality">Qualité d’export</string>
|
||||
<string name="export_quality_low">Basse</string>
|
||||
@@ -34,7 +45,7 @@
|
||||
<string name="file_size_total">Taille totale : %1$s</string>
|
||||
<string name="filename">Nom de fichier</string>
|
||||
<string name="grant_permission">Autoriser</string>
|
||||
<string name="last_saved_pdf_files">Derniers PDF enregistrés sur l’appareil :</string>
|
||||
<string name="import_photos">Importer</string>
|
||||
<string name="libraries">Bibliothèques</string>
|
||||
<string name="libraries_open_source">Bibliothèques open source</string>
|
||||
<string name="license">Licence</string>
|
||||
@@ -44,16 +55,32 @@
|
||||
<string name="open">Ouvrir</string>
|
||||
<string name="open_file">Ouvrir le fichier</string>
|
||||
<string name="reset_to_default">Réinitialiser par défaut</string>
|
||||
<string name="resume">Reprendre</string>
|
||||
<string name="rotate_left">Pivoter vers la gauche</string>
|
||||
<string name="rotate_right">Pivoter vers la droite</string>
|
||||
<string name="save">Enregistrer</string>
|
||||
<string name="scan_button">Nouveau scan</string>
|
||||
<string name="scan_in_progress">Scan en cours</string>
|
||||
<string name="scan_new">Nouveau scan</string>
|
||||
<string name="scan_current">Scan en cours</string>
|
||||
<string name="scan_discard_current">Abandonner le scan en cours</string>
|
||||
<string name="scan_resume">Reprendre le scan</string>
|
||||
<string name="settings">Paramètres</string>
|
||||
<string name="settings_ocr_download_error">Échec du téléchargement. Vérifiez votre connexion Internet et assurez-vous que l\'accès au réseau est autorisé pour cette application.</string>
|
||||
<string name="settings_ocr_download_intro">Les fichiers de langue sont téléchargés depuis le projet officiel Tesseract. La reconnaissance de texte reste entièrement hors ligne.</string>
|
||||
<string name="settings_ocr_downloading">Téléchargement du fichier de langue</string>
|
||||
<string name="settings_ocr_languages">Langues</string>
|
||||
<string name="settings_ocr_languages_available">Langues disponibles</string>
|
||||
<string name="settings_ocr_languages_disabled">Désactivé</string>
|
||||
<string name="settings_ocr_languages_installed">Installées</string>
|
||||
<string name="settings_ocr_suggested">Suggéré</string>
|
||||
<string name="settings_section_ocr">Reconnaissance de texte</string>
|
||||
<string name="settings_section_scan">Scan</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Partager</string>
|
||||
<string name="share_document">Partager le document</string>
|
||||
<string name="storage_permission_denied">Impossible d’enregistrer le fichier : permission refusée</string>
|
||||
<string name="support">Support</string>
|
||||
<string name="support_last_image">Signaler un problème avec la dernière image capturée</string>
|
||||
<string name="text_recognition_disabled">Reconnaissance de texte : désactivée</string>
|
||||
<string name="text_recognition_enabled">Reconnaissance de texte : activée</string>
|
||||
<string name="turn_off_torch">Éteindre la torche</string>
|
||||
<string name="turn_on_torch">Allumer la torche</string>
|
||||
<string name="unknown_size">Taille inconnue</string>
|
||||
@@ -65,6 +92,10 @@
|
||||
<item quantity="one">%2$s enregistré dans %3$s</item>
|
||||
<item quantity="other">%1$d fichiers enregistrés dans %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">Import de %d photo</item>
|
||||
<item quantity="other">Import de %d photos</item>
|
||||
</plurals>
|
||||
<plurals name="page_count" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">%d page</item>
|
||||
<item quantity="other">%d pages</item>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<resources>
|
||||
<string name="about">Sobre</string>
|
||||
<string name="add_page">Engadir páxina</string>
|
||||
<string name="app_tagline">Unha aplicación sinxela e respectuosa para escanear os teus documentos.</string>
|
||||
<string name="apply">Aplicar</string>
|
||||
<string name="back">Atrás</string>
|
||||
<string name="camera_permission_denied">Permiso da cámara denegado</string>
|
||||
<string name="camera_permission_rationale">A aplicación precisa acceso á cámara para escanear documentos. As imaxes capturadas gárdanse só neste dispositivo e eliminaranse ao pechar o escaneo actual.</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Trocar cartafol</string>
|
||||
<string name="clear_text">Borrar texto</string>
|
||||
<string name="close">Pechar</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automático</string>
|
||||
<string name="color_mode_color">Cor</string>
|
||||
<string name="color_mode_default">Filtro predeterminado</string>
|
||||
<string name="color_mode_grayscale">Escala de grises</string>
|
||||
<string name="contact">Contacto</string>
|
||||
<string name="copied_logs">Rexistros copiados ao portapapeis</string>
|
||||
<string name="copy_logs">Copiar rexistros</string>
|
||||
<string name="creating_export">Preparando exportación…</string>
|
||||
<string name="crop">Recortar</string>
|
||||
<string name="delete">Eliminar</string>
|
||||
<string name="delete_page">Eliminar páxina</string>
|
||||
<string name="delete_page_warning">Queres eliminar esta páxina?</string>
|
||||
<string name="developer">Desenvolvedor</string>
|
||||
<string name="download_dirname">Descargas</string>
|
||||
<string name="error_context_folder">Cartafol de exportación: %1$s</string>
|
||||
<string name="error_context_provider">Provedor: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">O cartafol de exportación seleccionado xa non é accesible. Escolle outro cartafol.</string>
|
||||
<string name="error_no_app">Non se atopou ningunha aplicación para abrir este ficheiro</string>
|
||||
<string name="error_occurred">Produciuse un erro</string>
|
||||
<string name="error_save">Produciuse un erro ao gardar o ficheiro</string>
|
||||
<string name="export">Exportar</string>
|
||||
<string name="export_as">Exportar como %1$s</string>
|
||||
<string name="export_directory">Cartafol de exportación</string>
|
||||
<string name="export_folder_permission_lost">Perdeuse o acceso ao cartafol</string>
|
||||
<string name="export_format">Formato de exportación</string>
|
||||
<string name="export_quality">Calidade de exportación</string>
|
||||
<string name="export_quality_low">Baixa</string>
|
||||
<string name="export_quality_balanced">Equilibrada</string>
|
||||
<string name="export_quality_high">Alta</string>
|
||||
<string name="file_size">Tamaño do ficheiro: %1$s</string>
|
||||
<string name="file_size_total">Tamaño total: %1$s</string>
|
||||
<string name="filename">Nome do ficheiro</string>
|
||||
<string name="grant_permission">Conceder permiso</string>
|
||||
<string name="import_photos">Importar</string>
|
||||
<string name="libraries">Bibliotecas</string>
|
||||
<string name="libraries_open_source">Bibliotecas de código aberto</string>
|
||||
<string name="license">Licenza</string>
|
||||
<string name="licensed_under">Esta aplicación está licenciada baixo a GNU General Public License v3.0.</string>
|
||||
<string name="menu">Menú</string>
|
||||
<string name="new_document_warning">O escaneo actual perderase. Queres continuar?</string>
|
||||
<string name="open">Abrir</string>
|
||||
<string name="open_file">Abrir ficheiro</string>
|
||||
<string name="reset_to_default">Restablecer valores predeterminados</string>
|
||||
<string name="rotate_left">Xirar á esquerda</string>
|
||||
<string name="rotate_right">Xirar á dereita</string>
|
||||
<string name="save">Gardar</string>
|
||||
<string name="scan_new">Novo escaneo</string>
|
||||
<string name="scan_current">Escaneo actual</string>
|
||||
<string name="scan_discard_current">Descartar o escaneo actual</string>
|
||||
<string name="scan_resume">Retomar escaneo</string>
|
||||
<string name="settings">Configuración</string>
|
||||
<string name="settings_ocr_download_error">Produciuse un erro na descarga. Comproba a túa conexión á Internet e asegúrate de que esta aplicación ten permitido o acceso á rede.</string>
|
||||
<string name="settings_ocr_download_intro">Os ficheiros de idioma descárganse desde o proxecto oficial Tesseract. O recoñecemento de texto segue sendo completamente sen conexión.</string>
|
||||
<string name="settings_ocr_downloading">Descargando ficheiro de idioma</string>
|
||||
<string name="settings_ocr_languages">Idiomas</string>
|
||||
<string name="settings_ocr_languages_available">Idiomas dispoñibles</string>
|
||||
<string name="settings_ocr_languages_disabled">Desactivado</string>
|
||||
<string name="settings_ocr_languages_installed">Instalados</string>
|
||||
<string name="settings_ocr_suggested">Suxerido</string>
|
||||
<string name="settings_section_ocr">Recoñecemento de texto</string>
|
||||
<string name="settings_section_scan">Escaneo</string>
|
||||
<string name="settings_section_export">Exportación</string>
|
||||
<string name="share">Compartir</string>
|
||||
<string name="share_document">Compartir documento</string>
|
||||
<string name="storage_permission_denied">Non se pode gardar o ficheiro: permiso denegado</string>
|
||||
<string name="support">Soporte</string>
|
||||
<string name="support_last_image">Informar dun problema coa derradeira imaxe capturada</string>
|
||||
<string name="text_recognition_disabled">Recoñecemento de texto: desactivado</string>
|
||||
<string name="text_recognition_enabled">Recoñecemento de texto: activado</string>
|
||||
<string name="turn_off_torch">Apagar lanterna</string>
|
||||
<string name="turn_on_torch">Acender lanterna</string>
|
||||
<string name="unknown_size">Tamaño descoñecido</string>
|
||||
<string name="version">Versión</string>
|
||||
<string name="view_full_list">Ver lista completa</string>
|
||||
<string name="view_the_full_license">Ver a licenza completa</string>
|
||||
<string name="yes">Si</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s gardado en %3$s</item>
|
||||
<item quantity="other">%1$d ficheiros gardados en %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Importando 1 foto</item>
|
||||
<item quantity="other">Importando %d fotos</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d páxina</item>
|
||||
<item quantity="other">%d páxinas</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,102 @@
|
||||
<resources>
|
||||
<string name="about">Névjegy</string>
|
||||
<string name="add_page">Új oldal</string>
|
||||
<string name="app_tagline">Egy egyszerű, a felhasználót tiszteletben tartó dokumentum szkenner alkalmazás.</string>
|
||||
<string name="apply">Alkalmaz</string>
|
||||
<string name="back">Vissza</string>
|
||||
<string name="camera_permission_denied">Kamerahozzáférés megtagadva</string>
|
||||
<string name="camera_permission_rationale">Az alkalmazásnak a dokumentumok beolvasásához kamerahozzáférésre van szüksége. A rögzített képek csak ezen az eszközön kerülnek tárolásra, és törlődnek, amikor bezárod a folyamatban lévő szkennelést.</string>
|
||||
<string name="cancel">Mégse</string>
|
||||
<string name="change_directory">Mappaválasztás</string>
|
||||
<string name="clear_text">Szöveg törlése</string>
|
||||
<string name="close">Bezárás</string>
|
||||
<string name="color_mode">Színszűrő</string>
|
||||
<string name="color_mode_auto">Automatikus</string>
|
||||
<string name="color_mode_color">Színes</string>
|
||||
<string name="color_mode_default">Alapértelmezett színszűrő</string>
|
||||
<string name="color_mode_grayscale">Szürkeárnyalatos</string>
|
||||
<string name="contact">Kapcsolat</string>
|
||||
<string name="copied_logs">Napló vágólapra másolva</string>
|
||||
<string name="copy_logs">Napló másolása</string>
|
||||
<string name="creating_export">Dokumentum előkészítése…</string>
|
||||
<string name="crop">Levágás</string>
|
||||
<string name="delete">Törlés</string>
|
||||
<string name="delete_page">Oldal törlése</string>
|
||||
<string name="delete_page_warning">Törölni szeretnéd ezt az oldalt?</string>
|
||||
<string name="developer">Fejlesztő</string>
|
||||
<string name="download_dirname">Letöltések</string>
|
||||
<string name="error_context_folder">Exportálási mappa: %1$s</string>
|
||||
<string name="error_context_provider">Szolgáltató: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">A kijelölt exportálási mappa már nem elérhető. Kérlek válassz másik mappát.</string>
|
||||
<string name="error_no_app">Nem található alkalmazás a fájl megnyitásához</string>
|
||||
<string name="error_occurred">Hiba történt</string>
|
||||
<string name="error_save">Fájl mentése sikertelen</string>
|
||||
<string name="export">Exportálás</string>
|
||||
<string name="export_as">%1$s exportálása</string>
|
||||
<string name="export_directory">Exportálás helye</string>
|
||||
<string name="export_folder_permission_lost">Mappahozzáférés elveszett</string>
|
||||
<string name="export_format">Formátum</string>
|
||||
<string name="export_quality">Minőség</string>
|
||||
<string name="export_quality_low">Alacsony</string>
|
||||
<string name="export_quality_balanced">Kiegyensúlyozott</string>
|
||||
<string name="export_quality_high">Magas</string>
|
||||
<string name="file_size">Fájlméret: %1$s</string>
|
||||
<string name="file_size_total">Teljes méret: %1$s</string>
|
||||
<string name="filename">Fájlnév</string>
|
||||
<string name="grant_permission">Engedélyezés</string>
|
||||
<string name="import_photos">Importálás</string>
|
||||
<string name="libraries">Programkönyvtárak</string>
|
||||
<string name="libraries_open_source">Nyílt forráskódú programkönyvtárak</string>
|
||||
<string name="license">Licenc</string>
|
||||
<string name="licensed_under">Ez az alkalmazás a GNU General Public License v3.0 (GPLv3) licenc alatt érhető el.</string>
|
||||
<string name="menu">Menü</string>
|
||||
<string name="new_document_warning">A jelenlegi szkennelés el fog veszni. Biztosan folytatod?</string>
|
||||
<string name="open">Megnyitás</string>
|
||||
<string name="open_file">Fájl megnyitása</string>
|
||||
<string name="reset_to_default">Alapértelmezés visszaállítása</string>
|
||||
<string name="rotate_left">Forgatás balra</string>
|
||||
<string name="rotate_right">Forgatás jobbra</string>
|
||||
<string name="save">Mentés</string>
|
||||
<string name="scan_new">Új szkennelés</string>
|
||||
<string name="scan_current">Folyamatban lévő szkennelés</string>
|
||||
<string name="scan_discard_current">A folyamatban lévő szkennelés elvetése</string>
|
||||
<string name="scan_resume">Szkennelés folytatása</string>
|
||||
<string name="settings">Beállítások</string>
|
||||
<string name="settings_ocr_download_error">A letöltés sikertelen. Ellenőrizd az internetkapcsolatodat, valamint hogy a hálózati hozzáférés engedélyezett-e az alkalmazás számára.</string>
|
||||
<string name="settings_ocr_download_intro">"A nyelvi fájlok a hivatalos Tesseract projektből kerülnek letöltésre. A szövegfelismerés ezt követően teljes mértékben offline történik."</string>
|
||||
<string name="settings_ocr_downloading">Nyelvi fájl letöltése</string>
|
||||
<string name="settings_ocr_languages">Nyelvek</string>
|
||||
<string name="settings_ocr_languages_available">Elérhető nyelvek</string>
|
||||
<string name="settings_ocr_languages_disabled">Kikapcsolva</string>
|
||||
<string name="settings_ocr_languages_installed">Telepítve</string>
|
||||
<string name="settings_ocr_suggested">Ajánlott</string>
|
||||
<string name="settings_section_ocr">Szövegfelismerés</string>
|
||||
<string name="settings_section_scan">Szkennelés</string>
|
||||
<string name="settings_section_export">Exportálás</string>
|
||||
<string name="share">Megosztás</string>
|
||||
<string name="share_document">Dokumentum megosztása</string>
|
||||
<string name="storage_permission_denied">Fájl mentése sikertelen: engedély megtagadva</string>
|
||||
<string name="support">Támogatás</string>
|
||||
<string name="support_last_image">A legutóbb rögzített képpel kapcsolatos probléma jelentése</string>
|
||||
<string name="text_recognition_disabled">Szövegfelismerés: kikapcsolva</string>
|
||||
<string name="text_recognition_enabled">Szövegfelismerés: bekapcsolva</string>
|
||||
<string name="turn_off_torch">Zseblámpa kikapcsolása</string>
|
||||
<string name="turn_on_torch">Zseblámpa bekapcsolása</string>
|
||||
<string name="unknown_size">Ismeretlen méret</string>
|
||||
<string name="version">Verzió</string>
|
||||
<string name="view_full_list">Teljes lista megtekintése</string>
|
||||
<string name="view_the_full_license">Teljes licenc megtekintése</string>
|
||||
<string name="yes">Igen</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s mentve ide: %3$s</item>
|
||||
<item quantity="other">%1$d fájl mentve ide: %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">%d fénykép importálása</item>
|
||||
<item quantity="other">%d fénykép importálása</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d oldal</item>
|
||||
<item quantity="other">%d oldal</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,102 @@
|
||||
<resources>
|
||||
<string name="about">Tentang</string>
|
||||
<string name="add_page">Tambahkan halaman</string>
|
||||
<string name="app_tagline">Aplikasi sederhana dan aman untuk memindai dokumen Anda</string>
|
||||
<string name="apply">Terapkan</string>
|
||||
<string name="back">Kembali</string>
|
||||
<string name="camera_permission_denied">Izin penggunaan kamera ditolak</string>
|
||||
<string name="camera_permission_rationale">Aplikasi ini memerlukan akses kamera untuk memindai dokumen. Gambar yang diambil hanya disimpan di perangkat ini dan akan dihapus saat Anda menutup pemindaian saat ini.</string>
|
||||
<string name="cancel">Batalkan</string>
|
||||
<string name="change_directory">Ganti folder</string>
|
||||
<string name="clear_text">Bersihkan teks</string>
|
||||
<string name="close">Tutup</string>
|
||||
<string name="color_mode">Filter</string>
|
||||
<string name="color_mode_auto">Otomatis</string>
|
||||
<string name="color_mode_color">Warna</string>
|
||||
<string name="color_mode_default">Filter bawaan</string>
|
||||
<string name="color_mode_grayscale">Abu-abu</string>
|
||||
<string name="contact">Kontak</string>
|
||||
<string name="copied_logs">Log disalin ke papan klip</string>
|
||||
<string name="copy_logs">Salin log</string>
|
||||
<string name="creating_export">Mempersiapkan ekspor…</string>
|
||||
<string name="crop">Potong</string>
|
||||
<string name="delete">Hapus</string>
|
||||
<string name="delete_page">Hapus halaman</string>
|
||||
<string name="delete_page_warning">Apakah Anda ingin menghapus halaman ini?</string>
|
||||
<string name="developer">Pengembang</string>
|
||||
<string name="download_dirname">Undugan</string>
|
||||
<string name="error_context_folder">Folder ekspor %1$s</string>
|
||||
<string name="error_context_provider">Penyedia: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Folder ekspor yang dipilih tidak lagi dapat diakses. Silakan pilih folder lain.</string>
|
||||
<string name="error_no_app">Tidak ditemukan aplikasi untuk membuka file ini.</string>
|
||||
<string name="error_occurred">Terjadi kesalahan</string>
|
||||
<string name="error_save">Gagal menyimpan file</string>
|
||||
<string name="export">Ekspor</string>
|
||||
<string name="export_as">Ekspor sebagai %1$s</string>
|
||||
<string name="export_directory">Folder ekspor</string>
|
||||
<string name="export_folder_permission_lost">Akses folder hilang</string>
|
||||
<string name="export_format">Format ekspor</string>
|
||||
<string name="export_quality">Kualitas ekspor</string>
|
||||
<string name="export_quality_low">Rendah</string>
|
||||
<string name="export_quality_balanced">Sedang</string>
|
||||
<string name="export_quality_high">Tinggi</string>
|
||||
<string name="file_size">Ukuran file: %1$s</string>
|
||||
<string name="file_size_total">Ukuran total: %1$s</string>
|
||||
<string name="filename">Nama file</string>
|
||||
<string name="grant_permission">Berikan izin</string>
|
||||
<string name="import_photos">Impor</string>
|
||||
<string name="libraries">Perpustakaan</string>
|
||||
<string name="libraries_open_source">Pustaka sumber terbuka</string>
|
||||
<string name="license">Lisensi</string>
|
||||
<string name="licensed_under">Aplikasi ini dilisensikan berdasarkan Lisensi Publik Umum GNU v3.0.</string>
|
||||
<string name="menu">Menu</string>
|
||||
<string name="new_document_warning">Pemindaian saat ini akan hilang. Apakah Anda ingin melanjutkan?</string>
|
||||
<string name="open">Buka</string>
|
||||
<string name="open_file">Buka file</string>
|
||||
<string name="reset_to_default">Kembalikan ke bawaan</string>
|
||||
<string name="rotate_left">Putar ke kiri</string>
|
||||
<string name="rotate_right">Putar ke kanan</string>
|
||||
<string name="save">Simpan</string>
|
||||
<string name="scan_new">Pindai baru</string>
|
||||
<string name="scan_current">Pindaian terkini</string>
|
||||
<string name="scan_discard_current">Abaikan pemindaian saat ini.</string>
|
||||
<string name="scan_resume">Ulangi pemindaian</string>
|
||||
<string name="settings">Pengaturan</string>
|
||||
<string name="settings_ocr_download_error">Pengunduhan gagal. Periksa koneksi internet Anda dan pastikan akses jaringan diizinkan untuk aplikasi ini.</string>
|
||||
<string name="settings_ocr_download_intro">"Berkas bahasa diunduh dari proyek resmi Tesseract. Pengenalan teks tetap sepenuhnya offline."</string>
|
||||
<string name="settings_ocr_downloading">Mengunduh berkas bahasa</string>
|
||||
<string name="settings_ocr_languages">Bahasa</string>
|
||||
<string name="settings_ocr_languages_available">Bahasa yang tersedia</string>
|
||||
<string name="settings_ocr_languages_disabled">Matikan</string>
|
||||
<string name="settings_ocr_languages_installed">Terpasang</string>
|
||||
<string name="settings_ocr_suggested">Disarankan</string>
|
||||
<string name="settings_section_ocr">Pengenalan teks</string>
|
||||
<string name="settings_section_scan">Pindah</string>
|
||||
<string name="settings_section_export">Ekspor</string>
|
||||
<string name="share">Bagikan</string>
|
||||
<string name="share_document">Bagikan dokumen</string>
|
||||
<string name="storage_permission_denied">Tidak dapat menyimpan file: izin ditolak.</string>
|
||||
<string name="support">Dukung</string>
|
||||
<string name="support_last_image">Laporkan masalah pada gambar yang terakhir diambil</string>
|
||||
<string name="text_recognition_disabled">Pengenalan teks: matikan</string>
|
||||
<string name="text_recognition_enabled">Pengenalan teks: diaktifkan</string>
|
||||
<string name="turn_off_torch">Matikan senter</string>
|
||||
<string name="turn_on_torch">Hidupkan senter</string>
|
||||
<string name="unknown_size">Ukuran tidak diketahui</string>
|
||||
<string name="version">Versi</string>
|
||||
<string name="view_full_list">Lihat daftar lengkap</string>
|
||||
<string name="view_the_full_license">Lihat lisensi lengkapnya</string>
|
||||
<string name="yes">Yes</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s disimpan ke %3$s</item>
|
||||
<item quantity="other">%1$d berkas yang disimpan ke %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Impor 1 foto</item>
|
||||
<item quantity="other">Mengimpor %d foto</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d halaman</item>
|
||||
<item quantity="other">%d halaman</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -2,29 +2,39 @@
|
||||
<string name="about">Informazioni</string>
|
||||
<string name="add_page">Aggiungi pagina</string>
|
||||
<string name="app_tagline">Un\'app semplice e rispettosa per scansionare i tuoi documenti.</string>
|
||||
<string name="apply">Applica</string>
|
||||
<string name="back">Indietro</string>
|
||||
<string name="camera_permission_denied">Autorizzazione fotocamera negata</string>
|
||||
<string name="camera_permission_rationale">L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi il documento attuale.</string>
|
||||
<string name="camera_permission_rationale">L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi la scansione corrente.</string>
|
||||
<string name="cancel">Annulla</string>
|
||||
<string name="change_directory">Cambia cartella</string>
|
||||
<string name="clear_text">Svuota testo</string>
|
||||
<string name="close">Chiudi</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automatico</string>
|
||||
<string name="color_mode_color">Colore</string>
|
||||
<string name="color_mode_default">Filtro predefinito</string>
|
||||
<string name="color_mode_grayscale">Scala di grigi</string>
|
||||
<string name="contact">Contatti</string>
|
||||
<string name="copied_logs">Log copiati negli appunti</string>
|
||||
<string name="copy_logs">Copia log</string>
|
||||
<string name="creating_export">Preparazione dell’esportazione…</string>
|
||||
<string name="crop">Ritaglia</string>
|
||||
<string name="delete">Elimina</string>
|
||||
<string name="delete_page">Elimina pagina</string>
|
||||
<string name="delete_page_warning">Vuoi eliminare questa pagina?</string>
|
||||
<string name="developer">Sviluppatore</string>
|
||||
<string name="discard_scan">Scarta scansione</string>
|
||||
<string name="download_dirname">Download</string>
|
||||
<string name="end_scan">Termina scansione</string>
|
||||
<string name="error">Errore: %1$s</string>
|
||||
<string name="error_context_folder">Cartella di esportazione: %1$s</string>
|
||||
<string name="error_context_provider">Provider: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">La cartella di esportazione selezionata non è più accessibile. Scegli un’altra cartella.</string>
|
||||
<string name="error_no_app">Nessuna app trovata per aprire questo file</string>
|
||||
<string name="error_no_document">Nessun documento rilevato</string>
|
||||
<string name="error_occurred">Si è verificato un errore</string>
|
||||
<string name="error_save">Impossibile salvare il file</string>
|
||||
<string name="export">Esporta</string>
|
||||
<string name="export_as">Esporta come %1$s</string>
|
||||
<string name="export_directory">Cartella di esportazione</string>
|
||||
<string name="export_folder_permission_lost">Accesso alla cartella perso</string>
|
||||
<string name="export_format">Formato di esportazione</string>
|
||||
<string name="export_quality">Qualità di esportazione</string>
|
||||
<string name="export_quality_low">Bassa</string>
|
||||
@@ -34,7 +44,7 @@
|
||||
<string name="file_size_total">Dimensione totale: %1$s</string>
|
||||
<string name="filename">Nome file</string>
|
||||
<string name="grant_permission">Concendi autorizzazione</string>
|
||||
<string name="last_saved_pdf_files">PDF recenti salvati su questo dispositivo:</string>
|
||||
<string name="import_photos">Importa</string>
|
||||
<string name="libraries">Librerie</string>
|
||||
<string name="libraries_open_source">Librerie open source</string>
|
||||
<string name="license">Licenza</string>
|
||||
@@ -44,16 +54,32 @@
|
||||
<string name="open">Apri</string>
|
||||
<string name="open_file">Apri file</string>
|
||||
<string name="reset_to_default">Ripristina impostazioni predefinite</string>
|
||||
<string name="resume">Riprendi</string>
|
||||
<string name="rotate_left">Ruota a sinistra</string>
|
||||
<string name="rotate_right">Ruota a destra</string>
|
||||
<string name="save">Salva</string>
|
||||
<string name="scan_button">Nuova scansione</string>
|
||||
<string name="scan_in_progress">Scansione in corso</string>
|
||||
<string name="scan_new">Nuova scansione</string>
|
||||
<string name="scan_current">Scansione corrente</string>
|
||||
<string name="scan_discard_current">Elimina la scansione corrente</string>
|
||||
<string name="scan_resume">Riprendi scansione</string>
|
||||
<string name="settings">Impostazioni</string>
|
||||
<string name="settings_ocr_download_error">Download non riuscito. Controlla la connessione a Internet e assicurati che l\'accesso alla rete sia consentito per questa app.</string>
|
||||
<string name="settings_ocr_download_intro">I file di lingua vengono scaricati dal progetto ufficiale Tesseract. Il riconoscimento del testo rimane completamente offline.</string>
|
||||
<string name="settings_ocr_downloading">Download del file di lingua</string>
|
||||
<string name="settings_ocr_languages">Lingue</string>
|
||||
<string name="settings_ocr_languages_available">Lingue disponibili</string>
|
||||
<string name="settings_ocr_languages_disabled">Disattivato</string>
|
||||
<string name="settings_ocr_languages_installed">Installate</string>
|
||||
<string name="settings_ocr_suggested">Suggerito</string>
|
||||
<string name="settings_section_ocr">Riconoscimento del testo</string>
|
||||
<string name="settings_section_scan">Scansione</string>
|
||||
<string name="settings_section_export">Esportazione</string>
|
||||
<string name="share">Condividi</string>
|
||||
<string name="share_document">Condividi documento</string>
|
||||
<string name="storage_permission_denied">Impossibile salvare il file: permesso negato</string>
|
||||
<string name="support">Supporto</string>
|
||||
<string name="support_last_image">Segnala un problema con l’ultima immagine acquisita</string>
|
||||
<string name="text_recognition_disabled">Riconoscimento del testo: disattivato</string>
|
||||
<string name="text_recognition_enabled">Riconoscimento del testo: attivato</string>
|
||||
<string name="turn_off_torch">Spegni la torcia</string>
|
||||
<string name="turn_on_torch">Accendi la torcia</string>
|
||||
<string name="unknown_size">Dimensione sconosciuta</string>
|
||||
@@ -65,6 +91,10 @@
|
||||
<item quantity="one">%2$s salvato in %3$s</item>
|
||||
<item quantity="other">%1$d file salvati in %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">Importazione di 1 foto</item>
|
||||
<item quantity="other">Importazione di %d foto</item>
|
||||
</plurals>
|
||||
<plurals name="page_count" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">%d pagina</item>
|
||||
<item quantity="other">%d pagine</item>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="about">このアプリについて</string>
|
||||
<string name="add_page">ページを追加</string>
|
||||
<string name="app_tagline">シンプルで安心なドキュメントスキャナーです。</string>
|
||||
<string name="apply">適用</string>
|
||||
<string name="back">戻る</string>
|
||||
<string name="camera_permission_denied">カメラの権限が拒否されました</string>
|
||||
<string name="camera_permission_rationale">ドキュメントをスキャンするためにカメラへのアクセスが必要です。撮影した画像はこのデバイスにのみ保存され、現在のスキャンを閉じると削除されます。</string>
|
||||
<string name="cancel">キャンセル</string>
|
||||
<string name="change_directory">フォルダを変更</string>
|
||||
<string name="clear_text">テキストを消去</string>
|
||||
<string name="close">閉じる</string>
|
||||
<string name="color_mode">フィルター</string>
|
||||
<string name="color_mode_auto">自動</string>
|
||||
<string name="color_mode_color">カラー</string>
|
||||
<string name="color_mode_default">デフォルトフィルター</string>
|
||||
<string name="color_mode_grayscale">グレースケール</string>
|
||||
<string name="contact">連絡先</string>
|
||||
<string name="copied_logs">ログをクリップボードにコピーしました</string>
|
||||
<string name="copy_logs">ログをコピー</string>
|
||||
<string name="creating_export">エクスポートを準備中…</string>
|
||||
<string name="crop">トリミング</string>
|
||||
<string name="delete">削除</string>
|
||||
<string name="delete_page">ページを削除</string>
|
||||
<string name="delete_page_warning">このページを削除しますか?</string>
|
||||
<string name="developer">開発者</string>
|
||||
<string name="download_dirname">ダウンロード</string>
|
||||
<string name="error_context_folder">エクスポートフォルダ: %1$s</string>
|
||||
<string name="error_context_provider">プロバイダー: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">選択したエクスポートフォルダにアクセスできなくなりました。別のフォルダを選択してください。</string>
|
||||
<string name="error_no_app">このファイルを開けるアプリが見つかりません</string>
|
||||
<string name="error_occurred">エラーが発生しました</string>
|
||||
<string name="error_save">ファイルの保存に失敗しました</string>
|
||||
<string name="export">エクスポート</string>
|
||||
<string name="export_as">%1$s としてエクスポート</string>
|
||||
<string name="export_directory">エクスポートフォルダ</string>
|
||||
<string name="export_folder_permission_lost">フォルダへのアクセスが失われました</string>
|
||||
<string name="export_format">エクスポート形式</string>
|
||||
<string name="export_quality">エクスポート品質</string>
|
||||
<string name="export_quality_low">低</string>
|
||||
<string name="export_quality_balanced">バランス</string>
|
||||
<string name="export_quality_high">高</string>
|
||||
<string name="file_size">ファイルサイズ: %1$s</string>
|
||||
<string name="file_size_total">合計サイズ: %1$s</string>
|
||||
<string name="filename">ファイル名</string>
|
||||
<string name="grant_permission">権限を許可</string>
|
||||
<string name="import_photos">インポート</string>
|
||||
<string name="libraries">ライブラリ</string>
|
||||
<string name="libraries_open_source">オープンソースライブラリ</string>
|
||||
<string name="license">ライセンス</string>
|
||||
<string name="licensed_under">このアプリは GNU General Public License v3.0 の下でライセンスされています。</string>
|
||||
<string name="menu">メニュー</string>
|
||||
<string name="new_document_warning">現在のスキャンは失われます。続けますか?</string>
|
||||
<string name="open">開く</string>
|
||||
<string name="open_file">ファイルを開く</string>
|
||||
<string name="reset_to_default">デフォルトに戻す</string>
|
||||
<string name="rotate_left">左に回転</string>
|
||||
<string name="rotate_right">右に回転</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="scan_new">新規スキャン</string>
|
||||
<string name="scan_current">現在のスキャン</string>
|
||||
<string name="scan_discard_current">現在のスキャンを破棄</string>
|
||||
<string name="scan_resume">スキャンを再開</string>
|
||||
<string name="settings">設定</string>
|
||||
<string name="settings_ocr_download_error">ダウンロードに失敗しました。インターネット接続を確認し、このアプリのネットワークアクセスが許可されていることを確認してください。</string>
|
||||
<string name="settings_ocr_download_intro">言語ファイルは公式の Tesseract プロジェクトからダウンロードされます。テキスト認識は完全にオフラインで実行されます。</string>
|
||||
<string name="settings_ocr_downloading">言語ファイルをダウンロード中</string>
|
||||
<string name="settings_ocr_languages">言語</string>
|
||||
<string name="settings_ocr_languages_available">利用可能な言語</string>
|
||||
<string name="settings_ocr_languages_disabled">無効</string>
|
||||
<string name="settings_ocr_languages_installed">インストール済み</string>
|
||||
<string name="settings_ocr_suggested">おすすめ</string>
|
||||
<string name="settings_section_ocr">テキスト認識</string>
|
||||
<string name="settings_section_scan">スキャン</string>
|
||||
<string name="settings_section_export">エクスポート</string>
|
||||
<string name="share">共有</string>
|
||||
<string name="share_document">ドキュメントを共有</string>
|
||||
<string name="storage_permission_denied">権限が拒否されたため、ファイルを保存できません</string>
|
||||
<string name="support">サポート</string>
|
||||
<string name="support_last_image">最後に撮影した画像の問題を報告</string>
|
||||
<string name="text_recognition_disabled">テキスト認識: 無効</string>
|
||||
<string name="text_recognition_enabled">テキスト認識: 有効</string>
|
||||
<string name="turn_off_torch">ライトをオフ</string>
|
||||
<string name="turn_on_torch">ライトをオン</string>
|
||||
<string name="unknown_size">サイズ不明</string>
|
||||
<string name="version">バージョン</string>
|
||||
<string name="view_full_list">全リストを表示</string>
|
||||
<string name="view_the_full_license">ライセンス全文を表示</string>
|
||||
<string name="yes">はい</string>
|
||||
<plurals name="files_saved_to" tools:ignore="MissingQuantity">
|
||||
<item quantity="other">%3$s に %1$d 件のファイルを保存しました</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos" tools:ignore="MissingQuantity">
|
||||
<item quantity="other">%d 枚の写真をインポート中</item>
|
||||
</plurals>
|
||||
<plurals name="page_count" tools:ignore="MissingQuantity">
|
||||
<item quantity="other">%d ページ</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="export_quality_balanced">Sabalansēta</string>
|
||||
<string name="close">Aizvērt</string>
|
||||
<string name="save">Saglabāt</string>
|
||||
<string name="add_page">Pievienot lapu</string>
|
||||
<string name="error_occurred">Radās kļūda</string>
|
||||
<string name="view_full_list">Skatīt visu sarakstu</string>
|
||||
<string name="developer">Izstrādātājs</string>
|
||||
<string name="rotate_left">Pagriezt pa kreisi</string>
|
||||
<string name="settings_ocr_languages_installed">Uzstādīta</string>
|
||||
<plurals name="page_count">
|
||||
<item quantity="zero">%d lapas</item>
|
||||
<item quantity="one">%d lapa</item>
|
||||
<item quantity="other">%d lapas</item>
|
||||
</plurals>
|
||||
<string name="delete_page">Dzēst lapu</string>
|
||||
<string name="settings_ocr_downloading">Lejupielādē valodas datni</string>
|
||||
<string name="text_recognition_enabled">Teksta atpazīšana: iespējota</string>
|
||||
<string name="yes">Jā</string>
|
||||
<string name="filename">Datnes nosaukums</string>
|
||||
<string name="camera_permission_denied">Piekļuve kamerai liegta</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="zero">%1$d datnes saglabātas %3$s mapē</item>
|
||||
<item quantity="one">%2$s saglabāts %3$s mapē</item>
|
||||
<item quantity="other">%1$d datnes saglabātas %3$s mapē</item>
|
||||
</plurals>
|
||||
<string name="text_recognition_disabled">Teksta atpazīšana: atspējota</string>
|
||||
<string name="back">Atpakaļ</string>
|
||||
<string name="delete">Dzēst</string>
|
||||
<string name="support">Atbalsts</string>
|
||||
<string name="version">Versija</string>
|
||||
<string name="export_quality_low">Zema</string>
|
||||
<string name="export_quality_high">Augsta</string>
|
||||
<string name="contact">Kontakti</string>
|
||||
<string name="libraries_open_source">Atvērtā pirmkoda bibliotēkas</string>
|
||||
<string name="error_save">Neizdevās saglabāt datni</string>
|
||||
<string name="settings_ocr_languages_disabled">Atspējotas</string>
|
||||
<string name="license">Licence</string>
|
||||
<string name="settings_ocr_languages">Valodas</string>
|
||||
<string name="about">Par lietotni</string>
|
||||
<string name="turn_on_torch">Ieslēgt zibspuldzi/lukturīti</string>
|
||||
<string name="cancel">Atcelt</string>
|
||||
<string name="delete_page_warning">Vai tiešām vēlaties dzēst šo lapu?</string>
|
||||
<string name="open">Atvērt</string>
|
||||
<string name="view_the_full_license">Skatīt pašu licenci</string>
|
||||
<string name="file_size">Datnes izmērs: %1$s</string>
|
||||
<string name="open_file">Atvērt datni</string>
|
||||
<string name="settings">Iestatījumi</string>
|
||||
<string name="libraries">Bibliotēkas</string>
|
||||
<string name="settings_section_ocr">Teksta atpazīšana</string>
|
||||
<string name="unknown_size">Nezināms izmērs</string>
|
||||
<string name="turn_off_torch">Izslēgt zibspuldzi/lukturīti</string>
|
||||
<string name="rotate_right">Pagriezt pa labi</string>
|
||||
<string name="share">Kopīgot</string>
|
||||
<string name="file_size_total">Kopējais izmērs: %1$s</string>
|
||||
<string name="crop">Apgriezt</string>
|
||||
<string name="share_document">Kopīgot dokumentu</string>
|
||||
<string name="settings_ocr_languages_available">Pieejamās valodas</string>
|
||||
<string name="settings_ocr_download_intro">Valodu datnes lejupielādē no oficiālās Tesseract projekta vietnes. Teksta atpazīšana pilnībā notiek bezsaistē.</string>
|
||||
<string name="download_dirname">Lejupielādes</string>
|
||||
<string name="menu">Izvēlne</string>
|
||||
<string name="settings_section_export">Veidošana</string>
|
||||
<string name="export">Veidot</string>
|
||||
<string name="settings_section_scan">Skenējums</string>
|
||||
<string name="change_directory">Mainīt mapi</string>
|
||||
<string name="clear_text">Notīrīt tekstu</string>
|
||||
<string name="error_context_provider">Pakalpojuma sniedzējs: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Atlasītā veidošanas mape vairs nav pieejama. Lūdzu, atlasiet citu mapi.</string>
|
||||
<string name="export_as">Veidot kā %1$s</string>
|
||||
<string name="export_format">Veidošanas formāts</string>
|
||||
<string name="export_directory">Mape, kur saglabāt</string>
|
||||
<string name="export_quality">Veidošanas kvalitāte</string>
|
||||
<string name="camera_permission_rationale">Lai varētu veikt skenējumus, lietotnei ir nepieciešama piekļuve kamerai. Uzņemtie attēli tiek saglabāti tikai šajā ierīcē un tiks dzēsti, kad aizvērsiet pašreizējo skenējumu.</string>
|
||||
<string name="color_mode_auto">Automātiski</string>
|
||||
<string name="copied_logs">Žurnāla ieraksti kopēti starpliktuvē</string>
|
||||
<string name="copy_logs">Kopēt žurnāla ierakstus</string>
|
||||
<string name="error_no_app">Nav atrasta neviena lietotne, kas varētu atvērt šo datni</string>
|
||||
<string name="export_folder_permission_lost">Zaudēta piekļuve mapei</string>
|
||||
<string name="grant_permission">Dot atļauju</string>
|
||||
<string name="licensed_under">Šī lietotne ir licencēta saskaņā ar GNU General Public License v3.0 noteikumiem.</string>
|
||||
<string name="new_document_warning">Pašreizējais skenējums tiks zaudēts. Vai tiešām vēlaties turpināt?</string>
|
||||
<string name="reset_to_default">Atiestatīt uz noklusējuma iestatījumu</string>
|
||||
<string name="scan_new">Jauns skenējums</string>
|
||||
<string name="scan_current">Pašreizējais skenējums</string>
|
||||
<string name="scan_discard_current">Atmestīs pašreizējo skenējumu</string>
|
||||
<string name="scan_resume">Turpināt skenējumu</string>
|
||||
<string name="settings_ocr_download_error">Neizdevās lejupielādēt. Pārbaudiet savu interneta savienojumu un pārliecinieties, ka šai lietotnei ir tīkla piekļuves atļauja.</string>
|
||||
<string name="settings_ocr_suggested">Iesaka</string>
|
||||
<string name="app_tagline">Vienkārša un cieņpilna lietotne, kas veic jūsu dokumentu skenējumus.</string>
|
||||
<string name="color_mode">Krāsu filtrs</string>
|
||||
<string name="color_mode_default">Noklusējuma krāsu filtrs</string>
|
||||
<string name="color_mode_grayscale">Pelēktoņu</string>
|
||||
<string name="import_photos">Pievienot</string>
|
||||
<string name="storage_permission_denied">Nevar saglabāt datni: piekļuve liegta</string>
|
||||
<string name="support_last_image">Ziņot par problēmu saistībā ar pēdējo uzņemto attēlu</string>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="zero">Pievieno %d fotoattēlus</item>
|
||||
<item quantity="one">Pievieno %d fotoattēlu</item>
|
||||
<item quantity="other">Pievieno %d fotoattēlus</item>
|
||||
</plurals>
|
||||
<string name="color_mode_color">Pilnkrāsu</string>
|
||||
<string name="creating_export">Sagatavo…</string>
|
||||
<string name="apply">Apstiprināt</string>
|
||||
<string name="error_context_folder">Mape, kur saglabā: %1$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="splash_background">#FF313332</color>
|
||||
</resources>
|
||||
@@ -2,29 +2,39 @@
|
||||
<string name="about">Sobre</string>
|
||||
<string name="add_page">Adicionar página</string>
|
||||
<string name="app_tagline">Um aplicativo simples e respeitoso para digitalizar seus documentos.</string>
|
||||
<string name="apply">Aplicar</string>
|
||||
<string name="back">Voltar</string>
|
||||
<string name="camera_permission_denied">Permissão da câmera negada</string>
|
||||
<string name="camera_permission_rationale">O aplicativo precisa de acesso à câmera para digitalizar documentos. As imagens capturadas são armazenadas apenas neste dispositivo e serão excluídas quando você fechar o documento atual.</string>
|
||||
<string name="camera_permission_rationale">O aplicativo precisa de acesso à câmera para digitalizar documentos. As imagens capturadas são armazenadas apenas neste dispositivo e serão excluídas quando você fechar a digitalização atual.</string>
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="change_directory">Alterar diretório</string>
|
||||
<string name="clear_text">Limpar texto</string>
|
||||
<string name="close">Fechar</string>
|
||||
<string name="color_mode">Filtro</string>
|
||||
<string name="color_mode_auto">Automático</string>
|
||||
<string name="color_mode_color">Cor</string>
|
||||
<string name="color_mode_default">Filtro padrão</string>
|
||||
<string name="color_mode_grayscale">Escala de cinza</string>
|
||||
<string name="contact">Contato</string>
|
||||
<string name="copied_logs">Registros copiados para a área de transferência</string>
|
||||
<string name="copy_logs">Copiar registros</string>
|
||||
<string name="creating_export">Preparando exportação…</string>
|
||||
<string name="crop">Recortar</string>
|
||||
<string name="delete">Excluir</string>
|
||||
<string name="delete_page">Excluir página</string>
|
||||
<string name="delete_page_warning">Deseja excluir esta página?</string>
|
||||
<string name="developer">Desenvolvedor</string>
|
||||
<string name="discard_scan">Descartar digitalização</string>
|
||||
<string name="download_dirname">Downloads</string>
|
||||
<string name="end_scan">Finalizar digitalização</string>
|
||||
<string name="error">Erro: %1$s</string>
|
||||
<string name="error_context_folder">Pasta de exportação: %1$s</string>
|
||||
<string name="error_context_provider">Provedor: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">A pasta de exportação selecionada não está mais acessível. Escolha outra pasta.</string>
|
||||
<string name="error_no_app">Nenhum app encontrado para abrir este arquivo</string>
|
||||
<string name="error_no_document">Nenhum documento detectado</string>
|
||||
<string name="error_occurred">Ocorreu um erro</string>
|
||||
<string name="error_save">Falha ao salvar o arquivo</string>
|
||||
<string name="export">Exportar</string>
|
||||
<string name="export_as">Exportar como %1$s</string>
|
||||
<string name="export_directory">Diretório de exportação</string>
|
||||
<string name="export_folder_permission_lost">Acesso à pasta perdido</string>
|
||||
<string name="export_format">Formato de exportação</string>
|
||||
<string name="export_quality">Qualidade de exportação</string>
|
||||
<string name="export_quality_low">Baixa</string>
|
||||
@@ -34,7 +44,7 @@
|
||||
<string name="file_size_total">Tamanho total: %1$s</string>
|
||||
<string name="filename">Nome do arquivo</string>
|
||||
<string name="grant_permission">Conceder permissão</string>
|
||||
<string name="last_saved_pdf_files">PDFs recentes salvos neste dispositivo:</string>
|
||||
<string name="import_photos">Importar</string>
|
||||
<string name="libraries">Bibliotecas</string>
|
||||
<string name="libraries_open_source">Bibliotecas de código aberto</string>
|
||||
<string name="license">Licença</string>
|
||||
@@ -44,16 +54,32 @@
|
||||
<string name="open">Abrir</string>
|
||||
<string name="open_file">Abrir arquivo</string>
|
||||
<string name="reset_to_default">Restaurar padrão</string>
|
||||
<string name="resume">Retomar</string>
|
||||
<string name="rotate_left">Girar para a esquerda</string>
|
||||
<string name="rotate_right">Girar para a direita</string>
|
||||
<string name="save">Salvar</string>
|
||||
<string name="scan_button">Nova digitalização</string>
|
||||
<string name="scan_in_progress">Digitalização em andamento</string>
|
||||
<string name="scan_new">Nova digitalização</string>
|
||||
<string name="scan_current">Digitalização atual</string>
|
||||
<string name="scan_discard_current">Descartar a digitalização atual</string>
|
||||
<string name="scan_resume">Retomar digitalização</string>
|
||||
<string name="settings">Configurações</string>
|
||||
<string name="settings_ocr_download_error">Falha no download. Verifique sua conexão com a Internet e certifique-se de que o acesso à rede está permitido para este aplicativo.</string>
|
||||
<string name="settings_ocr_download_intro">Os arquivos de idioma são baixados do projeto oficial Tesseract. O reconhecimento de texto permanece totalmente offline.</string>
|
||||
<string name="settings_ocr_downloading">Baixando arquivo de idioma</string>
|
||||
<string name="settings_ocr_languages">Idiomas</string>
|
||||
<string name="settings_ocr_languages_available">Idiomas disponíveis</string>
|
||||
<string name="settings_ocr_languages_disabled">Desativado</string>
|
||||
<string name="settings_ocr_languages_installed">Instalados</string>
|
||||
<string name="settings_ocr_suggested">Sugerido</string>
|
||||
<string name="settings_section_ocr">Reconhecimento de texto</string>
|
||||
<string name="settings_section_scan">Digitalização</string>
|
||||
<string name="settings_section_export">Exportação</string>
|
||||
<string name="share">Compartilhar</string>
|
||||
<string name="share_document">Compartilhar documento</string>
|
||||
<string name="storage_permission_denied">Não foi possível salvar o arquivo: permissão negada</string>
|
||||
<string name="support">Suporte</string>
|
||||
<string name="support_last_image">Relatar um problema com a última imagem capturada</string>
|
||||
<string name="text_recognition_disabled">Reconhecimento de texto: desativado</string>
|
||||
<string name="text_recognition_enabled">Reconhecimento de texto: ativado</string>
|
||||
<string name="turn_off_torch">Desligar lanterna</string>
|
||||
<string name="turn_on_torch">Ligar lanterna</string>
|
||||
<string name="unknown_size">Tamanho desconhecido</string>
|
||||
@@ -65,6 +91,10 @@
|
||||
<item quantity="one">%2$s salvo em %3$s</item>
|
||||
<item quantity="other">%1$d arquivos salvos em %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">Importando %d foto</item>
|
||||
<item quantity="other">Importando %d fotos</item>
|
||||
</plurals>
|
||||
<plurals name="page_count" tools:ignore="MissingQuantity">
|
||||
<item quantity="one">%d página</item>
|
||||
<item quantity="other">%d páginas</item>
|
||||
|
||||
@@ -2,29 +2,39 @@
|
||||
<string name="about">О приложении</string>
|
||||
<string name="add_page">Добавить страницу</string>
|
||||
<string name="app_tagline">Простое и открытое приложение для сканирования Ваших документов.</string>
|
||||
<string name="apply">Применить</string>
|
||||
<string name="back">Назад</string>
|
||||
<string name="camera_permission_denied">В доступе к камере отказано</string>
|
||||
<string name="camera_permission_rationale">Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании работы с текущим документом.</string>
|
||||
<string name="camera_permission_rationale">Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании текущего сканирования.</string>
|
||||
<string name="cancel">Отмена</string>
|
||||
<string name="change_directory">Изменить папку</string>
|
||||
<string name="clear_text">Стереть текст</string>
|
||||
<string name="close">Закрыть</string>
|
||||
<string name="color_mode">Фильтр</string>
|
||||
<string name="color_mode_auto">Автоматически</string>
|
||||
<string name="color_mode_color">Цвет</string>
|
||||
<string name="color_mode_default">Фильтр по умолчанию</string>
|
||||
<string name="color_mode_grayscale">Оттенки серого</string>
|
||||
<string name="contact">Контакты</string>
|
||||
<string name="copied_logs">Журналы скопированы в буфер обмена</string>
|
||||
<string name="copy_logs">Копировать журналы</string>
|
||||
<string name="creating_export">Подготовка экспорта…</string>
|
||||
<string name="crop">Обрезать</string>
|
||||
<string name="delete">Удалить</string>
|
||||
<string name="delete_page">Удалить страницу</string>
|
||||
<string name="delete_page_warning">Вы желаете удалить эту страницу?</string>
|
||||
<string name="developer">Разработчик</string>
|
||||
<string name="discard_scan">Отказаться</string>
|
||||
<string name="download_dirname">Download</string>
|
||||
<string name="end_scan">Закончить</string>
|
||||
<string name="error">Ошибка: %1$s</string>
|
||||
<string name="error_context_folder">Папка экспорта: %1$s</string>
|
||||
<string name="error_context_provider">Провайдер: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Выбранная папка экспорта больше недоступна. Пожалуйста, выберите другую папку.</string>
|
||||
<string name="error_no_app">Не найдено приложение для открытия этого файла</string>
|
||||
<string name="error_no_document">Документ не обнаружен</string>
|
||||
<string name="error_occurred">Произошла ошибка</string>
|
||||
<string name="error_save">Не удалось сохранить файл</string>
|
||||
<string name="export">Экспорт</string>
|
||||
<string name="export_as">Экспортировать как %1$s</string>
|
||||
<string name="export_directory">Папка экспорта</string>
|
||||
<string name="export_folder_permission_lost">Доступ к папке потерян</string>
|
||||
<string name="export_format">Формат экспорта</string>
|
||||
<string name="export_quality">Качество экспорта</string>
|
||||
<string name="export_quality_low">Низкое</string>
|
||||
@@ -34,7 +44,7 @@
|
||||
<string name="file_size_total">Общий размер: %1$s</string>
|
||||
<string name="filename">Имя файла</string>
|
||||
<string name="grant_permission">Предоставить разрешение</string>
|
||||
<string name="last_saved_pdf_files">Последние PDF, сохранённые на этом устройстве:</string>
|
||||
<string name="import_photos">Импорт</string>
|
||||
<string name="libraries">Библиотеки</string>
|
||||
<string name="libraries_open_source">Библиотеки с открытым исходным кодом</string>
|
||||
<string name="license">Лицензия</string>
|
||||
@@ -44,16 +54,32 @@
|
||||
<string name="open">Открыть</string>
|
||||
<string name="open_file">Открыть файл</string>
|
||||
<string name="reset_to_default">Сбросить по умолчанию</string>
|
||||
<string name="resume">Продолжить</string>
|
||||
<string name="rotate_left">Повернуть влево</string>
|
||||
<string name="rotate_right">Повернуть вправо</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="scan_button">Начать</string>
|
||||
<string name="scan_in_progress">Сканирование выполняется</string>
|
||||
<string name="scan_new">Начать</string>
|
||||
<string name="scan_current">Текущее сканирование</string>
|
||||
<string name="scan_discard_current">Отменить текущее сканирование</string>
|
||||
<string name="scan_resume">Продолжить сканирование</string>
|
||||
<string name="settings">Настройки</string>
|
||||
<string name="settings_ocr_download_error">Не удалось загрузить файл. Проверьте подключение к Интернету и убедитесь, что для этого приложения разрешён доступ к сети.</string>
|
||||
<string name="settings_ocr_download_intro">Языковые файлы загружаются из официального проекта Tesseract. Распознавание текста остаётся полностью офлайн.</string>
|
||||
<string name="settings_ocr_downloading">Загрузка языкового файла</string>
|
||||
<string name="settings_ocr_languages">Языки</string>
|
||||
<string name="settings_ocr_languages_available">Доступные языки</string>
|
||||
<string name="settings_ocr_languages_disabled">Отключено</string>
|
||||
<string name="settings_ocr_languages_installed">Установленные</string>
|
||||
<string name="settings_ocr_suggested">Рекомендуется</string>
|
||||
<string name="settings_section_ocr">Распознавание текста</string>
|
||||
<string name="settings_section_scan">Сканирование</string>
|
||||
<string name="settings_section_export">Экспорт</string>
|
||||
<string name="share">Поделиться</string>
|
||||
<string name="share_document">Поделиться документом</string>
|
||||
<string name="storage_permission_denied">Невозможно сохранить файл: доступ запрещён</string>
|
||||
<string name="support">Поддержка</string>
|
||||
<string name="support_last_image">Сообщить о проблеме с последним сделанным изображением</string>
|
||||
<string name="text_recognition_disabled">Распознавание текста: отключено</string>
|
||||
<string name="text_recognition_enabled">Распознавание текста: включено</string>
|
||||
<string name="turn_off_torch">Выключить фонарик</string>
|
||||
<string name="turn_on_torch">Включить фонарик</string>
|
||||
<string name="unknown_size">Неизвестный размер</string>
|
||||
@@ -67,6 +93,12 @@
|
||||
<item quantity="many">%1$d файлов сохранено в %3$s</item>
|
||||
<item quantity="other">%1$d файла сохранено в %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Импорт %d фото</item>
|
||||
<item quantity="few">Импорт %d фото</item>
|
||||
<item quantity="many">Импорт %d фото</item>
|
||||
<item quantity="other">Импорт %d фото</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d страница</item>
|
||||
<item quantity="few">%d страницы</item>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<resources>
|
||||
<string name="about">Om</string>
|
||||
<string name="add_page">Lägg till sida</string>
|
||||
<string name="app_tagline">En enkel och respektfull app för att skanna dina dokument.</string>
|
||||
<string name="apply">Använd</string>
|
||||
<string name="back">Tillbaka</string>
|
||||
<string name="camera_permission_denied">Kamerabehörighet nekades</string>
|
||||
<string name="camera_permission_rationale">Appen behöver åtkomst till kameran för att kunna skanna dokument. Tagna bilder lagras endast på den här enheten och raderas när du stänger den aktuella skanningen.</string>
|
||||
<string name="cancel">Avbryt</string>
|
||||
<string name="change_directory">Byt mapp</string>
|
||||
<string name="clear_text">Rensa text</string>
|
||||
<string name="close">Stäng</string>
|
||||
<string name="color_mode">Filter</string>
|
||||
<string name="color_mode_auto">Automatiskt</string>
|
||||
<string name="color_mode_color">Färg</string>
|
||||
<string name="color_mode_default">Standardfilter</string>
|
||||
<string name="color_mode_grayscale">Gråskala</string>
|
||||
<string name="contact">Kontakt</string>
|
||||
<string name="copied_logs">Loggarna har kopierats till urklipp</string>
|
||||
<string name="copy_logs">Kopiera loggar</string>
|
||||
<string name="creating_export">Förbereder export…</string>
|
||||
<string name="crop">Beskär</string>
|
||||
<string name="delete">Ta bort</string>
|
||||
<string name="delete_page">Ta bort sida</string>
|
||||
<string name="delete_page_warning">Vill du ta bort den här sidan?</string>
|
||||
<string name="developer">Utvecklare</string>
|
||||
<string name="download_dirname">Nedladdningar</string>
|
||||
<string name="error_context_folder">Exportmapp: %1$s</string>
|
||||
<string name="error_context_provider">Leverantör: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Den valda exportmappen är inte längre tillgänglig. Välj en annan mapp.</string>
|
||||
<string name="error_no_app">Ingen app för att öppna den här filen hittades</string>
|
||||
<string name="error_occurred">Ett fel uppstod</string>
|
||||
<string name="error_save">Det gick inte att spara filen</string>
|
||||
<string name="export">Exportera</string>
|
||||
<string name="export_as">Exportera som %1$s</string>
|
||||
<string name="export_directory">Exportmapp</string>
|
||||
<string name="export_folder_permission_lost">Åtkomsten till mappen har upphört</string>
|
||||
<string name="export_format">Exportformat</string>
|
||||
<string name="export_quality">Exportkvalitet</string>
|
||||
<string name="export_quality_low">Låg</string>
|
||||
<string name="export_quality_balanced">Balanserad</string>
|
||||
<string name="export_quality_high">Hög</string>
|
||||
<string name="file_size">Filstorlek: %1$s</string>
|
||||
<string name="file_size_total">Total storlek: %1$s</string>
|
||||
<string name="filename">Filnamn</string>
|
||||
<string name="grant_permission">Bevilja behörighet</string>
|
||||
<string name="import_photos">Importera</string>
|
||||
<string name="libraries">Bibliotek</string>
|
||||
<string name="libraries_open_source">Bibliotek med öppen källkod</string>
|
||||
<string name="license">Licens</string>
|
||||
<string name="licensed_under">Den här appen är licensierad under GNU General Public License v3.0.</string>
|
||||
<string name="menu">Meny</string>
|
||||
<string name="new_document_warning">Den aktuella skanningen raderas. Vill du fortsätta?</string>
|
||||
<string name="open">Öppna</string>
|
||||
<string name="open_file">Öppna fil</string>
|
||||
<string name="reset_to_default">Återställ till standard</string>
|
||||
<string name="rotate_left">Rotera åt vänster</string>
|
||||
<string name="rotate_right">Rotera åt höger</string>
|
||||
<string name="save">Spara</string>
|
||||
<string name="scan_new">Ny skanning</string>
|
||||
<string name="scan_current">Aktuell skanning</string>
|
||||
<string name="scan_discard_current">Kassera aktuell skanning</string>
|
||||
<string name="scan_resume">Återuppta skanning</string>
|
||||
<string name="settings">Inställningar</string>
|
||||
<string name="settings_ocr_download_error">Nedladdningen misslyckades. Kontrollera din internetanslutning och att nätverksåtkomst är tillåten för den här appen.</string>
|
||||
<string name="settings_ocr_download_intro">Språkfiler hämtas från det officiella Tesseract-projektet. Textigenkänning förblir helt offline.</string>
|
||||
<string name="settings_ocr_downloading">Hämtar språkfil</string>
|
||||
<string name="settings_ocr_languages">Språk</string>
|
||||
<string name="settings_ocr_languages_available">Tillgängliga språk</string>
|
||||
<string name="settings_ocr_languages_disabled">Inaktiverad</string>
|
||||
<string name="settings_ocr_languages_installed">Installerade</string>
|
||||
<string name="settings_ocr_suggested">Föreslagen</string>
|
||||
<string name="settings_section_ocr">Textigenkänning</string>
|
||||
<string name="settings_section_scan">Skanning</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="share">Dela</string>
|
||||
<string name="share_document">Dela dokument</string>
|
||||
<string name="storage_permission_denied">Det går inte att spara filen: behörighet nekades</string>
|
||||
<string name="support">Support</string>
|
||||
<string name="support_last_image">Rapportera ett problem med den senast tagna bilden</string>
|
||||
<string name="text_recognition_disabled">Textigenkänning: inaktiverad</string>
|
||||
<string name="text_recognition_enabled">Textigenkänning: aktiverad</string>
|
||||
<string name="turn_off_torch">Stäng av ficklampan</string>
|
||||
<string name="turn_on_torch">Tänd ficklampan</string>
|
||||
<string name="unknown_size">Okänd storlek</string>
|
||||
<string name="version">Version</string>
|
||||
<string name="view_full_list">Visa hela listan</string>
|
||||
<string name="view_the_full_license">Visa hela licensen</string>
|
||||
<string name="yes">Ja</string>
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="one">%2$s har sparats i %3$s</item>
|
||||
<item quantity="other">%1$d filer har sparats i %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">Importerar 1 foto</item>
|
||||
<item quantity="other">Importerar %d foton</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d sida</item>
|
||||
<item quantity="other">%d sidor</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -2,29 +2,39 @@
|
||||
<string name="about">Hakkında</string>
|
||||
<string name="add_page">Sayfa ekle</string>
|
||||
<string name="app_tagline">Belgelerinizi taramak için basit ve saygılı bir uygulama.</string>
|
||||
<string name="apply">Uygula</string>
|
||||
<string name="back">Geri</string>
|
||||
<string name="camera_permission_denied">Kamera izni reddedildi</string>
|
||||
<string name="camera_permission_rationale">Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve mevcut belgeyi kapattığınızda silinir.</string>
|
||||
<string name="camera_permission_rationale">Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve geçerli taramayı kapattığınızda silinir.</string>
|
||||
<string name="cancel">İptal</string>
|
||||
<string name="change_directory">Dizini değiştir</string>
|
||||
<string name="clear_text">Metni temizle</string>
|
||||
<string name="close">Kapat</string>
|
||||
<string name="color_mode">Filtre</string>
|
||||
<string name="color_mode_auto">Otomatik</string>
|
||||
<string name="color_mode_color">Renkli</string>
|
||||
<string name="color_mode_default">Varsayılan filtre</string>
|
||||
<string name="color_mode_grayscale">Gri tonlama</string>
|
||||
<string name="contact">İletişim</string>
|
||||
<string name="copied_logs">Günlükler panoya kopyalandı</string>
|
||||
<string name="copy_logs">Günlükleri kopyala</string>
|
||||
<string name="creating_export">Dışa aktarma hazırlanıyor…</string>
|
||||
<string name="crop">Kırp</string>
|
||||
<string name="delete">Sil</string>
|
||||
<string name="delete_page">Sayfayı sil</string>
|
||||
<string name="delete_page_warning">Bu sayfayı silmek istiyor musunuz?</string>
|
||||
<string name="developer">Geliştirici</string>
|
||||
<string name="discard_scan">Taramayı at</string>
|
||||
<string name="download_dirname">İndirilenler</string>
|
||||
<string name="end_scan">Taramayı bitir</string>
|
||||
<string name="error">Error: %1$s</string>
|
||||
<string name="error_context_folder">Dışa aktarma klasörü: %1$s</string>
|
||||
<string name="error_context_provider">Sağlayıcı: %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Seçilen dışa aktarma dizini artık erişilebilir değil. Lütfen başka bir dizin seçin.</string>
|
||||
<string name="error_no_app">No app found to open this file</string>
|
||||
<string name="error_no_document">No document detected</string>
|
||||
<string name="error_occurred">Bir hata oluştu</string>
|
||||
<string name="error_save">Failed to save file</string>
|
||||
<string name="export">Dışa aktar</string>
|
||||
<string name="export_as">%1$s olarak dışa aktar</string>
|
||||
<string name="export_directory">Dışa aktarma dizini</string>
|
||||
<string name="export_folder_permission_lost">Klasör erişimi kaybedildi</string>
|
||||
<string name="export_format">Dışa aktarma biçimi</string>
|
||||
<string name="export_quality">Dışa aktarma kalitesi</string>
|
||||
<string name="export_quality_low">Düşük</string>
|
||||
@@ -34,7 +44,7 @@
|
||||
<string name="file_size_total">Toplam boyut: %1$s</string>
|
||||
<string name="filename">Dosya adı</string>
|
||||
<string name="grant_permission">İzin ver</string>
|
||||
<string name="last_saved_pdf_files">Bu cihaza kaydedilen son PDF\'ler:</string>
|
||||
<string name="import_photos">İçe aktar</string>
|
||||
<string name="libraries">Kütüphaneler</string>
|
||||
<string name="libraries_open_source">Açık kaynaklı kütüphaneler</string>
|
||||
<string name="license">Lisans</string>
|
||||
@@ -44,16 +54,32 @@
|
||||
<string name="open">Aç</string>
|
||||
<string name="open_file">Dosyayı aç</string>
|
||||
<string name="reset_to_default">Varsayılana sıfırla</string>
|
||||
<string name="resume">Devam et</string>
|
||||
<string name="rotate_left">Sola döndür</string>
|
||||
<string name="rotate_right">Sağa döndür</string>
|
||||
<string name="save">Kaydet</string>
|
||||
<string name="scan_button">Yeni Tarama</string>
|
||||
<string name="scan_in_progress">Tarama devam ediyor</string>
|
||||
<string name="scan_new">Yeni Tarama</string>
|
||||
<string name="scan_current">Geçerli tarama</string>
|
||||
<string name="scan_discard_current">Geçerli taramayı sil</string>
|
||||
<string name="scan_resume">Taramaya devam et</string>
|
||||
<string name="settings">Ayarlar</string>
|
||||
<string name="settings_ocr_download_error">İndirme başarısız oldu. İnternet bağlantınızı kontrol edin ve bu uygulama için ağ erişimine izin verildiğinden emin olun.</string>
|
||||
<string name="settings_ocr_download_intro">Dil dosyaları resmi Tesseract projesinden indirilir. Metin tanıma tamamen çevrimdışı olarak çalışır.</string>
|
||||
<string name="settings_ocr_downloading">Dil dosyası indiriliyor</string>
|
||||
<string name="settings_ocr_languages">Diller</string>
|
||||
<string name="settings_ocr_languages_available">Kullanılabilir diller</string>
|
||||
<string name="settings_ocr_languages_disabled">Devre dışı</string>
|
||||
<string name="settings_ocr_languages_installed">Yüklü</string>
|
||||
<string name="settings_ocr_suggested">Önerilen</string>
|
||||
<string name="settings_section_ocr">Metin tanıma</string>
|
||||
<string name="settings_section_scan">Tarama</string>
|
||||
<string name="settings_section_export">Dışa aktarma</string>
|
||||
<string name="share">Paylaş</string>
|
||||
<string name="share_document">Belgeyi paylaş</string>
|
||||
<string name="storage_permission_denied">Dosya kaydedilemiyor: izin reddedildi</string>
|
||||
<string name="support">Destek</string>
|
||||
<string name="support_last_image">Son yakalanan görüntüyle ilgili bir sorunu bildir</string>
|
||||
<string name="text_recognition_disabled">Metin tanıma: devre dışı</string>
|
||||
<string name="text_recognition_enabled">Metin tanıma: etkin</string>
|
||||
<string name="turn_off_torch">El fenerini kapat</string>
|
||||
<string name="turn_on_torch">El fenerini aç</string>
|
||||
<string name="unknown_size">Bilinmeyen boyut</string>
|
||||
@@ -65,6 +91,10 @@
|
||||
<item quantity="one">%2$s, %3$s konumuna kaydedildi</item>
|
||||
<item quantity="other">%1$d dosya %3$s konumuna kaydedildi</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="one">1 foto içe aktarılıyor</item>
|
||||
<item quantity="other">%d foto içe aktarılıyor</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d sayfa</item>
|
||||
<item quantity="other">%d sayfa</item>
|
||||
|
||||
@@ -2,29 +2,39 @@
|
||||
<string name="about">關於</string>
|
||||
<string name="add_page">新增頁面</string>
|
||||
<string name="app_tagline">一個簡單且尊重隱私的文件掃描應用程式。</string>
|
||||
<string name="apply">套用</string>
|
||||
<string name="back">返回</string>
|
||||
<string name="camera_permission_denied">已拒絕相機權限</string>
|
||||
<string name="camera_permission_rationale">此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前文件時刪除。</string>
|
||||
<string name="camera_permission_rationale">此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前掃描時刪除。</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="change_directory">變更目錄</string>
|
||||
<string name="clear_text">清除文字</string>
|
||||
<string name="close">關閉</string>
|
||||
<string name="color_mode">濾鏡</string>
|
||||
<string name="color_mode_auto">自動</string>
|
||||
<string name="color_mode_color">彩色</string>
|
||||
<string name="color_mode_default">預設濾鏡</string>
|
||||
<string name="color_mode_grayscale">灰階</string>
|
||||
<string name="contact">聯絡我們</string>
|
||||
<string name="copied_logs">日誌已複製到剪貼簿</string>
|
||||
<string name="copy_logs">複製日誌</string>
|
||||
<string name="creating_export">正在準備匯出…</string>
|
||||
<string name="crop">裁切</string>
|
||||
<string name="delete">刪除</string>
|
||||
<string name="delete_page">刪除頁面</string>
|
||||
<string name="delete_page_warning">確定要刪除此頁面嗎?</string>
|
||||
<string name="developer">開發者</string>
|
||||
<string name="discard_scan">捨棄掃描</string>
|
||||
<string name="download_dirname">下載 (Downloads)</string>
|
||||
<string name="end_scan">結束掃描</string>
|
||||
<string name="error">錯誤:%1$s</string>
|
||||
<string name="error_context_folder">匯出資料夾:%1$s</string>
|
||||
<string name="error_context_provider">提供者:%1$s</string>
|
||||
<string name="error_export_dir_permission_lost">所選的匯出目錄已無法存取。請選擇其他目錄。</string>
|
||||
<string name="error_no_app">找不到可開啟此檔案的應用程式</string>
|
||||
<string name="error_no_document">未偵測到文件</string>
|
||||
<string name="error_occurred">發生錯誤</string>
|
||||
<string name="error_save">儲存檔案失敗</string>
|
||||
<string name="export">匯出</string>
|
||||
<string name="export_as">匯出為 %1$s</string>
|
||||
<string name="export_directory">匯出目錄</string>
|
||||
<string name="export_folder_permission_lost">資料夾存取權限已遺失</string>
|
||||
<string name="export_format">匯出格式</string>
|
||||
<string name="export_quality">匯出品質</string>
|
||||
<string name="export_quality_low">低</string>
|
||||
@@ -34,7 +44,7 @@
|
||||
<string name="file_size_total">總計大小:%1$s</string>
|
||||
<string name="filename">檔案名稱</string>
|
||||
<string name="grant_permission">授予權限</string>
|
||||
<string name="last_saved_pdf_files">此裝置上最近儲存的 PDF:</string>
|
||||
<string name="import_photos">匯入</string>
|
||||
<string name="libraries">函式庫</string>
|
||||
<string name="libraries_open_source">開放原始碼函式庫</string>
|
||||
<string name="license">許可證</string>
|
||||
@@ -44,16 +54,32 @@
|
||||
<string name="open">開啟</string>
|
||||
<string name="open_file">開啟檔案</string>
|
||||
<string name="reset_to_default">重設為預設值</string>
|
||||
<string name="resume">繼續</string>
|
||||
<string name="rotate_left">向左旋轉</string>
|
||||
<string name="rotate_right">向右旋轉</string>
|
||||
<string name="save">儲存</string>
|
||||
<string name="scan_button">開始掃描</string>
|
||||
<string name="scan_in_progress">掃描進行中</string>
|
||||
<string name="scan_new">開始掃描</string>
|
||||
<string name="scan_current">目前掃描</string>
|
||||
<string name="scan_discard_current">放棄目前掃描</string>
|
||||
<string name="scan_resume">繼續掃描</string>
|
||||
<string name="settings">設定</string>
|
||||
<string name="settings_ocr_download_error">下載失敗。請檢查您的網路連線,並確認已允許此應用程式存取網路。</string>
|
||||
<string name="settings_ocr_download_intro">語言檔案會從官方 Tesseract 專案下載。文字辨識始終完全離線運作。</string>
|
||||
<string name="settings_ocr_downloading">正在下載語言檔案</string>
|
||||
<string name="settings_ocr_languages">語言</string>
|
||||
<string name="settings_ocr_languages_available">可用語言</string>
|
||||
<string name="settings_ocr_languages_disabled">已停用</string>
|
||||
<string name="settings_ocr_languages_installed">已安裝</string>
|
||||
<string name="settings_ocr_suggested">推薦</string>
|
||||
<string name="settings_section_ocr">文字辨識</string>
|
||||
<string name="settings_section_scan">掃描</string>
|
||||
<string name="settings_section_export">匯出</string>
|
||||
<string name="share">分享</string>
|
||||
<string name="share_document">分享文件</string>
|
||||
<string name="storage_permission_denied">無法儲存檔案:權限遭拒</string>
|
||||
<string name="support">支援</string>
|
||||
<string name="support_last_image">回報最近拍攝影像的問題</string>
|
||||
<string name="text_recognition_disabled">文字辨識:已停用</string>
|
||||
<string name="text_recognition_enabled">文字辨識:已啟用</string>
|
||||
<string name="turn_off_torch">關閉閃光燈</string>
|
||||
<string name="turn_on_torch">開啟閃光燈</string>
|
||||
<string name="unknown_size">未知大小</string>
|
||||
@@ -65,6 +91,9 @@
|
||||
<item quantity="one">%2$s 已儲存至 %3$s</item>
|
||||
<item quantity="other">%1$d 個檔案已儲存至 %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="other">正在匯入 %d 張照片</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="one">%d 頁</item>
|
||||
<item quantity="other">%d 頁</item>
|
||||
|
||||
@@ -2,29 +2,39 @@
|
||||
<string name="about">关于</string>
|
||||
<string name="add_page">添加页面</string>
|
||||
<string name="app_tagline">一个简单且克制的文档扫描应用</string>
|
||||
<string name="apply">应用</string>
|
||||
<string name="back">返回</string>
|
||||
<string name="camera_permission_denied">相机权限被拒绝</string>
|
||||
<string name="camera_permission_rationale">应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前文档时将被删除。</string>
|
||||
<string name="camera_permission_rationale">应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前扫描时将被删除。</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="change_directory">更改目录</string>
|
||||
<string name="clear_text">清除文字</string>
|
||||
<string name="close">关闭</string>
|
||||
<string name="color_mode">滤镜</string>
|
||||
<string name="color_mode_auto">自动</string>
|
||||
<string name="color_mode_color">彩色</string>
|
||||
<string name="color_mode_default">默认滤镜</string>
|
||||
<string name="color_mode_grayscale">灰度</string>
|
||||
<string name="contact">联系人</string>
|
||||
<string name="copied_logs">日志已复制到剪贴板</string>
|
||||
<string name="copy_logs">复制日志</string>
|
||||
<string name="creating_export">正在准备导出…</string>
|
||||
<string name="crop">裁剪</string>
|
||||
<string name="delete">删除</string>
|
||||
<string name="delete_page">删除页面</string>
|
||||
<string name="delete_page_warning">是否要删除此页面?</string>
|
||||
<string name="developer">开发者</string>
|
||||
<string name="discard_scan">放弃扫描</string>
|
||||
<string name="download_dirname">下载</string>
|
||||
<string name="end_scan">结束扫描</string>
|
||||
<string name="error">错误: %1$s</string>
|
||||
<string name="error_context_folder">导出文件夹:%1$s</string>
|
||||
<string name="error_context_provider">提供方:%1$s</string>
|
||||
<string name="error_export_dir_permission_lost">所选的导出目录已无法访问。请选择其他目录。</string>
|
||||
<string name="error_no_app">未找到可打开此文件的应用</string>
|
||||
<string name="error_no_document">未检测到任何文档</string>
|
||||
<string name="error_occurred">发生错误</string>
|
||||
<string name="error_save">无法保存文件</string>
|
||||
<string name="export">导出</string>
|
||||
<string name="export_as">导出为 %1$s</string>
|
||||
<string name="export_directory">导出目录</string>
|
||||
<string name="export_folder_permission_lost">文件夹访问权限已丢失</string>
|
||||
<string name="export_format">导出格式</string>
|
||||
<string name="export_quality">导出质量</string>
|
||||
<string name="export_quality_low">低</string>
|
||||
@@ -34,7 +44,7 @@
|
||||
<string name="file_size_total">总大小:%1$s</string>
|
||||
<string name="filename">文件名字</string>
|
||||
<string name="grant_permission">授予权限</string>
|
||||
<string name="last_saved_pdf_files">最近保存在此设备上的 PDF:</string>
|
||||
<string name="import_photos">导入</string>
|
||||
<string name="libraries">库</string>
|
||||
<string name="libraries_open_source">开源库</string>
|
||||
<string name="license">许可证</string>
|
||||
@@ -44,16 +54,32 @@
|
||||
<string name="open">打开</string>
|
||||
<string name="open_file">打开文件</string>
|
||||
<string name="reset_to_default">恢复默认设置</string>
|
||||
<string name="resume">恢复</string>
|
||||
<string name="rotate_left">向左旋转</string>
|
||||
<string name="rotate_right">向右旋转</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="scan_button">新建扫描</string>
|
||||
<string name="scan_in_progress">正在进行扫描</string>
|
||||
<string name="scan_new">新建扫描</string>
|
||||
<string name="scan_current">当前扫描</string>
|
||||
<string name="scan_discard_current">放弃当前扫描</string>
|
||||
<string name="scan_resume">继续扫描</string>
|
||||
<string name="settings">设置</string>
|
||||
<string name="settings_ocr_download_error">下载失败。请检查您的网络连接,并确认已允许此应用访问网络。</string>
|
||||
<string name="settings_ocr_download_intro">语言文件从官方 Tesseract 项目下载。文本识别始终完全离线运行。</string>
|
||||
<string name="settings_ocr_downloading">正在下载语言文件</string>
|
||||
<string name="settings_ocr_languages">语言</string>
|
||||
<string name="settings_ocr_languages_available">可用语言</string>
|
||||
<string name="settings_ocr_languages_disabled">已禁用</string>
|
||||
<string name="settings_ocr_languages_installed">已安装</string>
|
||||
<string name="settings_ocr_suggested">推荐</string>
|
||||
<string name="settings_section_ocr">文本识别</string>
|
||||
<string name="settings_section_scan">扫描</string>
|
||||
<string name="settings_section_export">导出</string>
|
||||
<string name="share">共享</string>
|
||||
<string name="share_document">分享文档</string>
|
||||
<string name="storage_permission_denied">无法保存文件:权限被拒绝</string>
|
||||
<string name="support">支持</string>
|
||||
<string name="support_last_image">报告最近拍摄图像的问题</string>
|
||||
<string name="text_recognition_disabled">文本识别:已禁用</string>
|
||||
<string name="text_recognition_enabled">文本识别:已启用</string>
|
||||
<string name="turn_off_torch">关闭手电筒</string>
|
||||
<string name="turn_on_torch">打开手电筒</string>
|
||||
<string name="unknown_size">未知大小</string>
|
||||
@@ -64,6 +90,9 @@
|
||||
<plurals name="files_saved_to">
|
||||
<item quantity="other">%1$d 个文件已保存到 %3$s</item>
|
||||
</plurals>
|
||||
<plurals name="importing_photos">
|
||||
<item quantity="other">正在导入 %d 张照片</item>
|
||||
</plurals>
|
||||
<plurals name="page_count">
|
||||
<item quantity="other">%d 页</item>
|
||||
</plurals>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="splash_background">#FFFBFCFC</color>
|
||||
</resources>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user