New SettingsScreen with export dir preference

This commit is contained in:
Pierre-Yves Nicolas
2025-11-27 17:40:03 +01:00
committed by pynicolas
parent 96b2d5b830
commit 7c9267a866
15 changed files with 371 additions and 37 deletions

View File

@@ -116,6 +116,7 @@ dependencies {
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.protobuf.javalite)
implementation(libs.litert)
implementation(libs.litert.support)

View File

@@ -32,6 +32,8 @@ import org.fairscan.app.ui.screens.about.AboutViewModel
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.export.ExportViewModel
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
class FairScanApp : Application() {
@@ -58,6 +60,7 @@ class AppContainer(context: Context) {
val logger = FileLogger(logRepository)
val imageSegmentationService = ImageSegmentationService(context, logger)
val recentDocumentsDataStore = context.recentDocumentsDataStore
val settingsRepository = SettingsRepository(context)
@Suppress("UNCHECKED_CAST")
inline fun <reified VM : ViewModel> viewModelFactory(
@@ -73,4 +76,5 @@ class AppContainer(context: Context) {
val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) }
val exportViewModelFactory = viewModelFactory { ExportViewModel(it) }
val aboutViewModelFactory = viewModelFactory { AboutViewModel(it) }
val settingsViewModelFactory = viewModelFactory { SettingsViewModel(it) }
}

View File

@@ -67,6 +67,8 @@ import org.fairscan.app.ui.screens.export.ExportViewModel
import org.fairscan.app.ui.screens.export.PdfGenerationActions
import org.fairscan.app.ui.screens.home.HomeScreen
import org.fairscan.app.ui.screens.home.HomeViewModel
import org.fairscan.app.ui.screens.settings.SettingsScreen
import org.fairscan.app.ui.screens.settings.SettingsViewModel
import org.fairscan.app.ui.theme.FairScanTheme
import org.opencv.android.OpenCVLoader
@@ -83,6 +85,8 @@ class MainActivity : ComponentActivity() {
val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory }
val exportViewModel: ExportViewModel by viewModels { appContainer.exportViewModelFactory }
val aboutViewModel: AboutViewModel by viewModels { appContainer.aboutViewModelFactory }
val settingsViewModel: SettingsViewModel
by viewModels { appContainer.settingsViewModelFactory }
lifecycleScope.launch(Dispatchers.IO) {
exportViewModel.cleanUpOldPdfs(1000 * 3600)
}
@@ -158,10 +162,34 @@ class MainActivity : ComponentActivity() {
is Screen.Overlay.Libraries -> {
LibrariesScreen(onBack = navigation.back)
}
is Screen.Overlay.Settings -> {
SettingsScreenWrapper(settingsViewModel, navigation)
}
}
}
}
}
@Composable
private fun SettingsScreenWrapper(settingsViewModel: SettingsViewModel, nav: Navigation) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
if (uri != null) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(uri, flags)
settingsViewModel.setExportDirUri(uri.toString())
}
}
val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(
settingsUiState,
onChooseDirectoryClick = { launcher.launch(null) },
onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
onBack = nav.back
)
}
@Composable
private fun CollectAboutEvents(
@@ -303,5 +331,6 @@ private fun navigation(viewModel: MainViewModel): Navigation = Navigation(
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
toLibrariesScreen = { viewModel.navigateTo(Screen.Overlay.Libraries) },
toSettingsScreen = { viewModel.navigateTo(Screen.Overlay.Settings) },
back = { viewModel.navigateBack() }
)

View File

@@ -24,6 +24,7 @@ sealed class Screen {
sealed class Overlay : Screen() {
object About : Overlay()
object Libraries : Overlay()
object Settings : Overlay()
}
}
@@ -34,6 +35,7 @@ data class Navigation(
val toExportScreen: () -> Unit,
val toAboutScreen: () -> Unit,
val toLibrariesScreen: () -> Unit,
val toSettingsScreen: () -> Unit,
val back: () -> Unit,
)

View File

@@ -21,7 +21,7 @@ import kotlinx.collections.immutable.persistentListOf
import org.fairscan.app.ui.state.DocumentUiModel
fun dummyNavigation(): Navigation {
return Navigation({}, {}, {}, {}, {}, {}, {})
return Navigation({}, {}, {}, {}, {}, {}, {}, {})
}
fun fakeDocument(): DocumentUiModel {

View File

@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
@@ -85,19 +84,3 @@ fun BackButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
)
}
}
@Composable
fun AboutScreenNavButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(
onClick = onClick,
modifier = modifier
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = stringResource(R.string.about),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
}
}

View File

@@ -26,18 +26,32 @@ import androidx.compose.foundation.layout.fillMaxWidth
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.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import org.fairscan.app.ui.Navigation
@Composable
fun MyScaffold(
toAboutScreen: () -> Unit,
navigation: Navigation,
pageListState: CommonPageListState,
pageListButton: (@Composable () -> Unit)? = null,
bottomBar: @Composable () -> Unit,
@@ -69,8 +83,8 @@ fun MyScaffold(
.windowInsetsPadding(WindowInsets.safeDrawing)
)
}
AboutScreenNavButton(
onClick = toAboutScreen,
AppOverflowMenu(
navigation,
modifier = Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
@@ -86,11 +100,11 @@ fun DocumentBar(
pageListButton: (@Composable () -> Unit)? = null,
) {
val isLandscape = isLandscape(LocalConfiguration.current)
Column (
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
Box (
Box(
if (isLandscape)
Modifier
.weight(1f)
@@ -144,3 +158,45 @@ fun DocumentBar(
fun isLandscape(configuration: Configuration): Boolean {
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
}
@Composable
fun AppOverflowMenu(
navigation: Navigation,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
Box(
modifier
) {
IconButton(onClick = { expanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
) {
DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
text = { Text("Settings") },
onClick = {
expanded = false
navigation.toSettingsScreen()
}
)
DropdownMenuItem(
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null) },
text = { Text("About") },
onClick = {
expanded = false
navigation.toAboutScreen()
}
)
}
}
}

View File

@@ -95,7 +95,7 @@ fun DocumentScreen(
}
MyScaffold(
toAboutScreen = navigation.toAboutScreen,
navigation = navigation,
pageListState = CommonPageListState(
document,
onPageClick = { index -> currentPageIndex.intValue = index },
@@ -103,7 +103,6 @@ fun DocumentScreen(
currentPageIndex = currentPageIndex.intValue,
listState = listState,
),
onBack = navigation.back,
bottomBar = {
BottomBar(navigation)
},

View File

@@ -221,9 +221,8 @@ private fun CameraScreenScaffold(
Box {
MyScaffold(
toAboutScreen = navigation.toAboutScreen,
navigation = navigation,
pageListState = pageListState,
onBack = navigation.back,
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed) }
) { modifier ->
CameraPreviewBox(

View File

@@ -69,7 +69,7 @@ import androidx.core.net.toUri
import org.fairscan.app.R
import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.components.AboutScreenNavButton
import org.fairscan.app.ui.components.AppOverflowMenu
import org.fairscan.app.ui.components.BackButton
import org.fairscan.app.ui.components.MainActionButton
import org.fairscan.app.ui.components.NewDocumentDialog
@@ -154,7 +154,7 @@ fun ExportScreen(
title = { Text(stringResource(R.string.export_pdf)) },
navigationIcon = { BackButton(navigation.back) },
actions = {
AboutScreenNavButton(onClick = navigation.toAboutScreen)
AppOverflowMenu(navigation)
}
)
}

View File

@@ -55,13 +55,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.persistentListOf
import org.fairscan.app.ui.components.CameraPermissionState
import org.fairscan.app.ui.Navigation
import org.fairscan.app.R
import org.fairscan.app.ui.components.rememberCameraPermissionState
import org.fairscan.app.ui.components.AboutScreenNavButton
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
@@ -82,9 +82,7 @@ fun HomeScreen(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = {
AboutScreenNavButton(onClick = navigation.toAboutScreen)
}
actions = { AppOverflowMenu(navigation) }
)
},
) { padding ->

View File

@@ -0,0 +1,44 @@
/*
* 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.settings
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings")
class SettingsRepository(private val context: Context) {
private val EXPORT_DIR_URI = stringPreferencesKey("export_dir_uri")
val exportDirUri: Flow<String?> =
context.dataStore.data.map { prefs ->
prefs[EXPORT_DIR_URI]
}
suspend fun setExportDirUri(uri: String?) {
context.dataStore.edit { prefs ->
if (uri == null) {
prefs.remove(EXPORT_DIR_URI)
} else {
prefs[EXPORT_DIR_URI] = uri
}
}
}
}

View File

@@ -0,0 +1,173 @@
/*
* 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.settings
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import org.fairscan.app.ui.components.BackButton
import org.fairscan.app.ui.theme.FairScanTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
uiState: SettingsUiState,
onChooseDirectoryClick: () -> Unit,
onResetExportDirClick: () -> Unit,
onBack: () -> Unit,
) {
BackHandler { onBack() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = { BackButton(onBack) },
)
}
) { paddingValues ->
SettingsContent(uiState, onChooseDirectoryClick, onResetExportDirClick, modifier = Modifier.padding(paddingValues))
}
}
@Composable
private fun SettingsContent(
uiState: SettingsUiState,
onChooseDirectoryClick: () -> Unit,
onResetExportDirClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val folderName = remember(uiState.exportDirUri) {
extractFolderName(uiState.exportDirUri)
}
Column(
modifier
.fillMaxSize()
.padding(20.dp)
) {
DirectorySettingItem(
label = "Export directory",
folderName = folderName,
onClick = onChooseDirectoryClick
)
Spacer(Modifier.height(12.dp))
if (uiState.exportDirUri != null) {
OutlinedButton(
onClick = onResetExportDirClick,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
) {
Text("Reset to default")
}
}
}
}
@Composable
fun DirectorySettingItem(
label: String,
folderName: String,
onClick: () -> Unit,
) {
Column {
Text(
text = label,
style = MaterialTheme.typography.titleLarge
)
Spacer(Modifier.height(8.dp))
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = MaterialTheme.shapes.medium
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = folderName,
style = MaterialTheme.typography.bodyLarge
)
Icon(
Icons.Default.Folder,
contentDescription = "Change directory",
)
}
}
}
}
private fun extractFolderName(uriString: String?): String {
if (uriString == null) return "Downloads (default)"
return runCatching {
val uri = uriString.toUri()
uri.lastPathSegment?.substringAfter(':')?.substringAfter('/') ?: uriString
}.getOrElse { uriString }
}
@Preview
@Composable
fun SettingsScreenPreviewWithoutDir() {
SettingsScreenPreview(SettingsUiState(null))
}
@Preview
@Composable
fun SettingsScreenPreviewWithDir() {
SettingsScreenPreview(SettingsUiState("content://root/dir"))
}
@Composable
fun SettingsScreenPreview(uiState: SettingsUiState) {
FairScanTheme {
SettingsScreen(uiState, onChooseDirectoryClick = {}, onResetExportDirClick = {}, onBack= {})
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.fairscan.app.AppContainer
data class SettingsUiState(
val exportDirUri: String? = null
)
class SettingsViewModel(container: AppContainer) : ViewModel() {
private val repo = container.settingsRepository
val uiState: StateFlow<SettingsUiState> =
repo.exportDirUri
.map { uri -> SettingsUiState(exportDirUri = uri) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
SettingsUiState()
)
fun setExportDirUri(uri: String?) {
viewModelScope.launch {
repo.setExportDirUri(uri)
}
}
}

View File

@@ -10,7 +10,7 @@ lifecycleRuntimeKtx = "2.9.3"
activityCompose = "1.10.1"
composeBom = "2025.08.01"
camerax = "1.4.2"
datastore = "1.1.7"
datastore = "1.2.0"
litert = "1.4.0"
opencv = "4.12.0"
assertj = "3.27.4"
@@ -46,6 +46,7 @@ androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2",
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
androidx-datastore = { group = "androidx.datastore", name = "datastore" , version.ref = "datastore" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences" , version.ref = "datastore" }
protobuf-javalite = { group = "com.google.protobuf", name="protobuf-javalite", version.ref = "protobufJavaLite"}
litert = { group = "com.google.ai.edge.litert", name = "litert", version.ref = "litert" }
litert-support = { group = "com.google.ai.edge.litert", name = "litert-support", version.ref = "litert" }