CameraScreen: Landscape mode

This commit is contained in:
Pierre-Yves Nicolas
2025-07-13 10:02:01 +02:00
parent b37bca4c5c
commit d0a77cfd3d
3 changed files with 184 additions and 112 deletions

View File

@@ -14,6 +14,7 @@
*/
package org.mydomain.myscan.view
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
@@ -32,13 +33,16 @@ 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.WindowInsets
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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@@ -61,6 +65,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
@@ -89,6 +94,7 @@ data class CameraUiState(
val liveAnalysisState: LiveAnalysisState,
val captureState: CaptureState,
val showDetectionError: Boolean,
val isLandscape: Boolean,
val isDebugMode: Boolean
)
@@ -136,6 +142,7 @@ fun CameraScreen(
listState.animateScrollToItem(pageIds.lastIndex)
}
}
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
CameraScreenScaffold(
cameraPreview = {
CameraPreview(
@@ -144,21 +151,20 @@ fun CameraScreen(
onPreviewViewReady = { view -> previewView = view }
)
},
pageList = {
CommonPageList(
pageListState =
CommonPageListState(
pageIds = pageIds,
imageLoader = { id -> viewModel.getBitmap(id) },
onPageClick = { index -> viewModel.navigateTo(Screen.Document(index)) },
listState = listState,
onLastItemPosition =
{ offset -> thumbnailCoords.value = offset }
)
},
onLastItemPosition = { offset -> thumbnailCoords.value = offset },
),
cameraUiState = CameraUiState(
pageIds.size,
liveAnalysisState,
captureState,
showDetectionError,
isLandscape = isLandscape,
isDebugMode),
onCapture = {
previewView?.bitmap?.let {
@@ -179,7 +185,7 @@ fun CameraScreen(
@Composable
private fun CameraScreenScaffold(
cameraPreview: @Composable () -> Unit,
pageList: @Composable () -> Unit,
pageListState: CommonPageListState,
cameraUiState: CameraUiState,
onCapture: () -> Unit,
onFinalizePressed: () -> Unit,
@@ -187,33 +193,58 @@ private fun CameraScreenScaffold(
thumbnailCoords: MutableState<Offset>,
toAboutScreen: () -> Unit,
) {
Box {
Scaffold(
bottomBar = {
CameraScreenFooter(
pageList = pageList,
val documentBar : @Composable () -> Unit = {
DocumentBar(
pageListState = pageListState,
pageCount = cameraUiState.pageCount,
onFinalizePressed = onFinalizePressed,
onDebugModeSwitched = onDebugModeSwitched,
isLandscape = cameraUiState.isLandscape
)
}
) { innerPadding ->
Box(
modifier = Modifier
.padding(bottom = innerPadding.calculateBottomPadding())
.fillMaxSize()
) {
CameraPreviewWithOverlay(cameraPreview, cameraUiState, Modifier.align(Alignment.BottomCenter))
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
Box {
if (!cameraUiState.isLandscape) {
Scaffold(
bottomBar = documentBar
) { padding ->
val modifier = Modifier.padding(bottom = padding.calculateBottomPadding()).fillMaxSize()
CameraPreviewBox(cameraPreview, cameraUiState, onCapture, modifier)
}
} else {
Scaffold { innerPadding ->
Row(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
CameraPreviewBox(cameraPreview, cameraUiState, onCapture, Modifier)
documentBar()
}
}
}
AboutScreenNavButton(
onClick = toAboutScreen,
modifier = Modifier.align(Alignment.TopEnd)
modifier = Modifier.align(Alignment.TopEnd).windowInsetsPadding(WindowInsets.safeDrawing)
)
if (cameraUiState.captureState is CaptureState.CapturePreview) {
CapturedImage(cameraUiState.captureState.processed.asImageBitmap(), thumbnailCoords)
}
}
}
@Composable
private fun CameraPreviewBox(
cameraPreview: @Composable (() -> Unit),
cameraUiState: CameraUiState,
onCapture: () -> Unit,
modifier: Modifier,
) {
Box(
modifier = modifier
) {
CameraPreviewWithOverlay(
cameraPreview,
cameraUiState,
Modifier.align(Alignment.BottomCenter)
)
if (cameraUiState.isDebugMode) {
MessageBox(cameraUiState.liveAnalysisState.inferenceTime)
}
@@ -225,11 +256,6 @@ private fun CameraScreenScaffold(
)
}
}
if (cameraUiState.captureState is CaptureState.CapturePreview) {
CapturedImage(cameraUiState.captureState.processed.asImageBitmap(), thumbnailCoords)
}
}
}
@Composable
private fun CapturedImage(image: ImageBitmap, thumbnailCoords: MutableState<Offset>) {
@@ -319,8 +345,12 @@ private fun CameraPreviewWithOverlay(
modifier: Modifier,
) {
val captureState = cameraUiState.captureState
val width = LocalConfiguration.current.screenWidthDp
val height = width / 3 * 4
var width = LocalConfiguration.current.screenWidthDp
var height = width * 4 / 3
if (cameraUiState.isLandscape) {
height = LocalConfiguration.current.screenHeightDp
width = height * 4 / 3
}
var showShutter by remember { mutableStateOf(false) }
LaunchedEffect(captureState.frozenImage) {
@@ -366,7 +396,6 @@ private fun CameraPreviewWithOverlay(
)
}
}
}
}
@@ -382,11 +411,12 @@ fun MessageBox(inferenceTime: Long) {
}
@Composable
fun CameraScreenFooter(
pageList: @Composable () -> Unit,
fun DocumentBar(
pageListState: CommonPageListState,
pageCount: Int,
onFinalizePressed: () -> Unit,
onDebugModeSwitched: () -> Unit,
isLandscape: Boolean,
) {
var tapCount by remember { mutableStateOf(0) }
var lastTapTime by remember { mutableStateOf(0L) }
@@ -405,17 +435,41 @@ fun CameraScreenFooter(
lastTapTime = currentTime
}
Column (modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
pageList()
Column (
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
CommonPageList(pageListState, Modifier.weight(1f))
BottomAppBar(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
if (isLandscape) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Bar(pageCount, onPageCountClick, onFinalizePressed)
}
} else {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 1.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Bar(pageCount, onPageCountClick, onFinalizePressed)
}
}
}
}
}
@Composable
private fun Bar(
pageCount: Int,
onPageCountClick: () -> Unit,
onFinalizePressed: () -> Unit,
) {
Text(
text = pageCountText(pageCount),
@@ -429,9 +483,6 @@ fun CameraScreenFooter(
icon = Icons.AutoMirrored.Filled.Article,
)
}
}
}
}
@Preview(showBackground = true)
@Composable
@@ -447,8 +498,14 @@ fun CameraScreenPreviewWithProcessedImage() {
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg")))
}
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
@Composable
private fun ScreenPreview(captureState: CaptureState) {
fun CameraScreenPreviewInLandscapeMode() {
ScreenPreview(CaptureState.Idle, rotationDegrees = 90f)
}
@Composable
private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0f) {
val context = LocalContext.current
MyScanTheme {
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
@@ -462,12 +519,13 @@ private fun ScreenPreview(captureState: CaptureState) {
) {
Image(
debugImage("uncropped/img01.jpg").asImageBitmap(),
modifier=Modifier.rotate(rotationDegrees),
contentDescription = null
)
}
},
pageList = {
CommonPageList(
pageListState =
CommonPageListState(
pageIds = listOf(1, 2, 2, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" },
imageLoader = { id ->
context.assets.open(id).use { input ->
@@ -476,9 +534,9 @@ private fun ScreenPreview(captureState: CaptureState) {
},
onPageClick = {},
listState = LazyListState(),
)
},
cameraUiState = CameraUiState(pageCount = 4, LiveAnalysisState(), captureState, false, false),
),
cameraUiState = CameraUiState(pageCount = 4, LiveAnalysisState(), captureState,
false, rotationDegrees > 0, false),
onCapture = {},
onFinalizePressed = {},
onDebugModeSwitched = {},

View File

@@ -29,6 +29,7 @@ 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.Add
@@ -201,10 +202,13 @@ private fun PageList(
) {
Box {
CommonPageList(
CommonPageListState(
pageIds,
imageLoader,
onPageClick = { index -> currentPageIndex.value = index },
currentPageIndex = currentPageIndex.value,
listState = rememberLazyListState()
)
)
SecondaryActionButton(
icon = Icons.Default.Add,

View File

@@ -14,6 +14,7 @@
*/
package org.mydomain.myscan.view
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -26,10 +27,10 @@ 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.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -40,6 +41,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
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.text.style.TextAlign
import androidx.compose.ui.unit.Density
@@ -48,42 +50,55 @@ import androidx.compose.ui.unit.sp
const val PAGE_LIST_ELEMENT_SIZE_DP = 120
data class CommonPageListState(
val pageIds: List<String>,
val imageLoader: (String) -> Bitmap?,
val onPageClick: (Int) -> Unit,
val listState: LazyListState,
val currentPageIndex: Int? = null,
val onLastItemPosition: ((Offset) -> Unit)? = null,
)
@Composable
fun CommonPageList(
pageIds: List<String>,
imageLoader: (String) -> Bitmap?,
onPageClick: (Int) -> Unit,
listState: LazyListState = rememberLazyListState(),
currentPageIndex: Int? = null,
onLastItemPosition: ((Offset) -> Unit)? = null,
state: CommonPageListState,
modifier: Modifier = Modifier,
) {
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
if (isLandscape) {
LazyColumn (
modifier = modifier
) {
itemsIndexed(state.pageIds) { index, id ->
// TODO Use small images rather than big ones
val image = state.imageLoader(id)
if (image != null) {
PageThumbnail(image, index, state)
}
}
}
} else {
LazyRow (
state = listState,
state = state.listState,
contentPadding = PaddingValues(4.dp),
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainer),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
itemsIndexed(pageIds) { index, id ->
itemsIndexed(state.pageIds) { index, id ->
// TODO Use small images rather than big ones
val image = imageLoader(id)
val image = state.imageLoader(id)
if (image != null) {
PageThumbnail(
image,
index,
currentPageIndex,
pageIds.lastIndex,
onLastItemPosition,
onPageClick
)
PageThumbnail(image, index, state)
}
}
}
if (pageIds.isEmpty()) {
}
if (state.pageIds.isEmpty()) {
Box(
modifier = Modifier
.height(120.dp)
.addPositionCallback(onLastItemPosition, LocalDensity.current, 0.5f)
.addPositionCallback(state.onLastItemPosition, LocalDensity.current, 0.5f)
) {}
}
}
@@ -92,13 +107,10 @@ fun CommonPageList(
private fun PageThumbnail(
image: Bitmap,
index: Int,
currentPageIndex: Int?,
lastIndex: Int,
onLastItemPosition: ((Offset) -> Unit)?,
onPageClick: (Int) -> Unit,
state: CommonPageListState,
) {
val bitmap = image.asImageBitmap()
val isSelected = index == currentPageIndex
val isSelected = index == state.currentPageIndex
val borderColor =
if (isSelected) MaterialTheme.colorScheme.secondary else Color.Transparent
val maxImageSize = PAGE_LIST_ELEMENT_SIZE_DP.dp
@@ -107,9 +119,9 @@ private fun PageThumbnail(
Modifier.height(maxImageSize)
else
Modifier.width(maxImageSize)
if (index == lastIndex) {
if (index == state.pageIds.lastIndex) {
val density = LocalDensity.current
modifier = modifier.addPositionCallback(onLastItemPosition, density, 1.0f)
modifier = modifier.addPositionCallback(state.onLastItemPosition, density, 1.0f)
}
Box (modifier = Modifier.height(PAGE_LIST_ELEMENT_SIZE_DP.dp)) {
Image(
@@ -119,12 +131,11 @@ private fun PageThumbnail(
.align(Alignment.Center)
.padding(4.dp)
.border(2.dp, borderColor)
.clickable { onPageClick(index) }
.clickable { state.onPageClick(index) }
)
Box(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(4.dp))
.padding(vertical = 0.dp, horizontal = 8.dp)
@@ -134,7 +145,6 @@ private fun PageThumbnail(
color = Color.White,
fontSize = 10.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}