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

View File

@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
@@ -201,10 +202,13 @@ private fun PageList(
) { ) {
Box { Box {
CommonPageList( CommonPageList(
CommonPageListState(
pageIds, pageIds,
imageLoader, imageLoader,
onPageClick = { index -> currentPageIndex.value = index }, onPageClick = { index -> currentPageIndex.value = index },
currentPageIndex = currentPageIndex.value, currentPageIndex = currentPageIndex.value,
listState = rememberLazyListState()
)
) )
SecondaryActionButton( SecondaryActionButton(
icon = Icons.Default.Add, icon = Icons.Default.Add,

View File

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