From 2eaede07135458e811d38b61234f1fb9cff6ad76 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:43:16 +0100 Subject: [PATCH] AboutScreen: new button to send the last captured image (#88) --- .../main/java/org/fairscan/app/FairScanApp.kt | 1 - .../java/org/fairscan/app/MainActivity.kt | 28 +++++++++-- .../org/fairscan/app/data/ImageRepository.kt | 8 ++++ .../main/java/org/fairscan/app/ui/FileUris.kt | 25 ++++++++++ .../app/ui/screens/about/AboutScreen.kt | 45 ++++++++++++++---- .../app/ui/screens/about/AboutUiState.kt | 22 +++++++++ .../app/ui/screens/about/AboutViewModel.kt | 21 ++++++++- .../fairscan/app/ui/screens/about/Emails.kt | 46 +++++++++++++++++++ app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values-zh/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/file_paths.xml | 4 ++ .../fairscan/app/data/ImageRepositoryTest.kt | 23 ++++++++++ 20 files changed, 228 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/org/fairscan/app/ui/FileUris.kt create mode 100644 app/src/main/java/org/fairscan/app/ui/screens/about/AboutUiState.kt create mode 100644 app/src/main/java/org/fairscan/app/ui/screens/about/Emails.kt diff --git a/app/src/main/java/org/fairscan/app/FairScanApp.kt b/app/src/main/java/org/fairscan/app/FairScanApp.kt index 7348b7f..b3cb446 100644 --- a/app/src/main/java/org/fairscan/app/FairScanApp.kt +++ b/app/src/main/java/org/fairscan/app/FairScanApp.kt @@ -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() { diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 51e83da..54f13ce 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -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( diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index d982246..f82c777 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -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 = diff --git a/app/src/main/java/org/fairscan/app/ui/FileUris.kt b/app/src/main/java/org/fairscan/app/ui/FileUris.kt new file mode 100644 index 0000000..531bb08 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/FileUris.kt @@ -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 . + */ +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) +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/about/AboutScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/about/AboutScreen.kt index a354e8e..f4bd213 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/about/AboutScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/about/AboutScreen.kt @@ -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, 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, {}, {}, {}, {}) } } \ No newline at end of file diff --git a/app/src/main/java/org/fairscan/app/ui/screens/about/AboutUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/about/AboutUiState.kt new file mode 100644 index 0000000..b82d379 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/about/AboutUiState.kt @@ -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 . + */ +package org.fairscan.app.ui.screens.about + +import androidx.compose.runtime.Immutable + +@Immutable +data class AboutUiState( + val hasLastCapturedImage: Boolean = false, +) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/about/AboutViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/about/AboutViewModel.kt index 7135cc6..d3a9d22 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/about/AboutViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/about/AboutViewModel.kt @@ -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() 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) + } + } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/about/Emails.kt b/app/src/main/java/org/fairscan/app/ui/screens/about/Emails.kt new file mode 100644 index 0000000..29d7aba --- /dev/null +++ b/app/src/main/java/org/fairscan/app/ui/screens/about/Emails.kt @@ -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 . + */ +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) +} diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 10de839..939cbfc 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -52,6 +52,8 @@ Sdílet Sdílet dokument Nelze uložit soubor: oprávnění bylo odmítnuto + Podpora + Nahlásit problém s posledním zachyceným obrázkem Vypnout svítilnu Zapnout svítilnu Neznámá velikost diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ba5eb16..82fad44 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -52,6 +52,8 @@ Teilen Dokument teilen Datei kann nicht gespeichert werden: Berechtigung verweigert + Support + Problem mit dem zuletzt aufgenommenen Bild melden Taschenlampe ausschalten Taschenlampe einschalten Unbekannte Größe diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6684f84..30a90ca 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -52,6 +52,8 @@ Compartir Compartir documento No se puede guardar el archivo: permiso denegado + Soporte + Informar de un problema con la última imagen capturada Apagar linterna Encender linterna Tamaño desconocido diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2b4dbe4..8b21965 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -52,6 +52,8 @@ Partager Partager le document Impossible d’enregistrer le fichier : permission refusée + Support + Signaler un problème avec la dernière image capturée Éteindre la torche Allumer la torche Taille inconnue diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b4406af..ac9b1fe 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -52,6 +52,8 @@ Condividi Condividi documento Impossibile salvare il file: permesso negato + Supporto + Segnala un problema con l’ultima immagine acquisita Spegni la torcia Accendi la torcia Dimensione sconosciuta diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3e4b013..882ac37 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -52,6 +52,8 @@ Compartilhar Compartilhar documento Não foi possível salvar o arquivo: permissão negada + Suporte + Relatar um problema com a última imagem capturada Desligar lanterna Ligar lanterna Tamanho desconhecido diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7bbc9a8..59a0ffb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -52,6 +52,8 @@ Поделиться Поделиться документом Невозможно сохранить файл: доступ запрещён + Поддержка + Сообщить о проблеме с последним сделанным изображением Выключить фонарик Включить фонарик Неизвестный размер diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a095506..393243f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -52,6 +52,8 @@ 分享 分享文件 無法儲存檔案:權限遭拒 + 支援 + 回報最近拍攝影像的問題 關閉閃光燈 開啟閃光燈 未知大小 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index da54a55..7bc1244 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -52,6 +52,8 @@ 共享 分享文档 无法保存文件:权限被拒绝 + 支持 + 报告最近拍摄图像的问题 关闭手电筒 打开手电筒 未知大小 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 380d383..126cff6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,6 +56,8 @@ Share Share document Cannot save file: permission was denied + Support + Report a problem with last captured image Turn off torch Turn on torch Unknown size diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index a4dae74..763b0d4 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -6,4 +6,8 @@ + + diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index 4deab91..9524d18 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -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)