Skip to content

Commit 4246852

Browse files
committed
Home screen
1 parent e74bbcd commit 4246852

File tree

12 files changed

+343
-50
lines changed

12 files changed

+343
-50
lines changed

app/src/main/java/org/mydomain/myscan/MainActivity.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import org.mydomain.myscan.ui.theme.MyScanTheme
4040
import org.mydomain.myscan.view.AboutScreen
4141
import org.mydomain.myscan.view.CameraScreen
4242
import org.mydomain.myscan.view.DocumentScreen
43+
import org.mydomain.myscan.view.HomeScreen
4344
import org.mydomain.myscan.view.LibrariesScreen
4445
import org.opencv.android.OpenCVLoader
4546

@@ -61,16 +62,26 @@ class MainActivity : ComponentActivity() {
6162
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
6263
MyScanTheme {
6364
val navigation = Navigation(
65+
toHomeScreen = { viewModel.navigateTo(Screen.Home) },
6466
toCameraScreen = { viewModel.navigateTo(Screen.Camera) },
6567
toDocumentScreen = { viewModel.navigateTo(Screen.Document()) },
6668
toAboutScreen = { viewModel.navigateTo(Screen.About) },
6769
toLibrariesScreen = { viewModel.navigateTo(Screen.Libraries) },
6870
back = { viewModel.navigateBack() }
6971
)
7072
when (val screen = currentScreen) {
73+
is Screen.Home -> {
74+
HomeScreen(
75+
hasCameraPermission = hasCameraPermission(this),
76+
currentDocument = document,
77+
navigation = navigation,
78+
onStartNewScan = navigation.toCameraScreen,
79+
)
80+
}
7181
is Screen.Camera -> {
7282
CameraScreen(
7383
viewModel,
84+
navigation,
7485
liveAnalysisState,
7586
onImageAnalyzed = { image -> viewModel.liveAnalysis(image) },
7687
onFinalizePressed = { viewModel.navigateTo(Screen.Document()) },
@@ -92,7 +103,7 @@ class MainActivity : ComponentActivity() {
92103
),
93104
onStartNew = {
94105
viewModel.startNewDocument()
95-
viewModel.navigateTo(Screen.Camera) },
106+
viewModel.navigateTo(Screen.Home) },
96107
onDeleteImage = { id -> viewModel.deletePage(id) }
97108
)
98109
}

app/src/main/java/org/mydomain/myscan/MainViewModel.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ class MainViewModel(
6868
val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow()
6969
private var lastSuccessfulLiveAnalysisState: LiveAnalysisState? = null
7070

71-
private val _screenStack = MutableStateFlow<List<Screen>>(listOf(Screen.Camera))
71+
private val _screenStack = MutableStateFlow<List<Screen>>(listOf(Screen.Home))
7272
val currentScreen: StateFlow<Screen> = _screenStack.map { it.last() }
73-
.stateIn(viewModelScope, SharingStarted.Eagerly, Screen.Camera)
73+
.stateIn(viewModelScope, SharingStarted.Eagerly, _screenStack.value.last())
7474

7575
private val _pageIds = MutableStateFlow(imageRepository.imageIds())
7676
val documentUiModel: StateFlow<DocumentUiModel> =

app/src/main/java/org/mydomain/myscan/Navigation.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
package org.mydomain.myscan
1616

1717
sealed class Screen {
18+
object Home : Screen()
1819
object Camera : Screen()
1920
data class Document(val initialPage: Int = 0) : Screen()
2021
object About : Screen()
2122
object Libraries : Screen()
2223
}
2324

2425
data class Navigation(
26+
val toHomeScreen: () -> Unit,
2527
val toCameraScreen: () -> Unit,
2628
val toDocumentScreen: () -> Unit,
2729
val toAboutScreen: () -> Unit,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright 2025 Pierre-Yves Nicolas
3+
*
4+
* This program is free software: you can redistribute it and/or modify it
5+
* under the terms of the GNU General Public License as published by the Free
6+
* Software Foundation, either version 3 of the License, or (at your option)
7+
* any later version.
8+
* This program is distributed in the hope that it will be useful, but WITHOUT
9+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
10+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
11+
* more details.
12+
* You should have received a copy of the GNU General Public License along with
13+
* this program. If not, see <https://www.gnu.org/licenses/>.
14+
*/
15+
package org.mydomain.myscan
16+
17+
import android.Manifest
18+
import android.content.Context
19+
import android.content.pm.PackageManager
20+
import android.widget.Toast
21+
import androidx.activity.compose.ManagedActivityResultLauncher
22+
import androidx.activity.compose.rememberLauncherForActivityResult
23+
import androidx.activity.result.contract.ActivityResultContracts
24+
import androidx.compose.runtime.Composable
25+
import androidx.compose.ui.platform.LocalContext
26+
import androidx.core.content.ContextCompat
27+
28+
fun hasCameraPermission(context: Context): Boolean {
29+
val camera = Manifest.permission.CAMERA
30+
return ContextCompat.checkSelfPermission(context, camera) == PackageManager.PERMISSION_GRANTED
31+
}
32+
33+
@Composable
34+
fun rememberCameraPermissionLauncher(
35+
onGranted: () -> Unit = {},
36+
onDenied: () -> Unit = {}
37+
): ManagedActivityResultLauncher<String, Boolean> {
38+
val context = LocalContext.current
39+
return rememberLauncherForActivityResult (
40+
ActivityResultContracts.RequestPermission()
41+
) { isGranted ->
42+
if (isGranted) {
43+
onGranted()
44+
} else {
45+
onDenied()
46+
Toast.makeText(
47+
context,
48+
context.getString(R.string.camera_permission_denied),
49+
Toast.LENGTH_SHORT
50+
).show()
51+
}
52+
}
53+
}

app/src/main/java/org/mydomain/myscan/view/Buttons.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.size
1919
import androidx.compose.foundation.layout.width
2020
import androidx.compose.material.icons.Icons
2121
import androidx.compose.material.icons.automirrored.filled.ArrowBack
22-
import androidx.compose.material.icons.outlined.Info
22+
import androidx.compose.material.icons.filled.Info
2323
import androidx.compose.material3.Button
2424
import androidx.compose.material3.FilledIconButton
2525
import androidx.compose.material3.Icon
@@ -38,13 +38,15 @@ import org.mydomain.myscan.R
3838
fun MainActionButton(
3939
onClick: () -> Unit,
4040
text: String,
41-
icon: ImageVector,
41+
icon: ImageVector? = null,
4242
modifier: Modifier = Modifier,
4343
iconDescription: String? = null,
4444
enabled: Boolean = true,
4545
) {
4646
Button(onClick = onClick, enabled = enabled, modifier = modifier) {
47-
Icon(icon, contentDescription = iconDescription)
47+
icon?.let {
48+
Icon(icon, contentDescription = iconDescription)
49+
}
4850
Spacer(Modifier.width(8.dp))
4951
Text(text)
5052
}
@@ -93,7 +95,7 @@ fun AboutScreenNavButton(
9395
modifier = modifier
9496
) {
9597
Icon(
96-
imageVector = Icons.Outlined.Info,
98+
imageVector = Icons.Default.Info,
9799
contentDescription = stringResource(R.string.about),
98100
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
99101
}

app/src/main/java/org/mydomain/myscan/view/CameraPreview.kt

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,10 @@
1414
*/
1515
package org.mydomain.myscan.view
1616

17-
import android.content.pm.PackageManager.PERMISSION_GRANTED
1817
import android.graphics.Bitmap
1918
import android.util.Log
2019
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
2120
import android.widget.LinearLayout
22-
import android.widget.Toast
23-
import androidx.activity.compose.rememberLauncherForActivityResult
24-
import androidx.activity.result.contract.ActivityResultContracts
2521
import androidx.camera.core.CameraSelector
2622
import androidx.camera.core.ImageAnalysis
2723
import androidx.camera.core.ImageCapture
@@ -58,7 +54,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
5854
import com.google.common.util.concurrent.ListenableFuture
5955
import org.mydomain.myscan.LiveAnalysisState
6056
import org.mydomain.myscan.Point
61-
import org.mydomain.myscan.R
57+
import org.mydomain.myscan.hasCameraPermission
58+
import org.mydomain.myscan.rememberCameraPermissionLauncher
6259
import org.mydomain.myscan.scaledTo
6360
import java.util.concurrent.ExecutorService
6461
import java.util.concurrent.Executors
@@ -71,18 +68,10 @@ fun CameraPreview(
7168
onPreviewViewReady: (PreviewView) -> Unit,
7269
) {
7370
val context = LocalContext.current
74-
val requestPermissionLauncher = rememberLauncherForActivityResult(
75-
ActivityResultContracts.RequestPermission()
76-
) { isGranted: Boolean ->
77-
if (!isGranted) {
78-
Toast.makeText(context,
79-
context.getString(R.string.camera_permission_denied), Toast.LENGTH_SHORT).show()
80-
}
81-
}
82-
71+
val requestPermissionLauncher = rememberCameraPermissionLauncher(onGranted = {}, onDenied = {})
8372
LaunchedEffect(Unit) {
8473
val camera = android.Manifest.permission.CAMERA
85-
if (ContextCompat.checkSelfPermission(context, camera) != PERMISSION_GRANTED) {
74+
if (!hasCameraPermission(context)) {
8675
requestPermissionLauncher.launch(camera)
8776
}
8877
}

app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import android.content.res.Configuration
1818
import android.graphics.Bitmap
1919
import android.graphics.BitmapFactory
2020
import android.util.Log
21+
import androidx.activity.compose.BackHandler
2122
import androidx.camera.core.ImageProxy
2223
import androidx.camera.view.PreviewView
2324
import androidx.compose.animation.core.animateFloat
@@ -77,6 +78,7 @@ import kotlinx.coroutines.delay
7778
import org.mydomain.myscan.LiveAnalysisState
7879
import org.mydomain.myscan.MainViewModel
7980
import org.mydomain.myscan.MainViewModel.CaptureState
81+
import org.mydomain.myscan.Navigation
8082
import org.mydomain.myscan.R
8183
import org.mydomain.myscan.Screen
8284
import org.mydomain.myscan.ui.theme.MyScanTheme
@@ -96,6 +98,7 @@ const val ANIMATION_DURATION = 200
9698
@Composable
9799
fun CameraScreen(
98100
viewModel: MainViewModel,
101+
navigation: Navigation,
99102
liveAnalysisState: LiveAnalysisState,
100103
onImageAnalyzed: (ImageProxy) -> Unit,
101104
onFinalizePressed: () -> Unit,
@@ -105,6 +108,8 @@ fun CameraScreen(
105108
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
106109
var isDebugMode by remember { mutableStateOf(false) }
107110

111+
BackHandler { navigation.back() }
112+
108113
val captureController = remember { CameraCaptureController() }
109114
DisposableEffect(Unit) {
110115
onDispose { captureController.shutdown() }
@@ -169,7 +174,7 @@ fun CameraScreen(
169174
onFinalizePressed = onFinalizePressed,
170175
onDebugModeSwitched = { isDebugMode = !isDebugMode },
171176
thumbnailCoords = thumbnailCoords,
172-
toAboutScreen = { viewModel.navigateTo(Screen.About) }
177+
navigation = navigation
173178
)
174179
}
175180

@@ -182,7 +187,7 @@ private fun CameraScreenScaffold(
182187
onFinalizePressed: () -> Unit,
183188
onDebugModeSwitched: () -> Unit,
184189
thumbnailCoords: MutableState<Offset>,
185-
toAboutScreen: () -> Unit,
190+
navigation: Navigation,
186191
) {
187192
var tapCount by remember { mutableStateOf(0) }
188193
var lastTapTime by remember { mutableStateOf(0L) }
@@ -203,8 +208,9 @@ private fun CameraScreenScaffold(
203208

204209
Box {
205210
MyScaffold(
206-
toAboutScreen = toAboutScreen,
211+
toAboutScreen = navigation.toAboutScreen,
207212
pageListState = pageListState,
213+
onBack = navigation.back,
208214
bottomBar = { Bar(cameraUiState.pageCount, onPageCountClick, onFinalizePressed) }
209215
) {
210216
modifier -> CameraPreviewBox(cameraPreview, cameraUiState, onCapture, modifier)
@@ -436,7 +442,6 @@ fun CameraScreenPreviewInLandscapeMode() {
436442

437443
@Composable
438444
private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0f) {
439-
val context = LocalContext.current
440445
MyScanTheme {
441446
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
442447
CameraScreenScaffold(
@@ -456,13 +461,9 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
456461
},
457462
pageListState =
458463
CommonPageListState(
459-
document = DocumentUiModel(
460-
pageIds = listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
461-
imageLoader = { id ->
462-
context.assets.open(id).use { input ->
463-
BitmapFactory.decodeStream(input)
464-
}
465-
}),
464+
document = fakeDocument(
465+
listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
466+
LocalContext.current),
466467
onPageClick = {},
467468
listState = LazyListState(),
468469
),
@@ -472,7 +473,7 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
472473
onFinalizePressed = {},
473474
onDebugModeSwitched = {},
474475
thumbnailCoords = thumbnailCoords,
475-
toAboutScreen = {}
476+
navigation = dummyNavigation()
476477
)
477478
}
478479
}

app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
*/
1515
package org.mydomain.myscan.view
1616

17-
import android.graphics.BitmapFactory
1817
import androidx.activity.compose.BackHandler
1918
import androidx.compose.foundation.Image
2019
import androidx.compose.foundation.background
@@ -31,8 +30,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
3130
import androidx.compose.foundation.shape.RoundedCornerShape
3231
import androidx.compose.material.icons.Icons
3332
import androidx.compose.material.icons.filled.Add
33+
import androidx.compose.material.icons.filled.Close
3434
import androidx.compose.material.icons.filled.PictureAsPdf
35-
import androidx.compose.material.icons.filled.RestartAlt
3635
import androidx.compose.material.icons.outlined.Delete
3736
import androidx.compose.material3.AlertDialog
3837
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -114,7 +113,7 @@ fun DocumentScreen(
114113
) { modifier ->
115114
DocumentPreview(document, currentPageIndex, onDeleteImage, modifier)
116115
if (showNewDocDialog.value) {
117-
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog)
116+
NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog, stringResource(R.string.close_document))
118117
}
119118
if (showPdfDialog.value) {
120119
PdfGenerationBottomSheetWrapper(
@@ -203,7 +202,7 @@ private fun BottomBar(
203202
)
204203
Spacer(modifier = Modifier.width(8.dp))
205204
SecondaryActionButton(
206-
icon = Icons.Default.RestartAlt,
205+
icon = Icons.Default.Close,
207206
contentDescription = stringResource(R.string.restart),
208207
onClick = { showNewDocDialog.value = true },
209208
modifier = Modifier.padding(vertical = 8.dp)
@@ -212,9 +211,9 @@ private fun BottomBar(
212211
}
213212

214213
@Composable
215-
fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>) {
214+
fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>, title: String) {
216215
AlertDialog(
217-
title = { Text(stringResource(R.string.new_document)) },
216+
title = { Text(title) },
218217
text = { Text(stringResource(R.string.new_document_warning)) },
219218
confirmButton = {
220219
TextButton (onClick = {
@@ -236,20 +235,13 @@ fun NewDocumentDialog(onConfirm: () -> Unit, showDialog: MutableState<Boolean>)
236235
@Composable
237236
@Preview
238237
fun DocumentScreenPreview() {
239-
val context = LocalContext.current
240238
MyScanTheme {
241239
DocumentScreen(
242-
DocumentUiModel(
240+
fakeDocument(
243241
listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
244-
{ id ->
245-
context.assets.open(id).use { input ->
246-
BitmapFactory.decodeStream(input)
247-
}
248-
}
249-
),
242+
LocalContext.current),
250243
initialPage = 1,
251-
navigation = Navigation(
252-
{}, {}, {}, {}, {}),
244+
navigation = dummyNavigation(),
253245
pdfActions = PdfGenerationActions(
254246
{}, {}, {},
255247
MutableStateFlow(PdfGenerationUiState()),

0 commit comments

Comments
 (0)