AboutScreen: new button to send the last captured image (#88)

This commit is contained in:
Pierre-Yves Nicolas
2026-01-17 14:43:16 +01:00
parent 48985fb6b4
commit 2eaede0713
20 changed files with 228 additions and 15 deletions

View File

@@ -70,7 +70,6 @@ class AppContainer(context: Context) {
val homeViewModelFactory = viewModelFactory { HomeViewModel(it, context) }
val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) }
val aboutViewModelFactory = viewModelFactory { AboutViewModel(it) }
val settingsViewModelFactory = viewModelFactory { SettingsViewModel(it) }
fun cleanOrphanSessions() {

View File

@@ -50,6 +50,7 @@ import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.fairscan.app.data.FileLogger
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
@@ -58,6 +59,7 @@ import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.about.AboutEvent
import org.fairscan.app.ui.screens.about.AboutScreen
import org.fairscan.app.ui.screens.about.AboutViewModel
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
@@ -101,9 +103,14 @@ class MainActivity : ComponentActivity() {
ExportViewModel(appContainer, sessionContainer.imageRepository)
}
}
val aboutViewModel: AboutViewModel by viewModels {
appContainer.viewModelFactory {
AboutViewModel(appContainer, sessionContainer.imageRepository)
}
}
val homeViewModel: HomeViewModel by viewModels { appContainer.homeViewModelFactory }
val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory }
val aboutViewModel: AboutViewModel by viewModels { appContainer.aboutViewModelFactory }
val settingsViewModel: SettingsViewModel
by viewModels { appContainer.settingsViewModelFactory }
lifecycleScope.launch(Dispatchers.IO) {
@@ -119,7 +126,7 @@ class MainActivity : ComponentActivity() {
val cameraPermission = rememberCameraPermissionState()
CollectCameraEvents(cameraViewModel, viewModel)
CollectExportEvents(context, exportViewModel)
CollectAboutEvents(context, aboutViewModel)
CollectAboutEvents(context, aboutViewModel, sessionContainer.imageRepository)
FairScanTheme {
val navigation = navigation(viewModel, launchMode)
@@ -187,9 +194,16 @@ class MainActivity : ComponentActivity() {
)
}
is Screen.Overlay.About -> {
LaunchedEffect(Unit) {
aboutViewModel.refreshLastCapturedImageState()
}
val aboutUiState by aboutViewModel.uiState.collectAsStateWithLifecycle()
AboutScreen(
aboutUiState = aboutUiState,
onBack = navigation.back,
onCopyLogs = { aboutViewModel.onCopyLogsClicked() },
onContactWithLastImageClicked =
{ aboutViewModel.onContactWithLastImageClicked() },
onViewLibraries = navigation.toLibrariesScreen)
}
is Screen.Overlay.Libraries -> {
@@ -262,6 +276,7 @@ class MainActivity : ComponentActivity() {
private fun CollectAboutEvents(
context: Context,
aboutViewModel: AboutViewModel,
imageRepository: ImageRepository,
) {
val clipboard = LocalClipboard.current
val msgCopiedLogs = stringResource(R.string.copied_logs)
@@ -274,6 +289,12 @@ class MainActivity : ComponentActivity() {
)
Toast.makeText(context, msgCopiedLogs, Toast.LENGTH_SHORT).show()
}
is AboutEvent.PrepareEmailWithLastImage -> {
val file = imageRepository.lastAddedSourceFile()
if (file != null) {
startActivity(createEmailWithImageIntent(context, file))
}
}
}
}
}
@@ -370,8 +391,7 @@ class MainActivity : ComponentActivity() {
}
private fun uriForFile(file: File): Uri {
val authority = "${applicationContext.packageName}.fileprovider"
return FileProvider.getUriForFile(this, authority, file)
return org.fairscan.app.ui.uriForFile(this, file)
}
private fun checkPermissionThen(

View File

@@ -298,6 +298,14 @@ class ImageRepository(
}
}
}
fun lastAddedSourceFile(): File? {
val sourceFiles = sourceDir.listFiles()?.filter { it.extension == "jpg" }
if (sourceFiles.isNullOrEmpty()) {
return null
}
return sourceFiles.maxByOrNull { it.lastModified() }
}
}
fun Quad.toSerializable(): NormalizedQuad =

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import java.io.File
fun uriForFile(context: Context, file: File): Uri {
val authority = "${context.packageName}.fileprovider"
return FileProvider.getUriForFile(context, authority, file)
}

View File

@@ -57,6 +57,7 @@ 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.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -76,8 +77,10 @@ import org.fairscan.app.ui.theme.FairScanTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
aboutUiState: AboutUiState,
onBack: () -> Unit,
onCopyLogs: () -> Unit,
onContactWithLastImageClicked: () -> Unit,
onViewLibraries: () -> Unit,
) {
val showLicenseDialog = rememberSaveable { mutableStateOf(false) }
@@ -93,7 +96,9 @@ fun AboutScreen(
) { paddingValues ->
AboutContent(
modifier = Modifier.padding(paddingValues),
aboutUiState,
onCopyLogs,
onContactWithLastImageClicked,
showLicenseDialog,
onViewLibraries)
}
@@ -105,7 +110,9 @@ fun AboutScreen(
@Composable
fun AboutContent(
modifier: Modifier = Modifier,
aboutUiState: AboutUiState,
onCopyLogs: () -> Unit,
onContactWithLastImageClicked: () -> Unit,
showLicenseDialog: MutableState<Boolean>,
onViewLibraries: () -> Unit,
) {
@@ -145,16 +152,10 @@ fun AboutContent(
}
Section(title = stringResource(R.string.contact)) {
val emailAddress = "contact@fairscan.org"
ContactLink(
icon = Icons.Default.Email,
text = emailAddress,
onClick = {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:$emailAddress".toUri()
}
context.startActivity(intent)
}
text = EMAIL_ADDRESS,
onClick = { context.startActivity(createContactEmailIntent()) }
)
val websiteUrl = "https://fairscan.org"
ContactLink(
@@ -165,6 +166,10 @@ fun AboutContent(
context.startActivity(intent)
}
)
}
Section(title = stringResource(R.string.support)) {
EmailImageButton(aboutUiState, onContactWithLastImageClicked)
CopyLogsButton (onClick = onCopyLogs)
}
@@ -295,11 +300,33 @@ fun CopyLogsButton(onClick: () -> Unit) {
}
}
@Composable
fun EmailImageButton(
aboutUiState: AboutUiState,
onContactWithLastImageClicked: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
enabled = aboutUiState.hasLastCapturedImage,
onClick = onContactWithLastImageClicked
)
.alpha(if (aboutUiState.hasLastCapturedImage) 1f else 0.5f)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Email, contentDescription = null)
Spacer(Modifier.width(16.dp))
Text(stringResource(R.string.support_last_image))
}
}
@Preview
@Composable
fun AboutScreenPreview() {
FairScanTheme {
AboutScreen(onBack = {}, onCopyLogs = {}, onViewLibraries = {})
val state = AboutUiState(true)
AboutScreen(state, {}, {}, {}, {})
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.about
import androidx.compose.runtime.Immutable
@Immutable
data class AboutUiState(
val hasLastCapturedImage: Boolean = false,
)

View File

@@ -18,23 +18,31 @@ import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.fairscan.app.AppContainer
import org.fairscan.app.BuildConfig
import org.fairscan.app.data.ImageRepository
import java.time.LocalDateTime
sealed interface AboutEvent {
data class CopyLogs(val logs: String) : AboutEvent
object PrepareEmailWithLastImage : AboutEvent
}
class AboutViewModel(container: AppContainer): ViewModel() {
class AboutViewModel(container: AppContainer, val imageRepository: ImageRepository): ViewModel() {
private val logRepository = container.logRepository
private val _events = MutableSharedFlow<AboutEvent>()
val events = _events.asSharedFlow()
private val _uiState = MutableStateFlow(AboutUiState())
val uiState = _uiState.asStateFlow()
fun onCopyLogsClicked() {
viewModelScope.launch {
val logs = buildFullLogs()
@@ -55,4 +63,15 @@ class AboutViewModel(container: AppContainer): ViewModel() {
return header + logRepository.getLogs()
}
fun refreshLastCapturedImageState() {
_uiState.update {
it.copy(hasLastCapturedImage = imageRepository.lastAddedSourceFile() != null)
}
}
fun onContactWithLastImageClicked() {
viewModelScope.launch {
_events.emit(AboutEvent.PrepareEmailWithLastImage)
}
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.about
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import org.fairscan.app.BuildConfig
import org.fairscan.app.ui.uriForFile
import java.io.File
const val EMAIL_ADDRESS = "contact@fairscan.org"
fun createContactEmailIntent(): Intent =
Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:$EMAIL_ADDRESS".toUri()
}
fun createEmailWithImageIntent(context: Context, imageFile: File?): Intent {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/jpeg"
putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_ADDRESS))
putExtra(
Intent.EXTRA_SUBJECT,
"FairScan ${BuildConfig.VERSION_NAME}"
)
if (imageFile != null) {
val uri = uriForFile(context, imageFile)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
return Intent.createChooser(intent, null)
}

View File

@@ -52,6 +52,8 @@
<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="turn_off_torch">Vypnout svítilnu</string>
<string name="turn_on_torch">Zapnout svítilnu</string>
<string name="unknown_size">Neznámá velikost</string>

View File

@@ -52,6 +52,8 @@
<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="turn_off_torch">Taschenlampe ausschalten</string>
<string name="turn_on_torch">Taschenlampe einschalten</string>
<string name="unknown_size">Unbekannte Größe</string>

View File

@@ -52,6 +52,8 @@
<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="turn_off_torch">Apagar linterna</string>
<string name="turn_on_torch">Encender linterna</string>
<string name="unknown_size">Tamaño desconocido</string>

View File

@@ -52,6 +52,8 @@
<string name="share">Partager</string>
<string name="share_document">Partager le document</string>
<string name="storage_permission_denied">Impossible denregistrer 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="turn_off_torch">Éteindre la torche</string>
<string name="turn_on_torch">Allumer la torche</string>
<string name="unknown_size">Taille inconnue</string>

View File

@@ -52,6 +52,8 @@
<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 lultima immagine acquisita</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>

View File

@@ -52,6 +52,8 @@
<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="turn_off_torch">Desligar lanterna</string>
<string name="turn_on_torch">Ligar lanterna</string>
<string name="unknown_size">Tamanho desconhecido</string>

View File

@@ -52,6 +52,8 @@
<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="turn_off_torch">Выключить фонарик</string>
<string name="turn_on_torch">Включить фонарик</string>
<string name="unknown_size">Неизвестный размер</string>

View File

@@ -52,6 +52,8 @@
<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="turn_off_torch">關閉閃光燈</string>
<string name="turn_on_torch">開啟閃光燈</string>
<string name="unknown_size">未知大小</string>

View File

@@ -52,6 +52,8 @@
<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="turn_off_torch">关闭手电筒</string>
<string name="turn_on_torch">打开手电筒</string>
<string name="unknown_size">未知大小</string>

View File

@@ -56,6 +56,8 @@
<string name="share">Share</string>
<string name="share_document">Share document</string>
<string name="storage_permission_denied">Cannot save file: permission was denied</string>
<string name="support">Support</string>
<string name="support_last_image">Report a problem with last captured image</string>
<string name="turn_off_torch">Turn off torch</string>
<string name="turn_on_torch">Turn on torch</string>
<string name="unknown_size">Unknown size</string>

View File

@@ -6,4 +6,8 @@
<external-path
name="external_files"
path="." />
<!-- source images (to send the last captured image) -->
<files-path
name="sources"
path="sources/" />
</paths>

View File

@@ -242,6 +242,29 @@ class ImageRepositoryTest {
assertThat(PageV2("1", 42, 0, quad, true).toMetadata()).isNull()
}
@Test
fun last_added_source_file() {
val repo = repo()
assertThat(repo.lastAddedSourceFile()).isNull()
repo.add(byteArrayOf(101), byteArrayOf(51), metadata1)
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(51))
Thread.sleep(1)
repo.add(byteArrayOf(102), byteArrayOf(52), metadata1)
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(52))
val id = repo.imageIds().last()
repo.movePage(id, 0)
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(52))
repo.delete(id)
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(51))
val repo2 = repo()
assertThat(repo2.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(51))
repo2.clear()
assertThat(repo2.lastAddedSourceFile()).isNull()
}
private fun scanDir(): File = File(getFilesDir(), SCAN_DIR_NAME)
private fun sourceDir(): File = File(getFilesDir(), SOURCE_DIR_NAME)