diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 42753cc7..1407f480 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -21,22 +21,26 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + jobs: build: name: Build & check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Actions are pinned to full commit SHAs, as required by the org's zizmor scan. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: 'zulu' java-version: '17' cache: gradle - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 # The project compiles against API 37 (Android 17); install that platform. - name: Install compileSdk 37 platform @@ -49,7 +53,7 @@ jobs: run: ./gradlew assembleDebug - name: Upload debug APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: app-debug path: app/build/outputs/apk/debug/app-debug.apk diff --git a/.gitignore b/.gitignore index 024c3439..085cff4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # built application files *.apk *.ap_ +*.aar +aar/ + # Mac files .DS_Store diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b885446..fa826146 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,7 +70,9 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.navigation.compose) + implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.navigation.runtime.ktx) implementation(libs.hilt.android) diff --git a/app/src/main/java/com/android/camera/catalog/MainActivity.kt b/app/src/main/java/com/android/camera/catalog/MainActivity.kt index 9a5c0f33..c5857daa 100644 --- a/app/src/main/java/com/android/camera/catalog/MainActivity.kt +++ b/app/src/main/java/com/android/camera/catalog/MainActivity.kt @@ -15,10 +15,16 @@ */ package com.android.camera.catalog +import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier import com.android.camera.catalog.ui.CatalogApp import com.android.camera.coretheme.AISampleCatalogTheme import dagger.hilt.android.AndroidEntryPoint @@ -26,11 +32,19 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + ) super.onCreate(savedInstanceState) - enableEdgeToEdge() setContent { - AISampleCatalogTheme { - CatalogApp() + AISampleCatalogTheme(darkTheme = true) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CatalogApp() + } } } } diff --git a/app/src/main/java/com/android/camera/catalog/ui/CatalogApp.kt b/app/src/main/java/com/android/camera/catalog/ui/CatalogApp.kt index 4e0d5860..239abac8 100644 --- a/app/src/main/java/com/android/camera/catalog/ui/CatalogApp.kt +++ b/app/src/main/java/com/android/camera/catalog/ui/CatalogApp.kt @@ -30,25 +30,28 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn 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.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -56,6 +59,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -66,17 +70,12 @@ 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.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle @@ -90,8 +89,6 @@ import com.android.camera.catalog.domain.SampleCatalogItem import com.android.camera.catalog.domain.SampleCategory import com.android.camera.catalog.domain.SampleType import com.android.camera.catalog.domain.sampleCatalog -import com.android.camera.coretheme.bodyFontFamily -import com.android.camera.coretheme.monoFontFamily import kotlinx.serialization.Serializable @Composable @@ -162,7 +159,10 @@ fun CatalogApp() { startDestination = HomeScreen, ) { composable { - Scaffold(containerColor = AppBackground) { innerPadding -> + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> AnimatedContent( targetState = hasCameraPermission, label = "PermissionAnimation", @@ -216,7 +216,7 @@ fun CatalogApp() { } } -/** The console catalog: mono header, filter pills, featured hero, and a 2-column tile grid. */ +/** The Material 3 catalog: headline top bar with status pill, tonal filter pills, hero stage, and a 2-column grid. */ @Composable private fun CatalogHome( modifier: Modifier = Modifier, @@ -234,9 +234,9 @@ private fun CatalogHome( LazyVerticalGrid( columns = GridCells.Fixed(2), modifier = modifier, - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { item(span = { GridItemSpan(maxLineSpan) }) { CatalogHeader(count = filtered.size) @@ -251,7 +251,10 @@ private fun CatalogHome( } if (cameraXTiles.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { - SectionLabel(text = "CAMERAX") + SectionLabel( + text = stringResource(R.string.section_camerax), + count = cameraXTiles.size, + ) } items(items = cameraXTiles, key = { it.route }) { item -> CatalogRowCard(catalogItem = item, onClick = { onSampleClick(item) }) @@ -259,7 +262,10 @@ private fun CatalogHome( } if (camera2Tiles.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { - SectionLabel(text = "CAMERA2") + SectionLabel( + text = stringResource(R.string.section_camera2), + count = camera2Tiles.size, + ) } items(items = camera2Tiles, key = { it.route }) { item -> CatalogRowCard(catalogItem = item, onClick = { onSampleClick(item) }) @@ -270,47 +276,45 @@ private fun CatalogHome( @Composable private fun CatalogHeader(count: Int) { - Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(24.dp) - .clip(RoundedCornerShape(7.dp)) - .background(MaterialTheme.colorScheme.primary), - ) - Spacer(modifier = Modifier.width(10.dp)) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(id = R.string.top_bar_title_expanded), - style = - TextStyle( - fontFamily = bodyFontFamily, - fontSize = 17.sp, - fontWeight = FontWeight.Bold, - ), - color = TextPrimary, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, ) - Spacer(modifier = Modifier.weight(1f)) Text( - text = count.toString(), - style = TextStyle(fontFamily = monoFontFamily, fontSize = 11.sp), - color = Color.White.copy(alpha = 0.55f), + text = stringResource(id = R.string.top_bar_subtitle), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "CAMERA2 · CAMERAX · COMPOSE", - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 9.sp, - letterSpacing = 0.12.em, - ), - color = Color.White.copy(alpha = 0.40f), - ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(8.dp) + .background(ReadyGreen, CircleShape), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(id = R.string.catalog_sample_count, count), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } } @@ -319,16 +323,24 @@ private fun FilterRow( selectedFilter: SampleCategory?, onSelect: (SampleCategory?) -> Unit, ) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(7.dp), - verticalArrangement = Arrangement.spacedBy(7.dp), + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - FilterPill(label = "All", selected = selectedFilter == null) { onSelect(null) } + FilterPill( + label = stringResource(id = R.string.filter_all), + selected = selectedFilter == null, + onClick = { onSelect(null) }, + ) SampleCategory.entries.forEach { category -> - FilterPill(label = category.label, selected = selectedFilter == category) { - onSelect(category) - } + FilterPill( + label = category.label, + selected = selectedFilter == category, + onClick = { onSelect(category) }, + ) } } } @@ -339,39 +351,44 @@ private fun FilterPill( selected: Boolean, onClick: () -> Unit, ) { - val accent = MaterialTheme.colorScheme.primary - val borderColor = if (selected) accent else Color.White.copy(alpha = 0.14f) - val textColor = if (selected) accent else Color.White.copy(alpha = 0.5f) - val shape = RoundedCornerShape(15.dp) - Box( - modifier = - Modifier - .clip(shape) - .border(1.dp, borderColor, shape) - .clickable(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 7.dp), + val colors = MaterialTheme.colorScheme + Surface( + onClick = onClick, + shape = CircleShape, + color = if (selected) colors.primaryContainer else colors.surfaceContainerHigh, + contentColor = if (selected) colors.onPrimaryContainer else colors.onSurfaceVariant, ) { Text( text = label, - style = TextStyle(fontFamily = monoFontFamily, fontSize = 10.sp), - color = textColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } } @Composable -private fun SectionLabel(text: String) { - Text( - text = text, - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 9.sp, - letterSpacing = 0.16.em, - ), - color = Color.White.copy(alpha = 0.35f), - modifier = Modifier.padding(top = 6.dp), - ) +private fun SectionLabel( + text: String, + count: Int, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(id = R.string.catalog_sample_count, count), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } @Composable @@ -380,6 +397,7 @@ private fun PermissionContent( onAction: () -> Unit, modifier: Modifier = Modifier, ) { + val colors = MaterialTheme.colorScheme Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -387,30 +405,36 @@ private fun PermissionContent( ) { Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(28.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = colors.surfaceContainerLow, ), ) { Column( modifier = Modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( - painter = painterResource(R.drawable.spark_android), - contentDescription = stringResource(id = R.string.camera_permission_title), + Box( modifier = Modifier - .height(64.dp) - .width(92.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.height(24.dp)) + .size(64.dp) + .background(colors.primaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.spark_android), + contentDescription = stringResource(id = R.string.camera_permission_title), + modifier = Modifier.size(32.dp), + tint = colors.onPrimaryContainer, + ) + } + Spacer(modifier = Modifier.height(20.dp)) Text( text = stringResource(id = R.string.camera_permission_title), style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + color = colors.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) Text( @@ -421,14 +445,16 @@ private fun PermissionContent( stringResource(id = R.string.camera_permission_rationale) }, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = colors.onSurfaceVariant, textAlign = TextAlign.Center, ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(28.dp)) Button( onClick = onAction, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), ) { Text( text = @@ -437,7 +463,7 @@ private fun PermissionContent( } else { stringResource(id = R.string.grant_permission) }, - modifier = Modifier.padding(vertical = 8.dp), + style = MaterialTheme.typography.titleMedium, ) } } diff --git a/app/src/main/java/com/android/camera/catalog/ui/CatalogRowCard.kt b/app/src/main/java/com/android/camera/catalog/ui/CatalogRowCard.kt index 4e899913..57f14a4e 100644 --- a/app/src/main/java/com/android/camera/catalog/ui/CatalogRowCard.kt +++ b/app/src/main/java/com/android/camera/catalog/ui/CatalogRowCard.kt @@ -16,105 +16,123 @@ package com.android.camera.catalog.ui import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.defaultMinSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.FilterBAndW +import androidx.compose.material.icons.rounded.PhotoCamera +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.Videocam +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp import com.android.camera.catalog.R import com.android.camera.catalog.domain.SampleCatalogItem +import com.android.camera.catalog.domain.SampleCategory import com.android.camera.catalog.domain.SampleTags import com.android.camera.catalog.domain.SampleType import com.android.camera.coretheme.AISampleCatalogTheme -import com.android.camera.coretheme.bodyFontFamily -import com.android.camera.coretheme.monoFontFamily /** - * A compact catalog tile for the 2-column grid: a mono API label (accent for CameraX, muted for - * Camera2), a category dot from the sample's first tag, and the sample title. Clickable as a whole. + * A Material 3 catalog tile for the 2-column grid, modeled after the effect tiles in + * ImageSuperResolution: a rounded `surfaceContainerHigh` card with a 36dp circular category icon, + * a subtle API/tag label, the sample title, and a 2-line description. */ @Composable fun CatalogRowCard( catalogItem: SampleCatalogItem, onClick: () -> Unit, ) { - val shape = RoundedCornerShape(11.dp) + val colors = MaterialTheme.colorScheme val isCameraX = catalogItem.type == SampleType.CAMERAX - val apiColor = - if (isCameraX) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.5f) - val dotColor = catalogItem.tags.firstOrNull()?.backgroundColor ?: Color.White.copy(alpha = 0.18f) - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(TileSurface) - .border(1.dp, Color.White.copy(alpha = 0.07f), shape) - .clickable(onClick = onClick) - .defaultMinSize(minHeight = 84.dp) - .padding(horizontal = 12.dp, vertical = 13.dp), - verticalArrangement = Arrangement.spacedBy(9.dp), + val iconContainerColor = if (isCameraX) colors.primaryContainer else colors.secondaryContainer + val iconTint = if (isCameraX) colors.onPrimaryContainer else colors.onSecondaryContainer + val rawTitle = stringResource(catalogItem.title) + val displayTitle = rawTitle.substringAfter("•", rawTitle).trim() + val badgeText = catalogItem.tags.firstOrNull()?.label ?: catalogItem.category.label + + Surface( + onClick = onClick, + shape = RoundedCornerShape(20.dp), + color = colors.surfaceContainerHigh, + contentColor = colors.onSurface, + modifier = Modifier.fillMaxWidth(), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + Column(modifier = Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(36.dp) + .background(iconContainerColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = catalogItem.category.icon(), + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(20.dp), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = badgeText, + style = MaterialTheme.typography.labelSmall, + color = if (isCameraX) colors.primary else colors.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(12.dp)) Text( - text = catalogItem.type.apiLabel(), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 8.5.sp, - fontWeight = FontWeight.Normal, - letterSpacing = 0.08.em, - ), - color = apiColor, + text = displayTitle, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - Box( - modifier = - Modifier - .size(6.dp) - .clip(CircleShape) - .background(dotColor), + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = stringResource(catalogItem.description), + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, ) } - Text( - text = stringResource(catalogItem.title), - style = - TextStyle( - fontFamily = bodyFontFamily, - fontSize = 13.5.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 15.9.sp, - ), - color = TileTextColor, - ) } } -@Preview(showBackground = true, backgroundColor = 0xFF0D0E0A) +private fun SampleCategory.icon(): ImageVector = + when (this) { + SampleCategory.IMAGES -> Icons.Rounded.PhotoCamera + SampleCategory.VIDEO -> Icons.Rounded.Videocam + SampleCategory.ML -> Icons.Rounded.AutoAwesome + SampleCategory.GRAPHICS -> Icons.Rounded.FilterBAndW + SampleCategory.EXTENSIONS -> Icons.Rounded.Extension + SampleCategory.CONTROLS -> Icons.Rounded.Tune + } + +@Preview(showBackground = true, backgroundColor = 0xFF141218) @Composable private fun CatalogRowCardPreview() { AISampleCatalogTheme { @@ -125,6 +143,7 @@ private fun CatalogRowCardPreview() { route = "CameraXImageLabelingScreen", sampleEntryScreen = { }, type = SampleType.CAMERAX, + category = SampleCategory.ML, tags = listOf(SampleTags.ML_KIT, SampleTags.ANALYSIS), ) diff --git a/app/src/main/java/com/android/camera/catalog/ui/CatalogWideCard.kt b/app/src/main/java/com/android/camera/catalog/ui/CatalogWideCard.kt index beb1083f..99109c75 100644 --- a/app/src/main/java/com/android/camera/catalog/ui/CatalogWideCard.kt +++ b/app/src/main/java/com/android/camera/catalog/ui/CatalogWideCard.kt @@ -17,16 +17,20 @@ package com.android.camera.catalog.ui import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box 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.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -36,126 +40,122 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp import com.android.camera.catalog.R import com.android.camera.catalog.domain.SampleCatalogItem import com.android.camera.catalog.domain.SampleType import com.android.camera.coretheme.AISampleCatalogTheme -import com.android.camera.coretheme.bodyFontFamily -import com.android.camera.coretheme.monoFontFamily + +internal val ReadyGreen = Color(0xFF6DD58C) /** - * The featured "hero" card for the catalog: a bordered surface with a tall key-art region, an accent - * API badge, the sample title/description, and an "OPEN ▸" affordance. The whole card is clickable. + * The featured hero card for the catalog: a Material 3 rounded stage (`surfaceContainerLow`) with a + * framed key-art preview, a translucent pill badge, the sample title/description, and a filled + * action pill. */ @Composable fun CatalogWideCard( catalogItem: SampleCatalogItem, onClick: () -> Unit, ) { - val accent = MaterialTheme.colorScheme.primary - val shape = RoundedCornerShape(16.dp) - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(FeaturedSurface) - .border(1.dp, accent.copy(alpha = 0.34f), shape) - .clickable(onClick = onClick), + val colors = MaterialTheme.colorScheme + val rawTitle = stringResource(catalogItem.title) + val displayTitle = rawTitle.substringAfter("•", rawTitle).trim() + + Surface( + onClick = onClick, + shape = RoundedCornerShape(28.dp), + color = colors.surfaceContainerLow, + contentColor = colors.onSurface, + modifier = Modifier.fillMaxWidth(), ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(130.dp), - ) { - Image( - painter = painterResource(id = catalogItem.keyArt ?: R.drawable.img_keyart_multimodal), - contentDescription = null, - modifier = Modifier.fillMaxWidth().height(130.dp), - contentScale = ContentScale.Crop, - ) - Text( - text = catalogItem.type.apiLabel(), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.10.em, - ), - color = MaterialTheme.colorScheme.onPrimary, + Column(modifier = Modifier.padding(12.dp)) { + Box( modifier = Modifier - .align(Alignment.TopStart) - .padding(10.dp) - .clip(RoundedCornerShape(7.dp)) - .background(accent) - .padding(horizontal = 9.dp, vertical = 4.dp), - ) - } - Column( - modifier = Modifier.padding(start = 15.dp, end = 15.dp, top = 14.dp, bottom = 13.dp), - ) { - Text( - text = stringResource(catalogItem.title), - style = - TextStyle( - fontFamily = bodyFontFamily, - fontSize = 19.sp, - fontWeight = FontWeight.Bold, - ), - color = TextPrimary, - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = stringResource(catalogItem.description), - style = - TextStyle( - fontFamily = bodyFontFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Normal, - lineHeight = 17.4.sp, - ), - color = Color.White.copy(alpha = 0.55f), - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "OPEN ▸", - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.12.em, - ), - color = accent, - ) + .fillMaxWidth() + .height(152.dp) + .clip(RoundedCornerShape(18.dp)), + ) { + Image( + painter = painterResource(id = catalogItem.keyArt ?: R.drawable.img_keyart_multimodal), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + Row( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(12.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(8.dp) + .background(ReadyGreen, CircleShape), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.featured_badge, catalogItem.type.apiLabel()), + color = Color.White, + style = MaterialTheme.typography.labelLarge, + ) + } + } + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, top = 14.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayTitle, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = colors.onSurface, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(catalogItem.description), + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Surface( + shape = CircleShape, + color = colors.primary, + contentColor = colors.onPrimary, + ) { + Text( + text = stringResource(R.string.open_sample_button), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), + ) + } + } } } } -/** The uppercase API label shown on catalog cards/tiles. */ +/** The human-readable API label shown on catalog cards and tiles. */ +@Composable internal fun SampleType.apiLabel(): String = when (this) { - SampleType.CAMERAX -> "CAMERAX" - SampleType.CAMERA2 -> "CAMERA2" + SampleType.CAMERAX -> stringResource(R.string.section_camerax) + SampleType.CAMERA2 -> stringResource(R.string.section_camera2) } -/** Console catalog surface tokens (see design handoff). */ -internal val AppBackground = Color(0xFF0D0E0A) -internal val TileSurface = Color(0xFF14160D) -internal val FeaturedSurface = Color(0xFF13150C) -internal val TextPrimary = Color(0xFFF1F3EA) -internal val TileTextColor = Color(0xFFEEF0E6) - -@Preview(showBackground = true, backgroundColor = 0xFF0D0E0A) +@Preview(showBackground = true, backgroundColor = 0xFF141218) @Composable private fun CatalogWideCardPreview() { AISampleCatalogTheme { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdddfc18..f55f50ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,16 +3,22 @@ Android Camera Samples Camera Samples - Open sample + CameraX · Camera2 · Jetpack Compose + %1$d samples + All + CameraX + Camera2 + Featured · %1$s + Open Close Background Image - Camera Permission - This catalog requires access to your camera to demonstrate the various capabilities of the Android Camera APIs. - Camera access is permanently denied. You must enable it in your device\'s app settings to view the catalog. - Grant Permission - Open Settings + Camera access + This catalog requires access to your camera to demonstrate the capabilities of the Android Camera APIs. + Camera access is turned off. Enable it in Settings to try the camera samples. + Grant access + Open settings CameraX • Take a Photo diff --git a/core-camera/src/main/java/com/android/camera/core/camera2/BaseCamera2Controller.kt b/core-camera/src/main/java/com/android/camera/core/camera2/BaseCamera2Controller.kt index 8e756c5b..7f0da8d5 100644 --- a/core-camera/src/main/java/com/android/camera/core/camera2/BaseCamera2Controller.kt +++ b/core-camera/src/main/java/com/android/camera/core/camera2/BaseCamera2Controller.kt @@ -21,6 +21,7 @@ import android.graphics.Rect import android.hardware.camera2.CameraAccessException import android.hardware.camera2.CameraCaptureSession import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraConstrainedHighSpeedCaptureSession import android.hardware.camera2.CameraDevice import android.hardware.camera2.CameraManager import android.hardware.camera2.CameraMetadata @@ -335,8 +336,7 @@ abstract class BaseCamera2Controller( viewWidth: Float, viewHeight: Float, ) { - val session = captureSession ?: return - val builder = previewRequestBuilder ?: return + if (captureSession == null || previewRequestBuilder == null) return try { val characteristics = currentCharacteristics ?: cameraManager.getCameraCharacteristics(cameraId) @@ -356,22 +356,44 @@ abstract class BaseCamera2Controller( (y0 + halfTouchHeight).toInt().coerceAtMost(sensorArraySize.height()), ) - try { - session.stopRepeating() - } catch (e: CameraAccessException) { - Log.w(TAG, "Exception while stopping repeating", e) - } - - val rectangle = MeteringRectangle(focusArea, MeteringRectangle.METERING_WEIGHT_MAX) - builder.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(rectangle)) - builder.set(CaptureRequest.CONTROL_AE_REGIONS, arrayOf(rectangle)) - builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO) - builder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_START) - - session.capture(builder.build(), null, backgroundHandler) + val pendingFocusArea = focusArea + backgroundHandler.post { + val activeSession = captureSession ?: return@post + val activeBuilder = previewRequestBuilder ?: return@post + try { + try { + activeSession.stopRepeating() + } catch (e: CameraAccessException) { + Log.w(TAG, "Exception while stopping repeating", e) + } - builder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE) - session.setRepeatingRequest(builder.build(), null, backgroundHandler) + val rectangle = MeteringRectangle(pendingFocusArea, MeteringRectangle.METERING_WEIGHT_MAX) + activeBuilder.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(rectangle)) + activeBuilder.set(CaptureRequest.CONTROL_AE_REGIONS, arrayOf(rectangle)) + activeBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO) + activeBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_START) + + if (activeSession is CameraConstrainedHighSpeedCaptureSession) { + activeSession.captureBurst( + activeSession.createHighSpeedRequestList(activeBuilder.build()), + null, + backgroundHandler, + ) + activeBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE) + activeSession.setRepeatingBurst( + activeSession.createHighSpeedRequestList(activeBuilder.build()), + null, + backgroundHandler, + ) + } else { + activeSession.capture(activeBuilder.build(), null, backgroundHandler) + activeBuilder.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_IDLE) + activeSession.setRepeatingRequest(activeBuilder.build(), null, backgroundHandler) + } + } catch (e: Exception) { + Log.e(TAG, "Focus error", e) + } + } } catch (e: CameraAccessException) { Log.e(TAG, "Focus error", e) } diff --git a/core-theme/src/main/java/com/android/camera/coretheme/Color.kt b/core-theme/src/main/java/com/android/camera/coretheme/Color.kt index 2070ab37..2810bd2f 100644 --- a/core-theme/src/main/java/com/android/camera/coretheme/Color.kt +++ b/core-theme/src/main/java/com/android/camera/coretheme/Color.kt @@ -17,149 +17,149 @@ package com.android.camera.coretheme import androidx.compose.ui.graphics.Color -val primaryLight = Color(0xFFB59CFF) -val onPrimaryLight = Color(0xFF190F33) -val primaryContainerLight = Color(0xFF2A2350) -val onPrimaryContainerLight = Color(0xFFD9CCFF) -val secondaryLight = Color(0xFF34A853) -val onSecondaryLight = Color(0xFF002108) -val secondaryContainerLight = Color(0xFF184E26) -val onSecondaryContainerLight = Color(0xFF3CC360) -val tertiaryLight = Color(0xFFFDADEE) -val onTertiaryLight = Color(0xFF6D2F67) -val tertiaryContainerLight = Color(0xFF7B3A73) -val onTertiaryContainerLight = Color(0xFFFFD7F4) -val errorLight = Color(0xFFFFB4AB) -val onErrorLight = Color(0xFF690005) -val errorContainerLight = Color(0xFFBA1A1A) -val onErrorContainerLight = Color(0xFFFFDAD6) -val backgroundLight = Color(0xFF12140D) -val onBackgroundLight = Color(0xFFE3E3D7) -val surfaceLight = Color(0xFF1D181E) -val onSurfaceLight = Color(0xFFF1F1F1) -val surfaceVariantLight = Color(0xFF3F4948) -val onSurfaceVariantLight = Color(0xFFE5E2E1) -val outlineLight = Color(0xFF7D7171) -val outlineVariantLight = Color(0xFF43454B) +val primaryLight = Color(0xFF6650A4) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFEADDFF) +val onPrimaryContainerLight = Color(0xFF21005D) +val secondaryLight = Color(0xFF625B71) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFE8DEF8) +val onSecondaryContainerLight = Color(0xFF1D192B) +val tertiaryLight = Color(0xFF7D5260) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFFFD8E4) +val onTertiaryContainerLight = Color(0xFF31111D) +val errorLight = Color(0xFFB3261E) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFF9DEDC) +val onErrorContainerLight = Color(0xFF410E0B) +val backgroundLight = Color(0xFFFEF7FF) +val onBackgroundLight = Color(0xFF1D1B20) +val surfaceLight = Color(0xFFFEF7FF) +val onSurfaceLight = Color(0xFF1D1B20) +val surfaceVariantLight = Color(0xFFE7E0EC) +val onSurfaceVariantLight = Color(0xFF49454F) +val outlineLight = Color(0xFF79747E) +val outlineVariantLight = Color(0xFFCAC4D0) val scrimLight = Color(0xFF000000) -val inverseSurfaceLight = Color(0xFFEFDFE1) -val inverseOnSurfaceLight = Color(0xFF372E30) -val inversePrimaryLight = Color(0xFF6E5BB0) -val surfaceDimLight = Color(0xFF1C1B1B) -val surfaceBrightLight = Color(0xFF3C3B3B) -val surfaceContainerLowestLight = Color(0xFF111111) -val surfaceContainerLowLight = Color(0xFF1C1B1B) -val surfaceContainerLight = Color(0xFF262625) -val surfaceContainerHighLight = Color(0xFF313030) -val surfaceContainerHighestLight = Color(0xFF3C3B3B) +val inverseSurfaceLight = Color(0xFF322F35) +val inverseOnSurfaceLight = Color(0xFFF5EFF7) +val inversePrimaryLight = Color(0xFFD0BCFF) +val surfaceDimLight = Color(0xFFDED8E1) +val surfaceBrightLight = Color(0xFFFEF7FF) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF7F2FA) +val surfaceContainerLight = Color(0xFFF3EDF7) +val surfaceContainerHighLight = Color(0xFFECE6F0) +val surfaceContainerHighestLight = Color(0xFFE6E0E9) -val primaryLightMediumContrast = Color(0xFFB59CFF) -val onPrimaryLightMediumContrast = Color(0xFF190F33) -val primaryContainerLightMediumContrast = Color(0xFF2A2350) -val onPrimaryContainerLightMediumContrast = Color(0xFFD9CCFF) -val secondaryLightMediumContrast = Color(0xFF34A853) -val onSecondaryLightMediumContrast = Color(0xFF002D0E) -val secondaryContainerLightMediumContrast = Color(0xFF689D6D) -val onSecondaryContainerLightMediumContrast = Color(0xFF63CF80) -val tertiaryLightMediumContrast = Color(0xFFFFEBF7) -val onTertiaryLightMediumContrast = Color(0xFFA45F9A) -val tertiaryContainerLightMediumContrast = Color(0xFFB87EAD) -val onTertiaryContainerLightMediumContrast = Color(0xFF000000) -val errorLightMediumContrast = Color(0xFFFFD2CC) -val onErrorLightMediumContrast = Color(0xFF540003) -val errorContainerLightMediumContrast = Color(0xFFFF5449) -val onErrorContainerLightMediumContrast = Color(0xFF000000) -val backgroundLightMediumContrast = Color(0xFF12140D) -val onBackgroundLightMediumContrast = Color(0xFFE3E3D7) -val surfaceLightMediumContrast = Color(0xFF43454B) -val onSurfaceLightMediumContrast = Color(0xFFFFFFFF) -val surfaceVariantLightMediumContrast = Color(0xFF3F4948) -val onSurfaceVariantLightMediumContrast = Color(0xFFD4DEDD) -val outlineLightMediumContrast = Color(0xFFAAB4B3) -val outlineVariantLightMediumContrast = Color(0xFF889291) +val primaryLightMediumContrast = Color(0xFF4A3987) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF7D67BE) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF464054) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF787188) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF5E3744) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF966776) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF8C1D18) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFDA342E) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFEF7FF) +val onBackgroundLightMediumContrast = Color(0xFF1D1B20) +val surfaceLightMediumContrast = Color(0xFFFEF7FF) +val onSurfaceLightMediumContrast = Color(0xFF1D1B20) +val surfaceVariantLightMediumContrast = Color(0xFFE7E0EC) +val onSurfaceVariantLightMediumContrast = Color(0xFF45414A) +val outlineLightMediumContrast = Color(0xFF615D67) +val outlineVariantLightMediumContrast = Color(0xFF7D7983) val scrimLightMediumContrast = Color(0xFF000000) -val inverseSurfaceLightMediumContrast = Color(0xFFEFDFE1) -val inverseOnSurfaceLightMediumContrast = Color(0xFF31282A) -val inversePrimaryLightMediumContrast = Color(0xFF4A3D7A) -val surfaceDimLightMediumContrast = Color(0xFF191113) -val surfaceBrightLightMediumContrast = Color(0xFF4C4244) -val surfaceContainerLowestLightMediumContrast = Color(0xFF0C0607) -val surfaceContainerLowLightMediumContrast = Color(0xFF241B1E) -val surfaceContainerLightMediumContrast = Color(0xFF2F2628) -val surfaceContainerHighLightMediumContrast = Color(0xFF3A3032) -val surfaceContainerHighestLightMediumContrast = Color(0xFF453B3D) +val inverseSurfaceLightMediumContrast = Color(0xFF322F35) +val inverseOnSurfaceLightMediumContrast = Color(0xFFF5EFF7) +val inversePrimaryLightMediumContrast = Color(0xFFD0BCFF) +val surfaceDimLightMediumContrast = Color(0xFFDED8E1) +val surfaceBrightLightMediumContrast = Color(0xFFFEF7FF) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF7F2FA) +val surfaceContainerLightMediumContrast = Color(0xFFF3EDF7) +val surfaceContainerHighLightMediumContrast = Color(0xFFECE6F0) +val surfaceContainerHighestLightMediumContrast = Color(0xFFE6E0E9) -val primaryLightHighContrast = Color(0xFFE7DEFF) -val onPrimaryLightHighContrast = Color(0xFF000000) -val primaryContainerLightHighContrast = Color(0xFF2A2350) +val primaryLightHighContrast = Color(0xFF271463) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF4A3987) val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) -val secondaryLightHighContrast = Color(0xFF63CF80) -val onSecondaryLightHighContrast = Color(0xFF000000) -val secondaryContainerLightHighContrast = Color(0xFF184E26) +val secondaryLightHighContrast = Color(0xFF241F32) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF464054) val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) -val tertiaryLightHighContrast = Color(0xFFFFD7F4) -val onTertiaryLightHighContrast = Color(0xFF260025) -val tertiaryContainerLightHighContrast = Color(0xFFEEAFE1) -val onTertiaryContainerLightHighContrast = Color(0xFF1D001B) -val errorLightHighContrast = Color(0xFFFFECE9) -val onErrorLightHighContrast = Color(0xFF000000) -val errorContainerLightHighContrast = Color(0xFFFFAEA4) -val onErrorContainerLightHighContrast = Color(0xFF220001) -val backgroundLightHighContrast = Color(0xFF12140D) -val onBackgroundLightHighContrast = Color(0xFFE3E3D7) -val surfaceLightHighContrast = Color(0xFF0F1013) -val onSurfaceLightHighContrast = Color(0xFFFFFFFF) -val surfaceVariantLightHighContrast = Color(0xFF3F4948) -val onSurfaceVariantLightHighContrast = Color(0xFFFFFFFF) -val outlineLightHighContrast = Color(0xFFE8F2F1) -val outlineVariantLightHighContrast = Color(0xFFBAC5C3) +val tertiaryLightHighContrast = Color(0xFF391724) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF5E3744) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF4E0002) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF8C1D18) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFEF7FF) +val onBackgroundLightHighContrast = Color(0xFF000000) +val surfaceLightHighContrast = Color(0xFFFEF7FF) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFE7E0EC) +val onSurfaceVariantLightHighContrast = Color(0xFF25232B) +val outlineLightHighContrast = Color(0xFF45414A) +val outlineVariantLightHighContrast = Color(0xFF45414A) val scrimLightHighContrast = Color(0xFF000000) -val inverseSurfaceLightHighContrast = Color(0xFFEFDFE1) -val inverseOnSurfaceLightHighContrast = Color(0xFF000000) -val inversePrimaryLightHighContrast = Color(0xFF4A3D7A) -val surfaceDimLightHighContrast = Color(0xFF191113) -val surfaceBrightLightHighContrast = Color(0xFF584D50) -val surfaceContainerLowestLightHighContrast = Color(0xFF000000) -val surfaceContainerLowLightHighContrast = Color(0xFF261D20) -val surfaceContainerLightHighContrast = Color(0xFF372E30) -val surfaceContainerHighLightHighContrast = Color(0xFF43393B) -val surfaceContainerHighestLightHighContrast = Color(0xFF4F4446) +val inverseSurfaceLightHighContrast = Color(0xFF322F35) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFF3E8FF) +val surfaceDimLightHighContrast = Color(0xFFDED8E1) +val surfaceBrightLightHighContrast = Color(0xFFFEF7FF) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF7F2FA) +val surfaceContainerLightHighContrast = Color(0xFFF3EDF7) +val surfaceContainerHighLightHighContrast = Color(0xFFECE6F0) +val surfaceContainerHighestLightHighContrast = Color(0xFFE6E0E9) -val primaryDark = Color(0xFFB59CFF) -val onPrimaryDark = Color(0xFF190F33) -val primaryContainerDark = Color(0xFF2A2350) -val onPrimaryContainerDark = Color(0xFFD9CCFF) -val secondaryDark = Color(0xFF34A853) -val onSecondaryDark = Color(0xFF002108) -val secondaryContainerDark = Color(0xFF184E26) -val onSecondaryContainerDark = Color(0xFF3CC360) -val tertiaryDark = Color(0xFFFDADEE) -val onTertiaryDark = Color(0xFF6D2F67) -val tertiaryContainerDark = Color(0xFF7B3A73) -val onTertiaryContainerDark = Color(0xFFFFD7F4) +val primaryDark = Color(0xFFD0BCFF) +val onPrimaryDark = Color(0xFF381E72) +val primaryContainerDark = Color(0xFF4F378B) +val onPrimaryContainerDark = Color(0xFFEADDFF) +val secondaryDark = Color(0xFFCCC2DC) +val onSecondaryDark = Color(0xFF332D41) +val secondaryContainerDark = Color(0xFF4A4458) +val onSecondaryContainerDark = Color(0xFFE8DEF8) +val tertiaryDark = Color(0xFFEFB8C8) +val onTertiaryDark = Color(0xFF492532) +val tertiaryContainerDark = Color(0xFF633B48) +val onTertiaryContainerDark = Color(0xFFFFD8E4) val errorDark = Color(0xFFFFB4AB) val onErrorDark = Color(0xFF690005) -val errorContainerDark = Color(0xFFBA1A1A) +val errorContainerDark = Color(0xFF93000A) val onErrorContainerDark = Color(0xFFFFDAD6) -val backgroundDark = Color(0xFF12140D) -val onBackgroundDark = Color(0xFFE3E3D7) -val surfaceDark = Color(0xFF1D181E) -val onSurfaceDark = Color(0xFFF1F1F1) -val surfaceVariantDark = Color(0xFF3F4948) -val onSurfaceVariantDark = Color(0xFFE5E2E1) -val outlineDark = Color(0xFF7D7171) -val outlineVariantDark = Color(0xFF43454B) +val backgroundDark = Color(0xFF141218) +val onBackgroundDark = Color(0xFFE6E0E9) +val surfaceDark = Color(0xFF141218) +val onSurfaceDark = Color(0xFFE6E0E9) +val surfaceVariantDark = Color(0xFF49454F) +val onSurfaceVariantDark = Color(0xFFCAC4D0) +val outlineDark = Color(0xFF938F99) +val outlineVariantDark = Color(0xFF49454F) val scrimDark = Color(0xFF000000) -val inverseSurfaceDark = Color(0xFFEFDFE1) -val inverseOnSurfaceDark = Color(0xFF372E30) -val inversePrimaryDark = Color(0xFF6E5BB0) -val surfaceDimDark = Color(0xFF1C1B1B) -val surfaceBrightDark = Color(0xFF3C3B3B) -val surfaceContainerLowestDark = Color(0xFF111111) -val surfaceContainerLowDark = Color(0xFF1C1B1B) -val surfaceContainerDark = Color(0xFF262625) -val surfaceContainerHighDark = Color(0xFF313030) -val surfaceContainerHighestDark = Color(0xFF3C3B3B) +val inverseSurfaceDark = Color(0xFFE6E0E9) +val inverseOnSurfaceDark = Color(0xFF322F35) +val inversePrimaryDark = Color(0xFF6650A4) +val surfaceDimDark = Color(0xFF141218) +val surfaceBrightDark = Color(0xFF3B383E) +val surfaceContainerLowestDark = Color(0xFF0F0D13) +val surfaceContainerLowDark = Color(0xFF1D1B20) +val surfaceContainerDark = Color(0xFF211F26) +val surfaceContainerHighDark = Color(0xFF2B2930) +val surfaceContainerHighestDark = Color(0xFF36343B) val primaryDarkMediumContrast = Color(0xFFB59CFF) val onPrimaryDarkMediumContrast = Color(0xFF190F33) diff --git a/core-theme/src/main/java/com/android/camera/coretheme/Theme.kt b/core-theme/src/main/java/com/android/camera/coretheme/Theme.kt index 3e835e7c..9368eb90 100644 --- a/core-theme/src/main/java/com/android/camera/coretheme/Theme.kt +++ b/core-theme/src/main/java/com/android/camera/coretheme/Theme.kt @@ -18,9 +18,10 @@ package com.android.camera.coretheme import android.app.UiModeManager import android.content.Context import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color @@ -304,22 +305,37 @@ private fun systemContrast(): Contrast { @Composable fun AISampleCatalogTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + darkTheme: Boolean = true, contrast: Contrast = systemContrast(), + dynamicColor: Boolean = true, content: @Composable () -> Unit, ) { + val context = LocalContext.current + val useDynamicColor = + dynamicColor && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + !LocalInspectionMode.current && + contrast == Contrast.DEFAULT val colorScheme = - if (darkTheme) { - when (contrast) { - Contrast.DEFAULT -> darkScheme - Contrast.MEDIUM -> mediumContrastDarkColorScheme - Contrast.HIGH -> highContrastDarkColorScheme + when { + useDynamicColor -> { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - } else { - when (contrast) { - Contrast.DEFAULT -> lightScheme - Contrast.MEDIUM -> mediumContrastLightColorScheme - Contrast.HIGH -> highContrastLightColorScheme + + darkTheme -> { + when (contrast) { + Contrast.DEFAULT -> darkScheme + Contrast.MEDIUM -> mediumContrastDarkColorScheme + Contrast.HIGH -> highContrastDarkColorScheme + } + } + + else -> { + when (contrast) { + Contrast.DEFAULT -> lightScheme + Contrast.MEDIUM -> mediumContrastLightColorScheme + Contrast.HIGH -> highContrastLightColorScheme + } } } diff --git a/core-theme/src/main/java/com/android/camera/coretheme/Type.kt b/core-theme/src/main/java/com/android/camera/coretheme/Type.kt index 207a40b2..356d8483 100644 --- a/core-theme/src/main/java/com/android/camera/coretheme/Type.kt +++ b/core-theme/src/main/java/com/android/camera/coretheme/Type.kt @@ -16,43 +16,16 @@ package com.android.camera.coretheme import androidx.compose.material3.Typography -import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.googlefonts.Font -import androidx.compose.ui.text.googlefonts.GoogleFont -import com.android.camera.coretheme.R +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp -val provider = - GoogleFont.Provider( - providerAuthority = "com.google.android.gms.fonts", - providerPackage = "com.google.android.gms", - certificates = R.array.com_google_android_gms_fonts_certs, - ) +val bodyFontFamily: FontFamily = FontFamily.Default -val bodyFontFamily = - FontFamily( - Font( - googleFont = GoogleFont("Space Grotesk"), - fontProvider = provider, - ), - ) +val displayFontFamily: FontFamily = FontFamily.Default -val displayFontFamily = - FontFamily( - Font( - googleFont = GoogleFont("Space Grotesk"), - fontProvider = provider, - ), - ) - -// Monospace family for technical / metadata text: API labels, filter chips, counts, HUD readouts. -val monoFontFamily = - FontFamily( - Font( - googleFont = GoogleFont("Space Mono"), - fontProvider = provider, - ), - ) +val monoFontFamily: FontFamily = FontFamily.Default val baseline = Typography() @@ -67,7 +40,14 @@ val AppTypography = titleLarge = baseline.titleLarge.copy(fontFamily = displayFontFamily), titleMedium = baseline.titleMedium.copy(fontFamily = displayFontFamily), titleSmall = baseline.titleSmall.copy(fontFamily = displayFontFamily), - bodyLarge = baseline.bodyLarge.copy(fontFamily = bodyFontFamily), + bodyLarge = + TextStyle( + fontFamily = bodyFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), bodyMedium = baseline.bodyMedium.copy(fontFamily = bodyFontFamily), bodySmall = baseline.bodySmall.copy(fontFamily = bodyFontFamily), labelLarge = baseline.labelLarge.copy(fontFamily = bodyFontFamily), diff --git a/core-ui/src/main/java/com/android/camera/coreui/controls/CameraControls.kt b/core-ui/src/main/java/com/android/camera/coreui/controls/CameraControls.kt index 07d38de9..d1de895f 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/controls/CameraControls.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/controls/CameraControls.kt @@ -18,6 +18,7 @@ package com.android.camera.coreui.controls import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -26,29 +27,39 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cameraswitch +import androidx.compose.material.icons.rounded.Cameraswitch import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.android.camera.coreui.R + +private val RecordRed = Color(0xFFE53935) +private val ControlScrimColor = Color.Black.copy(alpha = 0.55f) +private val ControlBorderColor = Color.White.copy(alpha = 0.14f) /** - * The standard shutter button: a 70dp white ring around a 56dp white core. The core scales down - * briefly while pressed before firing [onClick], matching the console viewfinder. + * The Android camera shutter button: an 80dp white outer ring around a 64dp white core that scales + * smoothly while pressed before firing [onClick]. */ @Composable fun ShutterButton( @@ -59,15 +70,17 @@ fun ShutterButton( val interactionSource = remember { MutableInteractionSource() } val pressed by interactionSource.collectIsPressedAsState() val coreScale by animateFloatAsState( - targetValue = if (pressed) 0.8f else 1f, - animationSpec = tween(durationMillis = 130), + targetValue = if (pressed) 0.84f else 1f, + animationSpec = tween(durationMillis = 120), label = "shutterScale", ) Box( modifier = modifier - .size(70.dp) - .border(width = 3.dp, color = Color.White, shape = CircleShape) + .size(80.dp) + .background(Color.Black.copy(alpha = 0.25f), CircleShape) + .border(width = 4.dp, color = Color.White, shape = CircleShape) + .clip(CircleShape) .clickable( interactionSource = interactionSource, indication = null, @@ -79,7 +92,7 @@ fun ShutterButton( Box( modifier = Modifier - .size(56.dp) + .size(64.dp) .graphicsLayer { scaleX = coreScale scaleY = coreScale @@ -100,22 +113,23 @@ fun RecordButton( enabled: Boolean = true, ) { val cornerRadius by animateDpAsState( - targetValue = if (isRecording) 8.dp else 28.dp, + targetValue = if (isRecording) 8.dp else 32.dp, label = "recordCorner", ) val innerSize by animateDpAsState( - targetValue = if (isRecording) 32.dp else 56.dp, + targetValue = if (isRecording) 32.dp else 64.dp, label = "recordSize", ) IconButton( onClick = onClick, enabled = enabled, - modifier = modifier.size(72.dp), + modifier = modifier.size(80.dp), ) { Box( modifier = Modifier .fillMaxSize() + .background(Color.Black.copy(alpha = 0.25f), CircleShape) .border(width = 4.dp, color = Color.White, shape = CircleShape), contentAlignment = Alignment.Center, ) { @@ -124,7 +138,7 @@ fun RecordButton( Modifier .size(innerSize) .clip(RoundedCornerShape(cornerRadius)) - .background(Color.Red), + .background(RecordRed), ) } } @@ -135,19 +149,27 @@ fun CameraSwitchButton( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - IconButton(onClick = onClick, modifier = modifier.size(32.dp)) { - Icon( - imageVector = Icons.Filled.Cameraswitch, - contentDescription = "Swap Camera", - tint = Color.White, - modifier = Modifier.fillMaxSize(), - ) + Surface( + onClick = onClick, + shape = CircleShape, + color = ControlScrimColor, + contentColor = Color.White, + border = BorderStroke(1.dp, ControlBorderColor), + modifier = modifier.size(52.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.Cameraswitch, + contentDescription = stringResource(R.string.swap_camera), + modifier = Modifier.size(24.dp), + ) + } } } /** - * A circular scrim-backed icon button (translucent black disc + white glyph), used for the console - * viewfinder/review chrome. Defaults to 48dp; pass [size] = 34dp for the smaller top-bar variant. + * A circular scrim-backed icon button (translucent dark disc + white glyph) for viewfinder and + * media review chrome. */ @Composable fun ScrimIconButton( @@ -155,31 +177,31 @@ fun ScrimIconButton( imageVector: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, - size: Dp = 48.dp, + size: Dp = 44.dp, iconSize: Dp = 22.dp, ) { - Box( - modifier = - modifier - .size(size) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.42f)) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, + Surface( + onClick = onClick, + shape = CircleShape, + color = ControlScrimColor, + contentColor = Color.White, + border = BorderStroke(1.dp, ControlBorderColor), + modifier = modifier.size(size), ) { - Icon( - imageVector = imageVector, - contentDescription = contentDescription, - tint = Color.White, - modifier = Modifier.size(iconSize), - ) + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier.size(iconSize), + ) + } } } /** * Standard bottom control bar: a centered [center] action (shutter/record) with optional - * [startSlot] and [endSlot] (e.g. a camera-swap button), matching the layout shared by every - * capture sample. + * [startSlot] and [endSlot] (e.g. a camera-swap button), aligned on the same horizontal center axis + * and padded above the gesture navigation bar. */ @Composable fun CameraControlsBar( @@ -192,7 +214,17 @@ fun CameraControlsBar( modifier = modifier .fillMaxWidth() - .padding(bottom = 48.dp, start = 32.dp, end = 48.dp), + .background( + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.56f), + ), + ), + ).navigationBarsPadding() + .padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 28.dp) + .heightIn(min = 52.dp), ) { Box(modifier = Modifier.align(Alignment.CenterStart)) { startSlot() } Box(modifier = Modifier.align(Alignment.Center)) { center() } diff --git a/core-ui/src/main/java/com/android/camera/coreui/controls/ValueSlider.kt b/core-ui/src/main/java/com/android/camera/coreui/controls/ValueSlider.kt index 057c10a9..5a82f366 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/controls/ValueSlider.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/controls/ValueSlider.kt @@ -16,23 +16,25 @@ package com.android.camera.coreui.controls import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.android.camera.coretheme.monoFontFamily + +private val SliderCardShape = RoundedCornerShape(24.dp) /** * A labeled slider styled for use over a dark camera preview. Used by samples that expose a single @@ -55,25 +57,30 @@ fun ValueSlider( modifier = modifier .fillMaxWidth() - .background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) - .padding(horizontal = 16.dp, vertical = 8.dp), + .background(Color.Black.copy(alpha = 0.6f), SliderCardShape) + .border(1.dp, Color.White.copy(alpha = 0.14f), SliderCardShape) + .padding(horizontal = 18.dp, vertical = 14.dp), ) { - Row(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { Text( text = label, - style = TextStyle(fontFamily = monoFontFamily, fontSize = 12.sp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, color = Color.White, modifier = Modifier.weight(1f), ) Text( text = valueLabel, - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - ), + style = MaterialTheme.typography.labelLarge.copy(fontFeatureSettings = "tnum"), + fontWeight = FontWeight.SemiBold, color = accentColor, + modifier = + Modifier + .background(Color.White.copy(alpha = 0.1f), CircleShape) + .padding(horizontal = 12.dp, vertical = 4.dp), ) } Slider( diff --git a/core-ui/src/main/java/com/android/camera/coreui/controls/ZoomControls.kt b/core-ui/src/main/java/com/android/camera/coreui/controls/ZoomControls.kt index 7e62167d..ccb4a5fe 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/controls/ZoomControls.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/controls/ZoomControls.kt @@ -17,7 +17,6 @@ package com.android.camera.coreui.controls import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,30 +24,30 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.android.camera.coretheme.monoFontFamily import java.util.Locale import kotlin.math.abs -private val ScrimColor = Color.Black.copy(alpha = 0.42f) +private val CardScrimColor = Color.Black.copy(alpha = 0.6f) +private val CardBorderColor = Color.White.copy(alpha = 0.14f) +private val CardShape = RoundedCornerShape(24.dp) /** - * The console zoom control: a centered zoom-value pill, an accent slider, and discrete zoom-stop - * chips kept in sync with the slider. Stops outside [valueRange] are hidden; tapping a chip jumps the - * zoom to that ratio. + * The Android camera zoom control: a cohesive Material 3 card containing circular zoom stop pills, + * a live zoom-ratio readout badge, and a smooth slider kept in sync. Stops outside [valueRange] + * are hidden; tapping a chip jumps the zoom to that ratio. */ @Composable fun ZoomControls( @@ -59,27 +58,70 @@ fun ZoomControls( enabled: Boolean = true, stops: List = listOf(0.5f, 1f, 2f, 5f), ) { - val accent = MaterialTheme.colorScheme.primary + val colors = MaterialTheme.colorScheme Column( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = + modifier + .fillMaxWidth() + .background(CardScrimColor, CardShape) + .border(1.dp, CardBorderColor, CardShape) + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text( - text = String.format(Locale.US, "%.1f×", zoomRatio), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - ), - color = accent, - modifier = - Modifier - .clip(RoundedCornerShape(percent = 50)) - .background(ScrimColor) - .padding(horizontal = 14.dp, vertical = 5.dp), - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = + Modifier + .background(Color.White.copy(alpha = 0.08f), CircleShape) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + stops + .filter { it in valueRange } + .forEach { stop -> + val active = abs(zoomRatio - stop) < 0.05f + Surface( + onClick = { + onValueChange( + stop.coerceIn(valueRange.start, valueRange.endInclusive), + ) + }, + enabled = enabled, + shape = CircleShape, + color = if (active) colors.primaryContainer else Color.Transparent, + contentColor = if (active) colors.onPrimaryContainer else Color.White, + modifier = Modifier.size(36.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = stop.toStopLabel(), + style = + MaterialTheme.typography.labelMedium.copy( + fontFeatureSettings = "tnum", + ), + fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, + ) + } + } + } + } + + Text( + text = String.format(Locale.US, "%.1f×", zoomRatio), + style = MaterialTheme.typography.labelLarge.copy(fontFeatureSettings = "tnum"), + fontWeight = FontWeight.SemiBold, + color = colors.primary, + modifier = + Modifier + .background(Color.White.copy(alpha = 0.1f), CircleShape) + .padding(horizontal = 14.dp, vertical = 6.dp), + ) + } Slider( value = zoomRatio.coerceIn(valueRange.start, valueRange.endInclusive), @@ -88,56 +130,12 @@ fun ZoomControls( enabled = enabled, colors = SliderDefaults.colors( - thumbColor = accent, - activeTrackColor = accent, + thumbColor = colors.primary, + activeTrackColor = colors.primary, inactiveTrackColor = Color.White.copy(alpha = 0.3f), ), - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + modifier = Modifier.fillMaxWidth(), ) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - stops - .filter { it in valueRange } - .forEach { stop -> - val active = abs(zoomRatio - stop) < 0.05f - val shape = RoundedCornerShape(15.dp) - val chipBase = - Modifier - .size(width = 42.dp, height = 30.dp) - .clip(shape) - val chipModifier = - if (active) { - chipBase.background(accent) - } else { - chipBase - .background(ScrimColor) - .border(1.dp, Color.White.copy(alpha = 0.12f), shape) - } - Box( - modifier = - chipModifier.clickable(enabled = enabled) { - onValueChange(stop.coerceIn(valueRange.start, valueRange.endInclusive)) - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = stop.toStopLabel(), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - ), - color = - if (active) { - MaterialTheme.colorScheme.onPrimary - } else { - Color.White.copy(alpha = 0.85f) - }, - ) - } - } - } } } diff --git a/core-ui/src/main/java/com/android/camera/coreui/overlay/FocusIndicator.kt b/core-ui/src/main/java/com/android/camera/coreui/overlay/FocusIndicator.kt index 6e634561..37ba7183 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/overlay/FocusIndicator.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/overlay/FocusIndicator.kt @@ -88,7 +88,7 @@ fun FocusIndicator( .offset { IntOffset((x.value - halfPx).roundToInt(), (y.value - halfPx).roundToInt()) }.size(ReticleSize) - .border(width = 1.5.dp, color = tint, shape = RoundedCornerShape(6.dp)), + .border(width = 2.dp, color = tint, shape = CircleShape), contentAlignment = Alignment.Center, ) { Box( diff --git a/core-ui/src/main/java/com/android/camera/coreui/overlay/SettingsOverlay.kt b/core-ui/src/main/java/com/android/camera/coreui/overlay/SettingsOverlay.kt index ef66b569..ea0ae665 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/overlay/SettingsOverlay.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/overlay/SettingsOverlay.kt @@ -21,7 +21,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -39,6 +38,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.SwitchColors import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -48,24 +48,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp -import com.android.camera.coretheme.monoFontFamily - -private val PanelSurface = Color(0xFF14160D) -private val Hairline = Color.White.copy(alpha = 0.08f) -private val LabelColor = Color.White.copy(alpha = 0.70f) -private val ChipIdleBg = Color.White.copy(alpha = 0.05f) -private val ChipIdleBorder = Color.White.copy(alpha = 0.12f) -private val ChipIdleText = Color.White.copy(alpha = 0.85f) /** - * A dismissible bottom panel for sample settings (resolution, fps, extension mode, …) in the console - * style: a dark hairline-bordered surface with a drag handle and mono labels. Tapping the scrim - * dismisses it; taps on the panel itself are swallowed. + * A dismissible Material 3 bottom sheet panel for sample settings (resolution, fps, extension mode, …). + * Tapping the scrim dismisses it; taps on the panel itself are swallowed. */ @Composable fun SettingsOverlay( @@ -97,30 +85,29 @@ fun SettingsOverlay( exit = slideOutVertically { it }, modifier = Modifier.align(Alignment.BottomCenter), ) { - val panelShape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) + val panelShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) Column( modifier = Modifier .fillMaxWidth() .clip(panelShape) - .background(PanelSurface) - .border(1.dp, Hairline, panelShape) + .background(MaterialTheme.colorScheme.surfaceContainerLow) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {}, ).navigationBarsPadding() - .padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 24.dp), + .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 28.dp), ) { Box( modifier = Modifier .align(Alignment.CenterHorizontally) - .padding(bottom = 14.dp) - .width(36.dp) + .padding(bottom = 18.dp) + .width(32.dp) .height(4.dp) .clip(CircleShape) - .background(Color.White.copy(alpha = 0.18f)), + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), ) content() } @@ -129,22 +116,18 @@ fun SettingsOverlay( } } -/** A mono uppercase section title for a [SettingsOverlay] panel. */ +/** A section title for a [SettingsOverlay] panel. */ @Composable fun SettingsHeader( text: String, modifier: Modifier = Modifier, ) { Text( - text = text.uppercase(), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 11.sp, - letterSpacing = 0.16.em, - ), - color = Color.White.copy(alpha = 0.40f), - modifier = modifier.padding(bottom = 14.dp), + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier.padding(bottom = 16.dp), ) } @@ -164,8 +147,8 @@ fun SettingsRow( ) { Text( text = label, - style = TextStyle(fontFamily = monoFontFamily, fontSize = 12.sp), - color = LabelColor, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), ) trailing() @@ -173,8 +156,7 @@ fun SettingsRow( } /** - * A labeled setting whose value is chosen from [options] via inline accent chips (replacing the old - * Material dropdown). The selected chip is filled with the accent; the rest are scrim pills. + * A labeled setting whose value is chosen from [options] via inline tonal chips. */ @Composable fun SettingsDropdown( @@ -189,13 +171,14 @@ fun SettingsDropdown( modifier = modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 10.dp), ) { Text( text = label, - style = TextStyle(fontFamily = monoFontFamily, fontSize = 12.sp), - color = LabelColor, - modifier = Modifier.padding(bottom = 8.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 10.dp), ) FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -218,44 +201,33 @@ private fun OptionChip( selected: Boolean, onClick: () -> Unit, ) { - val accent = MaterialTheme.colorScheme.primary - val shape = RoundedCornerShape(14.dp) - val base = - Modifier - .clip(shape) - val styled = + val containerColor = + if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val contentColor = if (selected) { - base.background(accent) + MaterialTheme.colorScheme.onPrimaryContainer } else { - base - .background(ChipIdleBg) - .border(1.dp, ChipIdleBorder, shape) + MaterialTheme.colorScheme.onSurfaceVariant } - Box( - modifier = styled.clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 7.dp), - contentAlignment = Alignment.Center, + Surface( + onClick = onClick, + shape = RoundedCornerShape(16.dp), + color = containerColor, + contentColor = contentColor, ) { Text( text = label, - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 12.sp, - fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, - ), - color = if (selected) MaterialTheme.colorScheme.onPrimary else ChipIdleText, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), ) } } -/** Accent-themed [SwitchColors] for switches used inside a [SettingsOverlay]. */ +/** Standard Material 3 [SwitchColors] for switches used inside a [SettingsOverlay]. */ @Composable -fun settingsSwitchColors(): SwitchColors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.onPrimary, - checkedTrackColor = MaterialTheme.colorScheme.primary, - checkedBorderColor = MaterialTheme.colorScheme.primary, - uncheckedThumbColor = Color.White.copy(alpha = 0.85f), - uncheckedTrackColor = Color.White.copy(alpha = 0.12f), - uncheckedBorderColor = Color.White.copy(alpha = 0.22f), - ) +fun settingsSwitchColors(): SwitchColors = SwitchDefaults.colors() diff --git a/core-ui/src/main/java/com/android/camera/coreui/overlay/ViewfinderChrome.kt b/core-ui/src/main/java/com/android/camera/coreui/overlay/ViewfinderChrome.kt index 169eab8d..f843330e 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/overlay/ViewfinderChrome.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/overlay/ViewfinderChrome.kt @@ -17,37 +17,39 @@ package com.android.camera.coreui.overlay import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.rounded.FlashOn import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp -import com.android.camera.coretheme.monoFontFamily +import com.android.camera.coreui.R -private val HairlineColor = Color.White.copy(alpha = 0.07f) -private val ScrimColor = Color.Black.copy(alpha = 0.42f) +private val HairlineColor = Color.White.copy(alpha = 0.08f) +private val ScrimColor = Color.Black.copy(alpha = 0.55f) +private val ScrimBorderColor = Color.White.copy(alpha = 0.14f) private val WarmGlow = Color(0xFFFFFADC) /** Rule-of-thirds guide lines drawn as faint hairlines across the viewfinder. */ @@ -66,63 +68,67 @@ fun RuleOfThirdsGrid(modifier: Modifier = Modifier) { } } -/** A centered scrim pill showing the sample name in uppercase mono, for the viewfinder top area. */ +/** A translucent Material 3 pill showing a title or readout over the viewfinder. */ @Composable fun ViewfinderTitleChip( text: String, modifier: Modifier = Modifier, ) { Text( - text = text.uppercase(), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 10.sp, - letterSpacing = 0.16.em, - ), + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, color = Color.White, modifier = modifier - .clip(RoundedCornerShape(percent = 50)) - .background(ScrimColor) - .padding(horizontal = 12.dp, vertical = 6.dp), + .background(ScrimColor, CircleShape) + .border(1.dp, ScrimBorderColor, CircleShape) + .padding(horizontal = 14.dp, vertical = 8.dp), ) } -/** Torch toggle chip: a scrim pill whose label/icon turn accent when the torch is on. */ +/** Torch toggle chip: a Material 3 pill that highlights with the primary container when the torch is on. */ @Composable fun TorchChip( on: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier, ) { - val tint = if (on) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.75f) - Row( - modifier = - modifier - .clip(RoundedCornerShape(percent = 50)) - .background(ScrimColor) - .clickable(onClick = onToggle) - .padding(horizontal = 12.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, + val colors = MaterialTheme.colorScheme + Surface( + onClick = onToggle, + shape = CircleShape, + color = if (on) colors.primaryContainer else ScrimColor, + contentColor = if (on) colors.onPrimaryContainer else Color.White, + border = if (on) null else BorderStroke(1.dp, ScrimBorderColor), + modifier = modifier.heightIn(min = 44.dp), ) { - Icon( - imageVector = Icons.Filled.FlashOn, - contentDescription = if (on) "Turn torch off" else "Turn torch on", - tint = tint, - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = if (on) "TORCH ON" else "TORCH", - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 10.sp, - letterSpacing = 0.1.em, - ), - color = tint, - ) + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.FlashOn, + contentDescription = + if (on) { + stringResource(R.string.torch_turn_off) + } else { + stringResource(R.string.torch_turn_on) + }, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = + if (on) { + stringResource(R.string.torch_label_on) + } else { + stringResource(R.string.torch_label_off) + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (on) FontWeight.SemiBold else FontWeight.Medium, + ) + } } } diff --git a/core-ui/src/main/java/com/android/camera/coreui/overlay/ViewfinderTopBar.kt b/core-ui/src/main/java/com/android/camera/coreui/overlay/ViewfinderTopBar.kt index cbd4af6b..2a59552f 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/overlay/ViewfinderTopBar.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/overlay/ViewfinderTopBar.kt @@ -15,76 +15,139 @@ */ package com.android.camera.coreui.overlay +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.android.camera.coreui.R import com.android.camera.coreui.controls.ScrimIconButton +import com.android.camera.coreui.scaffold.CameraApi +import com.android.camera.coreui.scaffold.LocalCameraApi /** - * The shared viewfinder top bar: a single status-bar-padded [Row] pinned to the top of a sample's - * camera surface so the close button, centered title chip, and trailing [actions] always sit at the - * same vertical position across every sample. - * - * The bar is offset below [com.android.camera.coreui.scaffold.CameraSampleScaffold]'s ApiBadge (which - * floats at the very top center) so the badge and the title chip never collide. The title chip is - * held at the row's vertical center inside a weighted [Box] so it stays centered even when the start - * and end slots have different widths. - * - * @param title centered title; pass `null` to omit the chip. - * @param onClose invoked by the leading [ScrimIconButton]. - * @param closeIcon defaults to [Icons.Filled.Close]; pass an arrow for screens that "go back". - * @param actions trailing controls (torch, settings, camera-swap, record quality, etc.). + * The shared viewfinder top bar: a status-bar-padded [Box] pinned to the top of a sample's camera + * surface so the close button (`Alignment.CenterStart`), dead-centered API + title pill + * (`Alignment.Center`), and trailing [actions] (`Alignment.CenterEnd`) always sit on the exact + * same horizontal axis and true screen center across every sample. */ @Composable fun BoxScope.ViewfinderTopBar( title: String?, onClose: () -> Unit, - closeIcon: ImageVector = Icons.Filled.Close, + closeIcon: ImageVector = Icons.Rounded.Close, + showApiBadge: Boolean = true, actions: @Composable RowScope.() -> Unit = {}, modifier: Modifier = Modifier, ) { - Row( + val api = LocalCameraApi.current + Box( modifier = modifier .align(Alignment.TopCenter) .fillMaxWidth() - .statusBarsPadding() - // Offset below the scaffold's ApiBadge (top-center) so the two never overlap. - .padding(start = 16.dp, end = 16.dp, top = 40.dp, bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + .background( + Brush.verticalGradient( + colors = + listOf( + Color.Black.copy(alpha = 0.52f), + Color.Transparent, + ), + ), + ).statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp) + .heightIn(min = 44.dp), ) { ScrimIconButton( onClick = onClose, imageVector = closeIcon, - contentDescription = "Close", - size = 34.dp, - iconSize = 18.dp, + contentDescription = stringResource(R.string.close), + size = 44.dp, + iconSize = 22.dp, + modifier = Modifier.align(Alignment.CenterStart), ) Box( - modifier = Modifier.weight(1f), + modifier = + Modifier + .align(Alignment.Center) + .padding(horizontal = 56.dp), contentAlignment = Alignment.Center, ) { - title?.let { ViewfinderTitleChip(it) } + if (showApiBadge) { + ViewfinderHeaderPill(api = api, title = title) + } else if (title != null) { + ViewfinderTitleChip(title) + } } Row( + modifier = Modifier.align(Alignment.CenterEnd), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, content = actions, ) } } + +@Composable +private fun ViewfinderHeaderPill( + api: CameraApi, + title: String?, + modifier: Modifier = Modifier, +) { + val label = + if (!title.isNullOrBlank()) { + stringResource(R.string.top_bar_api_and_title, api.label, title) + } else { + api.label + } + Row( + modifier = + modifier + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape) + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(8.dp) + .background(api.accent, CircleShape), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/core-ui/src/main/java/com/android/camera/coreui/preview/CapturedMedia.kt b/core-ui/src/main/java/com/android/camera/coreui/preview/CapturedMedia.kt index 497e9f8d..864a0d10 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/preview/CapturedMedia.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/preview/CapturedMedia.kt @@ -19,25 +19,25 @@ import android.graphics.Bitmap import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -46,29 +46,23 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer -import com.android.camera.coretheme.bodyFontFamily -import com.android.camera.coretheme.monoFontFamily -import com.android.camera.coreui.controls.ScrimIconButton +import com.android.camera.coreui.R +import com.android.camera.coreui.overlay.ViewfinderTopBar import com.android.camera.coreui.widget.VideoPlayer /** - * Full-screen review of a captured still in the console style: a scrim back button, the real image - * dimensions as mono metadata, and a Retake / Done action bar. [onRetake] returns to the viewfinder; - * [onDone] leaves the sample. + * Full-screen Material 3 review of a captured still: a circular back button, a translucent pill + * showing the image dimensions, and a Material 3 Retake / Done action bar. [onRetake] returns to + * the viewfinder; [onDone] leaves the sample. */ @Composable fun CapturedImagePreview( @@ -85,121 +79,84 @@ fun CapturedImagePreview( ) { Image( bitmap = bitmap.asImageBitmap(), - contentDescription = "Captured Photo", + contentDescription = stringResource(R.string.captured_photo_description), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit, ) - ScrimIconButton( - onClick = onDone, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - size = 34.dp, - iconSize = 18.dp, - modifier = - Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(16.dp), - ) - - Text( - text = "${bitmap.width} × ${bitmap.height}", - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 10.sp, - letterSpacing = 0.06.em, - ), - color = Color.White.copy(alpha = 0.55f), - modifier = - Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .padding(start = 20.dp, bottom = 100.dp), + ViewfinderTopBar( + title = stringResource(R.string.review_dimensions, bitmap.width, bitmap.height), + onClose = onDone, + closeIcon = Icons.AutoMirrored.Rounded.ArrowBack, + showApiBadge = false, ) - Row( + ReviewActionsBar( + onRetake = onRetake, + onDone = onDone, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .navigationBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = 24.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ReviewActionPill( - modifier = Modifier.weight(1f), - icon = Icons.Filled.Refresh, - label = "Retake", - filled = false, - onClick = onRetake, - ) - ReviewActionPill( - modifier = Modifier.weight(1f), - icon = Icons.Filled.Check, - label = "Done", - filled = true, - onClick = onDone, - ) - } + .padding(start = 20.dp, end = 20.dp, bottom = 28.dp), + ) } } @Composable -private fun ReviewActionPill( - icon: ImageVector, - label: String, - filled: Boolean, - onClick: () -> Unit, +private fun ReviewActionsBar( + onRetake: () -> Unit, + onDone: () -> Unit, modifier: Modifier = Modifier, ) { - val accent = MaterialTheme.colorScheme.primary - val shape = RoundedCornerShape(26.dp) - val contentColor = if (filled) MaterialTheme.colorScheme.onPrimary else Color.White - val base = - modifier - .height(52.dp) - .clip(shape) - val styled = - if (filled) { - base.background(accent) - } else { - base - .background(Color.White.copy(alpha = 0.04f)) - .border(1.dp, Color.White.copy(alpha = 0.22f), shape) - } Row( - modifier = styled.clickable(onClick = onClick), - horizontalArrangement = Arrangement.Center, + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = contentColor, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = label, - style = - TextStyle( - fontFamily = bodyFontFamily, - fontSize = 14.sp, - fontWeight = if (filled) FontWeight.Bold else FontWeight.Medium, - ), - color = contentColor, - ) + FilledTonalButton( + onClick = onRetake, + modifier = + Modifier + .weight(1f) + .heightIn(min = 56.dp), + ) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.review_retake), + style = MaterialTheme.typography.titleMedium, + ) + } + Button( + onClick = onDone, + modifier = + Modifier + .weight(1f) + .heightIn(min = 56.dp), + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.review_done), + style = MaterialTheme.typography.titleMedium, + ) + } } } /** * Full-screen review of a captured video using the shared [VideoPlayer]. The player is created for - * the supplied [uri], loops, and is released automatically when this leaves composition. Mirrors - * [CapturedImagePreview]: a scrim back button plus a Retake / Done action bar. [onRetake] returns to - * the viewfinder (discarding the clip); [onDone] leaves the sample. + * the supplied [uri], loops, and is released automatically when this leaves composition. */ @Composable fun CapturedVideoPreview( @@ -228,43 +185,23 @@ fun CapturedVideoPreview( .background(Color.Black), ) { VideoPlayer(player = player) - ScrimIconButton( - onClick = onDone, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - size = 34.dp, - iconSize = 18.dp, - modifier = - Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(16.dp), + + ViewfinderTopBar( + title = null, + onClose = onDone, + closeIcon = Icons.AutoMirrored.Rounded.ArrowBack, + showApiBadge = false, ) - Row( + ReviewActionsBar( + onRetake = onRetake, + onDone = onDone, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .navigationBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = 24.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ReviewActionPill( - modifier = Modifier.weight(1f), - icon = Icons.Filled.Refresh, - label = "Retake", - filled = false, - onClick = onRetake, - ) - ReviewActionPill( - modifier = Modifier.weight(1f), - icon = Icons.Filled.Check, - label = "Done", - filled = true, - onClick = onDone, - ) - } + .padding(start = 20.dp, end = 20.dp, bottom = 28.dp), + ) } } diff --git a/core-ui/src/main/java/com/android/camera/coreui/scaffold/CameraSampleScaffold.kt b/core-ui/src/main/java/com/android/camera/coreui/scaffold/CameraSampleScaffold.kt index 38bae2fc..106ec8fb 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/scaffold/CameraSampleScaffold.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/scaffold/CameraSampleScaffold.kt @@ -20,6 +20,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -27,34 +28,39 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PhotoCamera import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -63,33 +69,29 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.android.camera.coreui.R +import com.android.camera.coreui.controls.ScrimIconButton + +/** CompositionLocal providing the [CameraApi] of the active sample to [ViewfinderTopBar]. */ +val LocalCameraApi = staticCompositionLocalOf { CameraApi.CAMERAX } /** * Scaffolds a camera sample: requests [permissions], gates the [content] behind them (showing a - * rationale / "open settings" card until granted), and paints a black full-screen surface. A - * sample's screen becomes: - * - * ``` - * CameraSampleScaffold(permissions = CameraPermissions.PHOTO) { - * when (state) { ... } - * } - * ``` - * - * The content is responsible for any in-camera chrome (back button, controls) so it composes - * correctly with full-screen overlays such as a captured-media preview. + * rationale / "open settings" card until granted), and paints an edge-to-edge black surface. */ @Composable fun CameraSampleScaffold( permissions: List, api: CameraApi, modifier: Modifier = Modifier, - rationale: String = "This sample needs camera access to run.", - deniedMessage: String = "Camera access was denied. Enable it in Settings to use this sample.", + rationale: String = stringResource(R.string.permission_rationale), + deniedMessage: String = stringResource(R.string.permission_denied), content: @Composable BoxScope.() -> Unit, ) { val context = LocalContext.current val activity = context as? Activity val lifecycleOwner = LocalLifecycleOwner.current + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher fun allGranted(): Boolean = permissions.all { @@ -129,29 +131,34 @@ fun CameraSampleScaffold( if (!hasPermission) launcher.launch(permissions.toTypedArray()) } - Scaffold( - modifier = modifier.fillMaxSize(), - containerColor = Color.Black, - ) { paddingValues -> + CompositionLocalProvider(LocalCameraApi provides api) { Box( modifier = - Modifier + modifier .fillMaxSize() - .padding(paddingValues), + .background(Color.Black), ) { if (hasPermission) { content() - ApiBadge( - api = api, + } else { + ScrimIconButton( + onClick = { backDispatcher?.onBackPressed() }, + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.close), modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 12.dp), + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(start = 16.dp, top = 12.dp), ) - } else { PermissionCard( message = if (permanentlyDenied) deniedMessage else rationale, - buttonText = if (permanentlyDenied) "Open Settings" else "Grant Permission", + buttonText = + if (permanentlyDenied) { + stringResource(R.string.permission_open_settings) + } else { + stringResource(R.string.permission_grant) + }, onClick = { if (permanentlyDenied) { context.startActivity( @@ -169,53 +176,23 @@ fun CameraSampleScaffold( } } -/** A small pill identifying which camera API the current sample uses (CameraX vs Camera2). */ -@Composable -private fun ApiBadge( - api: CameraApi, - modifier: Modifier = Modifier, -) { - Row( - modifier = - modifier - .clip(RoundedCornerShape(percent = 50)) - .background(Color.Black.copy(alpha = 0.45f)) - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(8.dp) - .clip(CircleShape) - .background(api.accent), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = api.label, - color = Color.White, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - } -} - @Composable private fun BoxScope.PermissionCard( message: String, buttonText: String, onClick: () -> Unit, ) { + val colors = MaterialTheme.colorScheme Card( modifier = Modifier .align(Alignment.Center) .fillMaxWidth() .padding(horizontal = 24.dp), - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(28.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = colors.surfaceContainerLow, ), ) { Column( @@ -223,19 +200,46 @@ private fun BoxScope.PermissionCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { + Box( + modifier = + Modifier + .size(56.dp) + .background(colors.primaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.PhotoCamera, + contentDescription = null, + tint = colors.onPrimaryContainer, + modifier = Modifier.size(28.dp), + ) + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = stringResource(R.string.permission_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = colors.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) Text( text = message, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = colors.onSurfaceVariant, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(24.dp)) Button( onClick = onClick, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), ) { - Text(text = buttonText, modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = buttonText, + style = MaterialTheme.typography.titleMedium, + ) } } } diff --git a/core-ui/src/main/java/com/android/camera/coreui/state/StateViews.kt b/core-ui/src/main/java/com/android/camera/coreui/state/StateViews.kt index c4251901..28c7337a 100644 --- a/core-ui/src/main/java/com/android/camera/coreui/state/StateViews.kt +++ b/core-ui/src/main/java/com/android/camera/coreui/state/StateViews.kt @@ -15,17 +15,37 @@ */ package com.android.camera.coreui.state +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.PhonelinkErase import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.android.camera.coreui.R +import com.android.camera.coreui.overlay.ViewfinderTopBar /** Full-screen loading spinner shown while a sample initializes. */ @Composable @@ -35,47 +55,137 @@ fun LoadingView(modifier: Modifier = Modifier) { } } -/** Full-screen error message with a retry action. */ +/** Full-screen Material 3 error card with a retry action and top navigation bar. */ @Composable fun ErrorView( errorMessage: String?, onRetry: () -> Unit, modifier: Modifier = Modifier, ) { - Box(modifier = modifier.fillMaxSize()) { - Text( - text = errorMessage ?: "Unknown Error", - color = Color.Red, - modifier = Modifier.align(Alignment.Center), + val colors = MaterialTheme.colorScheme + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + Box( + modifier = modifier.fillMaxSize(), + ) { + ViewfinderTopBar( + title = null, + onClose = { backDispatcher?.onBackPressed() }, ) - Button( - onClick = onRetry, + Surface( + shape = RoundedCornerShape(28.dp), + color = colors.surfaceContainerLow, + contentColor = colors.onSurface, modifier = Modifier - .align(Alignment.BottomCenter) - .padding(32.dp), + .align(Alignment.Center) + .fillMaxWidth() + .padding(horizontal = 24.dp), ) { - Text("Retry") + Column( + modifier = Modifier.padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(52.dp) + .background(colors.errorContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = null, + tint = colors.onErrorContainer, + modifier = Modifier.size(28.dp), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = errorMessage ?: stringResource(R.string.state_unknown_error), + style = MaterialTheme.typography.bodyLarge, + color = colors.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(20.dp)) + Button( + onClick = onRetry, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 52.dp), + ) { + Text( + text = stringResource(R.string.state_retry), + style = MaterialTheme.typography.titleMedium, + ) + } + } } } } /** - * Full-screen message shown when a sample cannot run on the current device (e.g. the camera lacks a - * required capability such as extensions or high-speed recording). + * Full-screen Material 3 notice shown when a sample cannot run on the current device (e.g. the + * camera lacks a required capability such as extensions or high-speed recording). */ @Composable fun UnsupportedView( message: String, modifier: Modifier = Modifier, ) { + val colors = MaterialTheme.colorScheme + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher Box( - modifier = - modifier - .fillMaxSize() - .padding(32.dp), - contentAlignment = Alignment.Center, + modifier = modifier.fillMaxSize(), ) { - Text(text = message, color = Color.White) + ViewfinderTopBar( + title = null, + onClose = { backDispatcher?.onBackPressed() }, + ) + Surface( + shape = RoundedCornerShape(28.dp), + color = colors.surfaceContainerLow, + contentColor = colors.onSurface, + modifier = + Modifier + .align(Alignment.Center) + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + Column( + modifier = Modifier.padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(52.dp) + .background(colors.secondaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.PhonelinkErase, + contentDescription = null, + tint = colors.onSecondaryContainer, + modifier = Modifier.size(28.dp), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.state_unsupported_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = colors.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } } } diff --git a/core-ui/src/main/res/values/strings.xml b/core-ui/src/main/res/values/strings.xml index 55f8dfbf..d1221665 100644 --- a/core-ui/src/main/res/values/strings.xml +++ b/core-ui/src/main/res/values/strings.xml @@ -2,4 +2,24 @@ Saved to Photos Couldn\'t save + Camera access + This sample needs camera access to run. + Camera access was denied. Enable it in Settings to use this sample. + Grant access + Open settings + Switch camera + Close + Back + Torch on + Torch + Turn torch off + Turn torch on + Captured photo + Retake + Done + %1$d × %2$d + Something went wrong + Retry + Not supported on this device + %1$s · %2$s diff --git a/samples/camera2-extensions/src/main/java/com/android/camera2/extensions/Camera2ExtensionsScreen.kt b/samples/camera2-extensions/src/main/java/com/android/camera2/extensions/Camera2ExtensionsScreen.kt index 79a7fb3e..ae76fc9b 100644 --- a/samples/camera2-extensions/src/main/java/com/android/camera2/extensions/Camera2ExtensionsScreen.kt +++ b/samples/camera2-extensions/src/main/java/com/android/camera2/extensions/Camera2ExtensionsScreen.kt @@ -141,7 +141,7 @@ private fun BoxScope.CapturingContent( Camera2Preview(controller = controller) ViewfinderTopBar( - title = null, + title = stringResource(extensionLabel(currentExtension)), onClose = onBack, closeIcon = Icons.AutoMirrored.Filled.ArrowBack, actions = { @@ -149,8 +149,6 @@ private fun BoxScope.CapturingContent( onClick = { settingsVisible = true }, imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.extensions_settings), - size = 34.dp, - iconSize = 18.dp, ) }, ) diff --git a/samples/camera2-hdrvideo/src/main/java/com/android/camera2/hdrvideo/Camera2HdrVideo.kt b/samples/camera2-hdrvideo/src/main/java/com/android/camera2/hdrvideo/Camera2HdrVideo.kt index 8be48e09..f01f1d5b 100644 --- a/samples/camera2-hdrvideo/src/main/java/com/android/camera2/hdrvideo/Camera2HdrVideo.kt +++ b/samples/camera2-hdrvideo/src/main/java/com/android/camera2/hdrvideo/Camera2HdrVideo.kt @@ -157,8 +157,6 @@ private fun BoxScope.CapturingContent( onClick = { isOverlayVisible = true }, imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.hdrvideo_settings), - size = 34.dp, - iconSize = 18.dp, ) } }, @@ -172,7 +170,7 @@ private fun BoxScope.CapturingContent( Modifier .align(Alignment.TopCenter) .statusBarsPadding() - .padding(top = 96.dp), + .padding(top = 68.dp), ) } diff --git a/samples/camera2-hdrviewfinder/src/main/java/com/android/camera2/hdrviewfinder/Camera2HdrViewfinderScreen.kt b/samples/camera2-hdrviewfinder/src/main/java/com/android/camera2/hdrviewfinder/Camera2HdrViewfinderScreen.kt index 211ccb16..1abf012e 100644 --- a/samples/camera2-hdrviewfinder/src/main/java/com/android/camera2/hdrviewfinder/Camera2HdrViewfinderScreen.kt +++ b/samples/camera2-hdrviewfinder/src/main/java/com/android/camera2/hdrviewfinder/Camera2HdrViewfinderScreen.kt @@ -134,7 +134,7 @@ private fun BoxScope.ViewfinderContent( var isOverlayVisible by remember { mutableStateOf(false) } ViewfinderTopBar( - title = null, + title = stringResource(mode.label), onClose = onBack, closeIcon = Icons.AutoMirrored.Filled.ArrowBack, actions = { @@ -142,8 +142,6 @@ private fun BoxScope.ViewfinderContent( onClick = { isOverlayVisible = true }, imageVector = Icons.Filled.Tune, contentDescription = stringResource(R.string.hdrviewfinder_processing_mode), - size = 34.dp, - iconSize = 18.dp, ) }, ) diff --git a/samples/camera2-manualcontrols/src/main/java/com/android/camera2/manualcontrols/Camera2ManualControlsScreen.kt b/samples/camera2-manualcontrols/src/main/java/com/android/camera2/manualcontrols/Camera2ManualControlsScreen.kt index 3e04f91b..1b4f530d 100644 --- a/samples/camera2-manualcontrols/src/main/java/com/android/camera2/manualcontrols/Camera2ManualControlsScreen.kt +++ b/samples/camera2-manualcontrols/src/main/java/com/android/camera2/manualcontrols/Camera2ManualControlsScreen.kt @@ -131,7 +131,7 @@ private fun BoxScope.CameraContent( var settingsVisible by remember { mutableStateOf(false) } ViewfinderTopBar( - title = null, + title = stringResource(R.string.manualcontrols_settings_header), onClose = onBack, closeIcon = Icons.AutoMirrored.Filled.ArrowBack, actions = { @@ -139,8 +139,6 @@ private fun BoxScope.CameraContent( onClick = { settingsVisible = true }, imageVector = Icons.Filled.Tune, contentDescription = stringResource(R.string.manualcontrols_settings), - size = 34.dp, - iconSize = 18.dp, ) }, ) diff --git a/samples/camera2-qrscanner/src/main/java/com/android/camera2/qrscanner/Camera2QrScannerScreen.kt b/samples/camera2-qrscanner/src/main/java/com/android/camera2/qrscanner/Camera2QrScannerScreen.kt index 1cd3f397..42f97957 100644 --- a/samples/camera2-qrscanner/src/main/java/com/android/camera2/qrscanner/Camera2QrScannerScreen.kt +++ b/samples/camera2-qrscanner/src/main/java/com/android/camera2/qrscanner/Camera2QrScannerScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -127,7 +128,8 @@ private fun BoxScope.ScanningContent( modifier = Modifier .align(Alignment.BottomCenter) - .padding(32.dp), + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 28.dp), ) } @@ -179,34 +181,32 @@ private fun BarcodeOverlay( } } -/** A console pill showing the most recently decoded barcode value. */ +/** A translucent pill showing the most recently decoded barcode value. */ @Composable private fun BarcodeValueChip( value: String, modifier: Modifier = Modifier, ) { - val shape = RoundedCornerShape(14.dp) Row( modifier = modifier - .clip(shape) - .background(Color.Black.copy(alpha = 0.55f)) - .border(1.dp, Color.White.copy(alpha = 0.10f), shape) - .padding(horizontal = 16.dp, vertical = 10.dp), + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 20.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier - .size(6.dp) + .size(8.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary), ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(10.dp)) Text( text = value, - color = Color.White.copy(alpha = 0.9f), - style = TextStyle(fontFamily = monoFontFamily, fontSize = 12.sp), + color = Color.White, + style = MaterialTheme.typography.labelLarge, ) } } diff --git a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureScreen.kt b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureScreen.kt index 16211816..1fcbbe48 100644 --- a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureScreen.kt +++ b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawCaptureScreen.kt @@ -126,8 +126,6 @@ private fun BoxScope.PreviewingContent( onClick = { viewModel.setSettingsVisible(true) }, imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.rawcapture_settings_button), - size = 34.dp, - iconSize = 18.dp, ) }, ) diff --git a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawEditor.kt b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawEditor.kt index dca40a25..12d41a6e 100644 --- a/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawEditor.kt +++ b/samples/camera2-rawcapture/src/main/java/com/android/camera2/rawcapture/Camera2RawEditor.kt @@ -60,9 +60,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.android.camera.coretheme.monoFontFamily -import com.android.camera.coreui.controls.ScrimIconButton import com.android.camera.coreui.controls.ValueSlider -import com.android.camera.coreui.overlay.ViewfinderTitleChip +import com.android.camera.coreui.overlay.ViewfinderTopBar import com.android.camera.coreui.state.LoadingView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -156,26 +155,11 @@ fun BoxScope.RawEditorContent( ) } - ScrimIconButton( - onClick = onBack, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.rawcapture_back_to_camera), - size = 34.dp, - iconSize = 18.dp, - modifier = - Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(16.dp), - ) - - ViewfinderTitleChip( - text = stringResource(R.string.rawcapture_editor_title), - modifier = - Modifier - .align(Alignment.TopCenter) - .statusBarsPadding() - .padding(top = 44.dp), + ViewfinderTopBar( + title = stringResource(R.string.rawcapture_editor_title), + onClose = onBack, + closeIcon = Icons.AutoMirrored.Filled.ArrowBack, + showApiBadge = false, ) } @@ -195,21 +179,17 @@ private fun EditPanel( modifier = Modifier .fillMaxWidth() - .background(Color.Black.copy(alpha = 0.7f)) + .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .background(androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainerLow) .navigationBarsPadding() - .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(horizontal = 20.dp, vertical = 16.dp) .verticalScroll(rememberScrollState()), ) { Text( text = stringResource(R.string.rawcapture_editor_caption), - color = Color.White.copy(alpha = 0.7f), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 11.sp, - letterSpacing = 0.04.em, - ), - modifier = Modifier.padding(bottom = 8.dp), + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 12.dp), ) ValueSlider( label = stringResource(R.string.rawcapture_exposure), @@ -248,25 +228,15 @@ private fun EditPanel( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp), + .padding(top = 8.dp), contentAlignment = Alignment.CenterEnd, ) { - Text( - text = stringResource(R.string.rawcapture_reset), - color = Color.White, - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 12.sp, - letterSpacing = 0.1.em, - ), - modifier = - Modifier - .clip(RoundedCornerShape(percent = 50)) - .background(Color.White.copy(alpha = 0.12f)) - .clickable(onClick = onReset) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) + androidx.compose.material3.FilledTonalButton(onClick = onReset) { + Text( + text = stringResource(R.string.rawcapture_reset), + style = androidx.compose.material3.MaterialTheme.typography.labelLarge, + ) + } } } } diff --git a/samples/camera2-slowmotion/src/main/java/com/android/camera2/slowmotion/Camera2SlowMotionController.kt b/samples/camera2-slowmotion/src/main/java/com/android/camera2/slowmotion/Camera2SlowMotionController.kt index 8eeaf46a..81c7a210 100644 --- a/samples/camera2-slowmotion/src/main/java/com/android/camera2/slowmotion/Camera2SlowMotionController.kt +++ b/samples/camera2-slowmotion/src/main/java/com/android/camera2/slowmotion/Camera2SlowMotionController.kt @@ -36,9 +36,11 @@ import android.view.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import com.android.camera.core.camera2.BaseCamera2Controller import com.android.camera.core.media.MediaStoreSaver import kotlinx.coroutines.CoroutineScope @@ -47,23 +49,29 @@ import java.util.concurrent.Executor private const val TAG = "Camera2SlowMotionCtrl" -/** Preferred high-speed capture size; falls back to the largest the device offers. */ -private val PREFERRED_SIZE = Size(1280, 720) +/** Preferred high-speed capture size (1080p); falls back to the largest the device offers. */ +private val PREFERRED_SIZE = Size(1920, 1080) + +/** Standard playback frame rate so a 120/240 fps capture plays back at 1/4x or 1/8x slow motion. */ +private const val PLAYBACK_FPS = 30 @Composable fun rememberCamera2SlowMotionController( context: Context, onUnsupported: () -> Unit, onVideoCaptured: (Uri) -> Unit, + onRecordingFailed: () -> Unit = {}, ): Camera2SlowMotionController { val coroutineScope = rememberCoroutineScope() val latestOnUnsupported by rememberUpdatedState(onUnsupported) val latestOnVideoCaptured by rememberUpdatedState(onVideoCaptured) + val latestOnRecordingFailed by rememberUpdatedState(onRecordingFailed) return remember(context) { Camera2SlowMotionController( context, onUnsupported = { latestOnUnsupported() }, onVideoCaptured = { latestOnVideoCaptured(it) }, + onRecordingFailed = { latestOnRecordingFailed() }, coroutineScope, ) } @@ -73,7 +81,8 @@ fun rememberCamera2SlowMotionController( * Camera2 high-speed ("slow motion") video controller. Reuses [BaseCamera2Controller] for the * background thread, camera open/close, transform, and tap-to-focus, but replaces the normal * capture-session path with a [CameraConstrainedHighSpeedCaptureSession] so the device can run at a - * fixed high frame rate (e.g. 120/240 fps) that [MediaRecorder] then plays back as slow motion. + * fixed high frame rate (e.g. 120/240 fps) that [MediaRecorder] then encodes for 30 fps slow-motion + * playback. * * Back camera only; high-speed recording is not available on emulators, in which case * [onUnsupported] fires and the screen shows the unsupported state. @@ -83,12 +92,16 @@ class Camera2SlowMotionController( context: Context, private val onUnsupported: () -> Unit, private val onVideoCaptured: (Uri) -> Unit, + private val onRecordingFailed: () -> Unit, private val coroutineScope: CoroutineScope, ) : BaseCamera2Controller(context, isFrontCamera = false) { private var isSupported = true private var highSpeedSize: Size = PREFERRED_SIZE private var highSpeedFpsRange: Range = Range(120, 120) + var currentFps: Int by mutableIntStateOf(0) + private set + private var isRecording = false private var mediaRecorder: MediaRecorder? = null private var pendingVideo: MediaStoreSaver.PendingVideo? = null @@ -118,6 +131,7 @@ class Camera2SlowMotionController( isSupported = true highSpeedSize = selection.first highSpeedFpsRange = selection.second + currentFps = highSpeedFpsRange.upper Log.d(TAG, "High-speed config: size=$highSpeedSize fps=$highSpeedFpsRange") } @@ -157,7 +171,7 @@ class Camera2SlowMotionController( surface: Surface, ) { if (!isSupported) { - onUnsupported() + coroutineScope.launch { onUnsupported() } return } previewSurface = surface @@ -165,6 +179,7 @@ class Camera2SlowMotionController( } override fun onCameraClosed() { + isRecording = false releaseMediaRecorder() } @@ -190,6 +205,7 @@ class Camera2SlowMotionController( private fun createHighSpeedSession( camera: CameraDevice, surfaces: List, + onConfigureFailedCallback: () -> Unit = {}, onConfigured: (CameraConstrainedHighSpeedCaptureSession) -> Unit, ) { val stateCallback = @@ -202,6 +218,7 @@ class Camera2SlowMotionController( override fun onConfigureFailed(session: CameraCaptureSession) { Log.e(TAG, "Failed to configure high-speed session") + onConfigureFailedCallback() } } @@ -252,6 +269,10 @@ class Camera2SlowMotionController( val pending = MediaStoreSaver.newPendingVideo(context) ?: return pendingVideo = pending + // True slow-motion encoding in MediaRecorder (StagefrightRecorder) uses video-only capture + // with captureRate = high-speed FPS (e.g. 240.0) and videoFrameRate = 30 so recorded frames + // are time-stretched for 30 fps slow-motion playback. Audio is omitted because MediaRecorder + // rejects audio tracks when captureRate differs from videoFrameRate. mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { MediaRecorder(context) @@ -259,7 +280,6 @@ class Camera2SlowMotionController( @Suppress("DEPRECATION") MediaRecorder() }.apply { - setAudioSource(MediaRecorder.AudioSource.MIC) setVideoSource(MediaRecorder.VideoSource.SURFACE) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) if (pending.usesFileDescriptor) { @@ -267,14 +287,11 @@ class Camera2SlowMotionController( } else { setOutputFile(pending.legacyFilePath) } - setVideoEncodingBitRate(10_000_000) - setVideoFrameRate(fps) - // Capturing at the high rate while keeping the playback frame rate lower yields the - // slow-motion effect. + setVideoEncodingBitRate(20_000_000) + setVideoFrameRate(PLAYBACK_FPS) setCaptureRate(fps.toDouble()) setVideoSize(highSpeedSize.width, highSpeedSize.height) setVideoEncoder(MediaRecorder.VideoEncoder.H264) - setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setOrientationHint(orientationHint) prepare() } @@ -291,17 +308,33 @@ class Camera2SlowMotionController( } fun startRecording() { - if (isRecording || !isSupported) return - val camera = cameraDevice ?: return - val viewfinderSurface = previewSurface ?: return + backgroundHandler.post { + if (isRecording || !isSupported) { + coroutineScope.launch { onRecordingFailed() } + return@post + } + val camera = cameraDevice + val viewfinderSurface = previewSurface + if (camera == null || viewfinderSurface == null) { + coroutineScope.launch { onRecordingFailed() } + return@post + } - coroutineScope.launch { try { - captureSession?.close() + try { + captureSession?.stopRepeating() + captureSession?.close() + } catch (e: Exception) { + Log.w(TAG, "Exception closing preview session before recording", e) + } captureSession = null setupMediaRecorder() - val recorderSurface = mediaRecorder?.surface ?: return@launch + val recorderSurface = + mediaRecorder?.surface ?: run { + coroutineScope.launch { onRecordingFailed() } + return@post + } previewRequestBuilder = camera.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply { @@ -310,7 +343,16 @@ class Camera2SlowMotionController( set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, highSpeedFpsRange) } - createHighSpeedSession(camera, listOf(viewfinderSurface, recorderSurface)) { session -> + createHighSpeedSession( + camera = camera, + surfaces = listOf(viewfinderSurface, recorderSurface), + onConfigureFailedCallback = { + releaseMediaRecorder() + pendingVideo?.discard(context) + pendingVideo = null + coroutineScope.launch { onRecordingFailed() } + }, + ) { session -> try { val builder = previewRequestBuilder ?: return@createHighSpeedSession val requestList = session.createHighSpeedRequestList(builder.build()) @@ -319,51 +361,65 @@ class Camera2SlowMotionController( isRecording = true } catch (e: Exception) { Log.e(TAG, "Failed to start high-speed recording", e) + releaseMediaRecorder() + pendingVideo?.discard(context) + pendingVideo = null + coroutineScope.launch { onRecordingFailed() } } } } catch (e: Exception) { Log.e(TAG, "Exception starting recording", e) + releaseMediaRecorder() + pendingVideo?.discard(context) + pendingVideo = null + coroutineScope.launch { onRecordingFailed() } } } } fun stopRecording() { - if (!isRecording) return - isRecording = false + backgroundHandler.post { + if (!isRecording) { + coroutineScope.launch { onRecordingFailed() } + return@post + } + isRecording = false - try { - captureSession?.stopRepeating() - captureSession?.close() - captureSession = null - } catch (e: CameraAccessException) { - Log.e(TAG, "Exception stopping repeating", e) - } + try { + captureSession?.stopRepeating() + captureSession?.close() + captureSession = null + } catch (e: Exception) { + Log.e(TAG, "Exception stopping repeating", e) + } - var stopFailed = false - try { - mediaRecorder?.stop() - } catch (e: RuntimeException) { - // MediaRecorder throws if stop is called immediately after start. - Log.e(TAG, "RuntimeException stopping MediaRecorder", e) - stopFailed = true - pendingVideo?.discard(context) - pendingVideo = null - } + var stopFailed = false + try { + mediaRecorder?.stop() + } catch (e: RuntimeException) { + // MediaRecorder throws if stop is called immediately after start. + Log.e(TAG, "RuntimeException stopping MediaRecorder", e) + stopFailed = true + pendingVideo?.discard(context) + pendingVideo = null + } - releaseMediaRecorder() + releaseMediaRecorder() - if (!stopFailed) { val pv = pendingVideo - if (pv != null) { + pendingVideo = null + if (!stopFailed && pv != null) { pv.finalize(context) - onVideoCaptured(pv.uri) + val uri = pv.uri + coroutineScope.launch { onVideoCaptured(uri) } + } else { + coroutineScope.launch { onRecordingFailed() } } - pendingVideo = null - } - // Restart the preview session. - val surface = previewSurface - val camera = cameraDevice - if (surface != null && camera != null) startPreviewSession(camera, surface) + // Restart the preview session. + val surface = previewSurface + val camera = cameraDevice + if (surface != null && camera != null) startPreviewSession(camera, surface) + } } } diff --git a/samples/camera2-slowmotion/src/main/java/com/android/camera2/slowmotion/Camera2SlowMotionScreen.kt b/samples/camera2-slowmotion/src/main/java/com/android/camera2/slowmotion/Camera2SlowMotionScreen.kt index 9a69504b..6143138e 100644 --- a/samples/camera2-slowmotion/src/main/java/com/android/camera2/slowmotion/Camera2SlowMotionScreen.kt +++ b/samples/camera2-slowmotion/src/main/java/com/android/camera2/slowmotion/Camera2SlowMotionScreen.kt @@ -17,16 +17,23 @@ package com.android.camera2.slowmotion import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect 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.geometry.Offset import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.camera.core.camera2.Camera2Preview @@ -34,6 +41,8 @@ import com.android.camera.core.permissions.CameraPermissions import com.android.camera.coreui.controls.CameraControlsBar import com.android.camera.coreui.controls.RecordButton import com.android.camera.coreui.feedback.ObserveSaveEvents +import com.android.camera.coreui.overlay.FocusIndicator +import com.android.camera.coreui.overlay.ViewfinderTitleChip import com.android.camera.coreui.overlay.ViewfinderTopBar import com.android.camera.coreui.preview.CapturedVideoPreview import com.android.camera.coreui.scaffold.CameraApi @@ -104,13 +113,21 @@ private fun BoxScope.CapturingContent( context = context, onUnsupported = viewModel::markUnsupported, onVideoCaptured = viewModel::videoCaptured, + onRecordingFailed = viewModel::resetToCamera, ) DisposableEffect(controller) { onDispose { controller.release() } } - Camera2Preview(controller = controller) + var tapOffset by remember { mutableStateOf(null) } + + Camera2Preview( + controller = controller, + onFocusTap = { tapOffset = it }, + ) + + FocusIndicator(tapOffset = tapOffset) ViewfinderTopBar( title = stringResource(R.string.slowmotion_title), @@ -118,6 +135,17 @@ private fun BoxScope.CapturingContent( closeIcon = Icons.AutoMirrored.Filled.ArrowBack, ) + if (controller.currentFps > 0) { + ViewfinderTitleChip( + text = stringResource(R.string.slowmotion_fps_badge, controller.currentFps), + modifier = + Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 68.dp), + ) + } + CameraControlsBar( modifier = Modifier.align(Alignment.BottomCenter), center = { diff --git a/samples/camera2-slowmotion/src/main/res/values/strings.xml b/samples/camera2-slowmotion/src/main/res/values/strings.xml index 48a5886c..2f4d44ff 100644 --- a/samples/camera2-slowmotion/src/main/res/values/strings.xml +++ b/samples/camera2-slowmotion/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ Back + %1$d fps Slow Motion Slow-motion (high-speed) recording isn\'t supported on this device. diff --git a/samples/camera2-takeaphoto/src/main/java/com/android/camera2/takeaphoto/Camera2TakeAPhotoScreen.kt b/samples/camera2-takeaphoto/src/main/java/com/android/camera2/takeaphoto/Camera2TakeAPhotoScreen.kt index b3378df1..6d5b87fc 100644 --- a/samples/camera2-takeaphoto/src/main/java/com/android/camera2/takeaphoto/Camera2TakeAPhotoScreen.kt +++ b/samples/camera2-takeaphoto/src/main/java/com/android/camera2/takeaphoto/Camera2TakeAPhotoScreen.kt @@ -18,8 +18,6 @@ package com.android.camera2.takeaphoto import android.media.Image import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.BoxScope -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cameraswitch import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -37,7 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.camera.core.camera2.Camera2Preview import com.android.camera.core.permissions.CameraPermissions import com.android.camera.coreui.controls.CameraControlsBar -import com.android.camera.coreui.controls.ScrimIconButton +import com.android.camera.coreui.controls.CameraSwitchButton import com.android.camera.coreui.controls.ShutterButton import com.android.camera.coreui.feedback.ObserveSaveEvents import com.android.camera.coreui.overlay.FocusIndicator @@ -131,13 +129,7 @@ private fun BoxScope.CapturingContent( CameraControlsBar( modifier = Modifier.align(Alignment.BottomCenter), - endSlot = { - ScrimIconButton( - onClick = onSwapCamera, - imageVector = Icons.Filled.Cameraswitch, - contentDescription = stringResource(R.string.takeaphoto_swap_camera), - ) - }, + endSlot = { CameraSwitchButton(onClick = onSwapCamera) }, center = { ShutterButton(onClick = controller::takePicture) }, ) } diff --git a/samples/camera2-takeavideo/src/main/java/com/android/camera2/takeavideo/Camera2TakeAVideoScreen.kt b/samples/camera2-takeavideo/src/main/java/com/android/camera2/takeavideo/Camera2TakeAVideoScreen.kt index 2f69f3fe..ee090978 100644 --- a/samples/camera2-takeavideo/src/main/java/com/android/camera2/takeavideo/Camera2TakeAVideoScreen.kt +++ b/samples/camera2-takeavideo/src/main/java/com/android/camera2/takeavideo/Camera2TakeAVideoScreen.kt @@ -154,8 +154,6 @@ private fun BoxScope.CapturingContent( onClick = viewModel::toggleOverlay, imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.takeavideo_settings), - size = 34.dp, - iconSize = 18.dp, ) } }, diff --git a/samples/camera2-zoomandtorch/src/main/java/com/android/camera2/zoomandtorch/Camera2ZoomAndTorchScreen.kt b/samples/camera2-zoomandtorch/src/main/java/com/android/camera2/zoomandtorch/Camera2ZoomAndTorchScreen.kt index ab0fc8f3..0bd40c13 100644 --- a/samples/camera2-zoomandtorch/src/main/java/com/android/camera2/zoomandtorch/Camera2ZoomAndTorchScreen.kt +++ b/samples/camera2-zoomandtorch/src/main/java/com/android/camera2/zoomandtorch/Camera2ZoomAndTorchScreen.kt @@ -16,7 +16,9 @@ package com.android.camera2.zoomandtorch import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -115,14 +117,6 @@ private fun BoxScope.PreviewingContent( ViewfinderTopBar( title = stringResource(R.string.zoomandtorch_title), onClose = onBack, - actions = { - if (state.hasFlash && !state.isFrontCamera) { - TorchChip( - on = state.torchOn, - onToggle = viewModel::toggleTorch, - ) - } - }, ) val zoomEnabled = state.minZoom < state.maxZoom @@ -132,21 +126,32 @@ private fun BoxScope.PreviewingContent( } else { state.minZoom..(state.minZoom + 1f) } - ZoomControls( - zoomRatio = state.zoomRatio, - valueRange = zoomRange, - onValueChange = viewModel::setZoom, - enabled = zoomEnabled, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 150.dp), - ) - - CameraControlsBar( + Column( modifier = Modifier.align(Alignment.BottomCenter), - endSlot = { CameraSwitchButton(onClick = viewModel::swapCamera) }, - center = {}, - ) + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ZoomControls( + zoomRatio = state.zoomRatio, + valueRange = zoomRange, + onValueChange = viewModel::setZoom, + enabled = zoomEnabled, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + + CameraControlsBar( + modifier = Modifier.fillMaxWidth(), + center = { + if (state.hasFlash && !state.isFrontCamera) { + TorchChip( + on = state.torchOn, + onToggle = viewModel::toggleTorch, + ) + } + }, + endSlot = { CameraSwitchButton(onClick = viewModel::swapCamera) }, + ) + } } diff --git a/samples/camerax-concurrentcamera/src/main/java/com/android/camerax/concurrentcamera/CameraXConcurrentCameraScreen.kt b/samples/camerax-concurrentcamera/src/main/java/com/android/camerax/concurrentcamera/CameraXConcurrentCameraScreen.kt index 35698b96..c3127aa2 100644 --- a/samples/camerax-concurrentcamera/src/main/java/com/android/camerax/concurrentcamera/CameraXConcurrentCameraScreen.kt +++ b/samples/camerax-concurrentcamera/src/main/java/com/android/camerax/concurrentcamera/CameraXConcurrentCameraScreen.kt @@ -134,7 +134,7 @@ private fun BoxScope.StreamingContent( Modifier .align(Alignment.TopEnd) .statusBarsPadding() - .padding(top = 56.dp, end = 16.dp) + .padding(top = 68.dp, end = 16.dp) .width(116.dp) .height(174.dp) .clip(pipShape) @@ -146,16 +146,8 @@ private fun BoxScope.StreamingContent( } ViewfinderTopBar( - title = null, + title = stringResource(R.string.concurrentcamera_title), onClose = onBack, closeIcon = Icons.AutoMirrored.Filled.ArrowBack, ) - - ViewfinderTitleChip( - text = stringResource(R.string.concurrentcamera_title), - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 40.dp), - ) } diff --git a/samples/camerax-exposure/src/main/java/com/android/camerax/exposure/CameraXExposureScreen.kt b/samples/camerax-exposure/src/main/java/com/android/camerax/exposure/CameraXExposureScreen.kt index efa65b59..bf3796bf 100644 --- a/samples/camerax-exposure/src/main/java/com/android/camerax/exposure/CameraXExposureScreen.kt +++ b/samples/camerax-exposure/src/main/java/com/android/camerax/exposure/CameraXExposureScreen.kt @@ -132,7 +132,7 @@ private fun BoxScope.PreviewingContent( } ViewfinderTopBar( - title = null, + title = stringResource(R.string.exposure_slider_label), onClose = onBack, closeIcon = Icons.AutoMirrored.Filled.ArrowBack, ) @@ -153,12 +153,11 @@ private fun BoxScope.PreviewingContent( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = 20.dp), ) CameraControlsBar( modifier = Modifier.fillMaxWidth(), - endSlot = { CameraSwitchButton(onClick = onSwapCamera) }, - center = {}, + center = { CameraSwitchButton(onClick = onSwapCamera) }, ) } } diff --git a/samples/camerax-extensions/src/main/java/com/android/camerax/extensions/CameraXExtensionsScreen.kt b/samples/camerax-extensions/src/main/java/com/android/camerax/extensions/CameraXExtensionsScreen.kt index 9e73e58d..20be3114 100644 --- a/samples/camerax-extensions/src/main/java/com/android/camerax/extensions/CameraXExtensionsScreen.kt +++ b/samples/camerax-extensions/src/main/java/com/android/camerax/extensions/CameraXExtensionsScreen.kt @@ -178,7 +178,7 @@ private fun BoxScope.CapturingContent( } ViewfinderTopBar( - title = null, + title = stringResource(extensionModeLabel(currentMode)), onClose = onBack, closeIcon = Icons.AutoMirrored.Filled.ArrowBack, actions = { @@ -186,8 +186,6 @@ private fun BoxScope.CapturingContent( onClick = { settingsVisible = true }, imageVector = Icons.Filled.AutoAwesome, contentDescription = stringResource(R.string.extensions_mode_button), - size = 34.dp, - iconSize = 18.dp, ) }, ) diff --git a/samples/camerax-featurecombination/src/main/java/com/android/camerax/featurecombination/CameraXFeatureCombination.kt b/samples/camerax-featurecombination/src/main/java/com/android/camerax/featurecombination/CameraXFeatureCombination.kt index 63899f6e..db1899f8 100644 --- a/samples/camerax-featurecombination/src/main/java/com/android/camerax/featurecombination/CameraXFeatureCombination.kt +++ b/samples/camerax-featurecombination/src/main/java/com/android/camerax/featurecombination/CameraXFeatureCombination.kt @@ -17,10 +17,7 @@ package com.android.camerax.featurecombination import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow @@ -38,6 +35,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -46,14 +44,10 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -62,7 +56,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.camera.core.camerax.CameraXPreview import com.android.camera.core.display.rememberDisplayRotation import com.android.camera.core.permissions.CameraPermissions -import com.android.camera.coretheme.monoFontFamily import com.android.camera.coreui.controls.CameraSwitchButton import com.android.camera.coreui.overlay.SettingsHeader import com.android.camera.coreui.overlay.ViewfinderTitleChip @@ -72,12 +65,6 @@ import com.android.camera.coreui.scaffold.CameraSampleScaffold import com.android.camera.coreui.state.ErrorView import com.android.camera.coreui.state.LoadingView -private val PanelSurface = Color(0xFF14160D) -private val ChipIdleBg = Color.White.copy(alpha = 0.05f) -private val ChipIdleBorder = Color.White.copy(alpha = 0.12f) -private val ChipIdleText = Color.White.copy(alpha = 0.85f) -private val UnsupportedRed = Color(0xFFE57373) - @Composable fun CameraXFeatureCombination( viewModel: CameraXFeatureCombinationViewModel = @@ -207,17 +194,17 @@ private fun DiagnosticPanel( modifier = modifier .fillMaxWidth() - .heightIn(max = 380.dp) - .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) - .background(PanelSurface) + .heightIn(max = 400.dp) + .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow) .verticalScroll(rememberScrollState()) .navigationBarsPadding() - .padding(20.dp), + .padding(horizontal = 24.dp, vertical = 20.dp), ) { SettingsHeader(text = stringResource(R.string.featurecombination_matrix_header)) ready.matrix.forEach { row -> MatrixRowView(row) } - Spacer(Modifier.height(18.dp)) + Spacer(Modifier.height(20.dp)) SettingsHeader(text = stringResource(R.string.featurecombination_interactive_header)) FlowRow( @@ -234,12 +221,12 @@ private fun DiagnosticPanel( } if (ready.selected.isNotEmpty()) { - Spacer(Modifier.height(14.dp)) + Spacer(Modifier.height(16.dp)) if (!ready.currentlySupported) { Text( text = stringResource(R.string.featurecombination_not_supported), - style = TextStyle(fontFamily = monoFontFamily, fontSize = 12.sp, letterSpacing = 0.04.em), - color = UnsupportedRed, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, ) } else { Button(onClick = onApply, enabled = !ready.applied) { @@ -263,19 +250,20 @@ private fun DiagnosticPanel( private fun MatrixRowView(row: MatrixRow) { val accent = MaterialTheme.colorScheme.primary Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(row.label), - style = TextStyle(fontFamily = monoFontFamily, fontSize = 12.sp), - color = Color.White.copy(alpha = 0.75f), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) Text( text = if (row.supported) "✓" else "✗", - style = TextStyle(fontFamily = monoFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Bold), - color = if (row.supported) accent else Color.White.copy(alpha = 0.3f), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (row.supported) accent else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), ) } } @@ -286,27 +274,29 @@ private fun FeatureChip( selected: Boolean, onClick: () -> Unit, ) { - val accent = MaterialTheme.colorScheme.primary - val shape = RoundedCornerShape(14.dp) - val styled = + val containerColor = + if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val contentColor = if (selected) { - Modifier.clip(shape).background(accent) + MaterialTheme.colorScheme.onPrimaryContainer } else { - Modifier.clip(shape).background(ChipIdleBg).border(1.dp, ChipIdleBorder, shape) + MaterialTheme.colorScheme.onSurfaceVariant } - Box( - modifier = styled.clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 7.dp), - contentAlignment = Alignment.Center, + Surface( + onClick = onClick, + shape = RoundedCornerShape(16.dp), + color = containerColor, + contentColor = contentColor, ) { Text( text = label, - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 12.sp, - fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, - ), - color = if (selected) MaterialTheme.colorScheme.onPrimary else ChipIdleText, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), ) } } diff --git a/samples/camerax-featurecombination/src/main/java/com/android/camerax/featurecombination/CameraXFeatureCombinationController.kt b/samples/camerax-featurecombination/src/main/java/com/android/camerax/featurecombination/CameraXFeatureCombinationController.kt index 3f0b9e43..7b5fcc49 100644 --- a/samples/camerax-featurecombination/src/main/java/com/android/camerax/featurecombination/CameraXFeatureCombinationController.kt +++ b/samples/camerax-featurecombination/src/main/java/com/android/camerax/featurecombination/CameraXFeatureCombinationController.kt @@ -29,8 +29,6 @@ import androidx.camera.core.SurfaceOrientedMeteringPointFactory import androidx.camera.core.SurfaceRequest import androidx.camera.core.UseCase import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.video.Quality -import androidx.camera.video.QualitySelector import androidx.camera.video.Recorder import androidx.camera.video.VideoCapture import androidx.compose.runtime.Composable @@ -129,7 +127,7 @@ class CameraXFeatureCombinationController( } private val videoCapture = VideoCapture.withOutput( - Recorder.Builder().setQualitySelector(QualitySelector.from(Quality.HD)).build(), + Recorder.Builder().build(), ) private val imageCapture = ImageCapture.Builder().build() diff --git a/samples/camerax-flipwhilerecording/src/main/java/com/android/camerax/flipwhilerecording/CameraXFlipWhileRecording.kt b/samples/camerax-flipwhilerecording/src/main/java/com/android/camerax/flipwhilerecording/CameraXFlipWhileRecording.kt index 29b84e4e..d77d904a 100644 --- a/samples/camerax-flipwhilerecording/src/main/java/com/android/camerax/flipwhilerecording/CameraXFlipWhileRecording.kt +++ b/samples/camerax-flipwhilerecording/src/main/java/com/android/camerax/flipwhilerecording/CameraXFlipWhileRecording.kt @@ -180,8 +180,6 @@ private fun BoxScope.CapturingContent( onClick = { isOverlayVisible = true }, imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.flipwhilerecording_settings), - size = 34.dp, - iconSize = 18.dp, ) } }, diff --git a/samples/camerax-hdrvideo/src/main/java/com/android/camerax/hdrvideo/CameraXHdrVideo.kt b/samples/camerax-hdrvideo/src/main/java/com/android/camerax/hdrvideo/CameraXHdrVideo.kt index f2ddb7e3..888f592d 100644 --- a/samples/camerax-hdrvideo/src/main/java/com/android/camerax/hdrvideo/CameraXHdrVideo.kt +++ b/samples/camerax-hdrvideo/src/main/java/com/android/camerax/hdrvideo/CameraXHdrVideo.kt @@ -213,8 +213,6 @@ private fun BoxScope.CapturingContent( onClick = { isOverlayVisible = true }, imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.hdrvideo_settings), - size = 34.dp, - iconSize = 18.dp, ) } }, @@ -228,7 +226,7 @@ private fun BoxScope.CapturingContent( Modifier .align(Alignment.TopCenter) .statusBarsPadding() - .padding(top = 96.dp), + .padding(top = 68.dp), ) } diff --git a/samples/camerax-imagelabeling/src/main/java/com/android/camerax/imagelabeling/CameraXImageLabelingScreen.kt b/samples/camerax-imagelabeling/src/main/java/com/android/camerax/imagelabeling/CameraXImageLabelingScreen.kt index db2e0fbc..ee55f3c0 100644 --- a/samples/camerax-imagelabeling/src/main/java/com/android/camerax/imagelabeling/CameraXImageLabelingScreen.kt +++ b/samples/camerax-imagelabeling/src/main/java/com/android/camerax/imagelabeling/CameraXImageLabelingScreen.kt @@ -17,12 +17,12 @@ package com.android.camerax.imagelabeling import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -39,11 +39,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -51,8 +48,6 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.camera.core.camerax.CameraXPreview import com.android.camera.core.permissions.CameraPermissions -import com.android.camera.coretheme.bodyFontFamily -import com.android.camera.coretheme.monoFontFamily import com.android.camera.coreui.overlay.ViewfinderTopBar import com.android.camera.coreui.scaffold.CameraApi import com.android.camera.coreui.scaffold.CameraSampleScaffold @@ -136,7 +131,7 @@ private fun BoxScope.AnalyzingContent( } ViewfinderTopBar( - title = null, + title = stringResource(R.string.imagelabeling_labels_header), onClose = onBack, closeIcon = Icons.AutoMirrored.Filled.ArrowBack, ) @@ -147,7 +142,8 @@ private fun BoxScope.AnalyzingContent( Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .padding(24.dp), + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 28.dp), ) } @@ -158,25 +154,20 @@ private fun LabelOverlay( ) { if (labels.isEmpty()) return - val shape = RoundedCornerShape(16.dp) + val shape = RoundedCornerShape(24.dp) Column( modifier = modifier .clip(shape) - .background(Color.Black.copy(alpha = 0.55f)) - .border(1.dp, Color.White.copy(alpha = 0.08f), shape) - .padding(horizontal = 16.dp, vertical = 14.dp), + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( text = stringResource(R.string.imagelabeling_labels_header), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 10.sp, - letterSpacing = 0.16.em, - ), - color = Color.White.copy(alpha = 0.40f), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = 0.7f), ) labels.take(MAX_LABELS).forEach { label -> val percent = (label.confidence * 100).roundToInt() @@ -187,18 +178,15 @@ private fun LabelOverlay( ) { Text( text = label.text, - color = Color.White.copy(alpha = 0.92f), - style = - TextStyle( - fontFamily = bodyFontFamily, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - ), + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, ) Text( text = stringResource(R.string.imagelabeling_confidence_percent, percent), color = MaterialTheme.colorScheme.primary, - style = TextStyle(fontFamily = monoFontFamily, fontSize = 12.sp), + style = MaterialTheme.typography.labelLarge.copy(fontFeatureSettings = "tnum"), + fontWeight = FontWeight.SemiBold, ) } } diff --git a/samples/camerax-lowlightboost/src/main/java/com/android/camerax/lowlightboost/CameraXLowLightBoost.kt b/samples/camerax-lowlightboost/src/main/java/com/android/camerax/lowlightboost/CameraXLowLightBoost.kt index 4daeb4d2..84910018 100644 --- a/samples/camerax-lowlightboost/src/main/java/com/android/camerax/lowlightboost/CameraXLowLightBoost.kt +++ b/samples/camerax-lowlightboost/src/main/java/com/android/camerax/lowlightboost/CameraXLowLightBoost.kt @@ -16,18 +16,19 @@ package com.android.camerax.lowlightboost import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.WbSunny import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -35,14 +36,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -50,7 +48,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.camera.core.camerax.CameraXPreview import com.android.camera.core.permissions.CameraPermissions -import com.android.camera.coretheme.monoFontFamily +import com.android.camera.coreui.controls.CameraControlsBar import com.android.camera.coreui.overlay.ViewfinderTopBar import com.android.camera.coreui.scaffold.CameraApi import com.android.camera.coreui.scaffold.CameraSampleScaffold @@ -141,7 +139,11 @@ private fun BoxScope.PreviewingContent( ViewfinderTopBar( title = stringResource(R.string.lowlightboost_title), onClose = onBack, - actions = { LowLightChip(on = state.enabled, onToggle = viewModel::toggle) }, + ) + + CameraControlsBar( + modifier = Modifier.align(Alignment.BottomCenter), + center = { LowLightChip(on = state.enabled, onToggle = viewModel::toggle) }, ) } @@ -151,30 +153,44 @@ private fun LowLightChip( onToggle: () -> Unit, modifier: Modifier = Modifier, ) { - val tint = if (on) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.75f) - Row( - modifier = - modifier - .clip(RoundedCornerShape(percent = 50)) - .background(Color.Black.copy(alpha = 0.42f)) - .clickable(onClick = onToggle) - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + val containerColor = + if (on) { + MaterialTheme.colorScheme.primaryContainer + } else { + Color.Black.copy(alpha = 0.55f) + } + val contentColor = + if (on) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + Color.White + } + Surface( + onClick = onToggle, + modifier = modifier.heightIn(min = 48.dp), + shape = CircleShape, + color = containerColor, + contentColor = contentColor, + border = if (on) null else BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)), ) { - Icon( - imageVector = Icons.Filled.WbSunny, - contentDescription = null, - tint = tint, - modifier = Modifier.size(16.dp), - ) - Text( - text = - stringResource( - if (on) R.string.lowlightboost_chip_on else R.string.lowlightboost_chip, - ).uppercase(), - style = TextStyle(fontFamily = monoFontFamily, fontSize = 10.sp, letterSpacing = 0.1.em), - color = tint, - ) + Row( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Filled.WbSunny, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text( + text = + stringResource( + if (on) R.string.lowlightboost_chip_on else R.string.lowlightboost_chip, + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = if (on) FontWeight.SemiBold else FontWeight.Medium, + ) + } } } diff --git a/samples/camerax-luminosity/src/main/java/com/android/camerax/luminosity/CameraXLuminosityScreen.kt b/samples/camerax-luminosity/src/main/java/com/android/camerax/luminosity/CameraXLuminosityScreen.kt index 043ad3a6..cc8aed53 100644 --- a/samples/camerax-luminosity/src/main/java/com/android/camerax/luminosity/CameraXLuminosityScreen.kt +++ b/samples/camerax-luminosity/src/main/java/com/android/camerax/luminosity/CameraXLuminosityScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -150,46 +151,50 @@ private fun BoxScope.AnalyzingContent( Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 40.dp), + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 28.dp), ) } -/** A console-style HUD: the luminance percentage, a fill meter, and the analysis frame rate. */ +/** A Material 3 HUD card: the luminance percentage, a fill meter, and the analysis frame rate. */ @Composable private fun LuminosityHud( luma: Float, fps: Float, modifier: Modifier = Modifier, ) { - val shape = RoundedCornerShape(16.dp) + val shape = RoundedCornerShape(24.dp) Column( modifier = modifier .clip(shape) - .background(Color.Black.copy(alpha = 0.5f)) - .border(1.dp, Color.White.copy(alpha = 0.10f), shape) - .padding(horizontal = 18.dp, vertical = 16.dp), + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 20.dp, vertical = 18.dp), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - HudText(text = stringResource(R.string.luminosity_hud_luminance), alpha = 0.6f) - HudText( + Text( + text = stringResource(R.string.luminosity_hud_luminance), + style = MaterialTheme.typography.titleSmall, + color = Color.White.copy(alpha = 0.75f), + ) + Text( text = stringResource(R.string.luminosity_hud_percent, (luma * 100).roundToInt()), color = MaterialTheme.colorScheme.primary, - size = 16.sp, + style = MaterialTheme.typography.headlineSmall.copy(fontFeatureSettings = "tnum"), ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(14.dp)) Box( modifier = Modifier .fillMaxWidth() .height(8.dp) .clip(CircleShape) - .background(Color.White.copy(alpha = 0.12f)), + .background(Color.White.copy(alpha = 0.16f)), ) { Box( modifier = @@ -200,32 +205,21 @@ private fun LuminosityHud( .background(MaterialTheme.colorScheme.primary), ) } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - HudText(text = stringResource(R.string.luminosity_hud_raw, (luma * 255).roundToInt()), alpha = 0.45f) - HudText(text = stringResource(R.string.luminosity_hud_fps, fps.roundToInt()), alpha = 0.45f) + Text( + text = stringResource(R.string.luminosity_hud_raw, (luma * 255).roundToInt()), + style = MaterialTheme.typography.labelMedium.copy(fontFeatureSettings = "tnum"), + color = Color.White.copy(alpha = 0.6f), + ) + Text( + text = stringResource(R.string.luminosity_hud_fps, fps.roundToInt()), + style = MaterialTheme.typography.labelMedium.copy(fontFeatureSettings = "tnum"), + color = Color.White.copy(alpha = 0.6f), + ) } } } - -@Composable -private fun HudText( - text: String, - color: Color = Color.White, - alpha: Float = 1f, - size: TextUnit = 11.sp, -) { - Text( - text = text, - color = color.copy(alpha = alpha), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = size, - letterSpacing = 0.08.em, - ), - ) -} diff --git a/samples/camerax-media3effects/src/main/java/com/android/camerax/media3effects/CameraXMedia3EffectsScreen.kt b/samples/camerax-media3effects/src/main/java/com/android/camerax/media3effects/CameraXMedia3EffectsScreen.kt index 6fa6ff3c..f757129e 100644 --- a/samples/camerax-media3effects/src/main/java/com/android/camerax/media3effects/CameraXMedia3EffectsScreen.kt +++ b/samples/camerax-media3effects/src/main/java/com/android/camerax/media3effects/CameraXMedia3EffectsScreen.kt @@ -133,8 +133,6 @@ private fun BoxScope.PreviewingContent( onClick = { isOverlayVisible = true }, imageVector = Icons.Filled.AutoFixHigh, contentDescription = stringResource(R.string.media3effects_settings), - size = 34.dp, - iconSize = 18.dp, ) }, ) diff --git a/samples/camerax-qrscanner/src/main/java/com/android/camerax/qrscanner/CameraXQrScannerScreen.kt b/samples/camerax-qrscanner/src/main/java/com/android/camerax/qrscanner/CameraXQrScannerScreen.kt index 8e1aaf01..811207df 100644 --- a/samples/camerax-qrscanner/src/main/java/com/android/camerax/qrscanner/CameraXQrScannerScreen.kt +++ b/samples/camerax-qrscanner/src/main/java/com/android/camerax/qrscanner/CameraXQrScannerScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -151,7 +152,8 @@ private fun BoxScope.ScanningContent( modifier = Modifier .align(Alignment.BottomCenter) - .padding(32.dp), + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 28.dp), ) } @@ -204,34 +206,32 @@ private fun BarcodeOverlay( } } -/** A console pill showing the most recently decoded barcode value. */ +/** A translucent pill showing the most recently decoded barcode value. */ @Composable private fun BarcodeValueChip( value: String, modifier: Modifier = Modifier, ) { - val shape = RoundedCornerShape(14.dp) Row( modifier = modifier - .clip(shape) - .background(Color.Black.copy(alpha = 0.55f)) - .border(1.dp, Color.White.copy(alpha = 0.10f), shape) - .padding(horizontal = 16.dp, vertical = 10.dp), + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 20.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier - .size(6.dp) + .size(8.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary), ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(10.dp)) Text( text = value, - color = Color.White.copy(alpha = 0.9f), - style = TextStyle(fontFamily = monoFontFamily, fontSize = 12.sp), + color = Color.White, + style = MaterialTheme.typography.labelLarge, ) } } diff --git a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureScreen.kt b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureScreen.kt index 5a3cda13..199e1edc 100644 --- a/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureScreen.kt +++ b/samples/camerax-rawcapture/src/main/java/com/android/camerax/rawcapture/CameraXRawCaptureScreen.kt @@ -211,8 +211,6 @@ private fun BoxScope.PreviewingContent( onClick = { viewModel.setSettingsVisible(true) }, imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.rawcapture_settings_button), - size = 34.dp, - iconSize = 18.dp, ) }, ) @@ -369,7 +367,7 @@ private fun ZoomableCapturedImagePreview( ) { Image( bitmap = bitmap.asImageBitmap(), - contentDescription = "Captured Photo", + contentDescription = stringResource(R.string.rawcapture_captured_photo), modifier = Modifier .fillMaxSize() @@ -386,9 +384,9 @@ private fun ZoomableCapturedImagePreview( ScrimIconButton( onClick = onDone, imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - size = 34.dp, - iconSize = 18.dp, + contentDescription = stringResource(R.string.rawcapture_back), + size = 40.dp, + iconSize = 20.dp, modifier = Modifier .align(Alignment.TopStart) @@ -397,38 +395,40 @@ private fun ZoomableCapturedImagePreview( ) if (scale > 1.05f) { - Text( - text = String.format(Locale.US, "%.1fx", scale), - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 12.sp, - letterSpacing = 0.06.em, - ), - color = MaterialTheme.colorScheme.primary, + Box( modifier = Modifier .align(Alignment.TopEnd) .statusBarsPadding() - .padding(16.dp), - ) + .padding(16.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(Color.Black.copy(alpha = 0.55f)) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + text = String.format(Locale.US, "%.1fx", scale), + style = MaterialTheme.typography.labelLarge.copy(fontFeatureSettings = "tnum"), + color = MaterialTheme.colorScheme.primary, + ) + } } - Text( - text = "${bitmap.width} × ${bitmap.height} • $mpLabel", - style = - TextStyle( - fontFamily = monoFontFamily, - fontSize = 10.sp, - letterSpacing = 0.06.em, - ), - color = Color.White.copy(alpha = 0.55f), + Box( modifier = Modifier - .align(Alignment.BottomStart) - .navigationBarsPadding() - .padding(start = 20.dp, bottom = 100.dp), - ) + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 20.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(Color.Black.copy(alpha = 0.55f)) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + text = "${bitmap.width} × ${bitmap.height} • $mpLabel", + style = MaterialTheme.typography.labelMedium.copy(fontFeatureSettings = "tnum"), + color = Color.White, + ) + } Row( modifier = @@ -436,73 +436,51 @@ private fun ZoomableCapturedImagePreview( .align(Alignment.BottomCenter) .fillMaxWidth() .navigationBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + .padding(start = 24.dp, end = 24.dp, bottom = 28.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - ReviewActionPill( - modifier = Modifier.weight(1f), - icon = Icons.Filled.Refresh, - label = "Retake", - filled = false, + androidx.compose.material3.FilledTonalButton( onClick = onRetake, - ) - ReviewActionPill( - modifier = Modifier.weight(1f), - icon = Icons.Filled.Check, - label = "Done", - filled = true, + modifier = + Modifier + .weight(1f) + .height(52.dp), + shape = androidx.compose.foundation.shape.CircleShape, + ) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.rawcapture_retake), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + androidx.compose.material3.Button( onClick = onDone, - ) - } - } -} - -@Composable -private fun ReviewActionPill( - icon: ImageVector, - label: String, - filled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val accent = MaterialTheme.colorScheme.primary - val shape = RoundedCornerShape(26.dp) - val contentColor = if (filled) MaterialTheme.colorScheme.onPrimary else Color.White - val base = - modifier - .height(52.dp) - .clip(shape) - val styled = - if (filled) { - base.background(accent) - } else { - base - .background(Color.White.copy(alpha = 0.04f)) - .border(1.dp, Color.White.copy(alpha = 0.22f), shape) + modifier = + Modifier + .weight(1f) + .height(52.dp), + shape = androidx.compose.foundation.shape.CircleShape, + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.rawcapture_done), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } } - Row( - modifier = styled.clickable(onClick = onClick), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = contentColor, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = label, - style = - TextStyle( - fontFamily = bodyFontFamily, - fontSize = 14.sp, - fontWeight = if (filled) FontWeight.Bold else FontWeight.Medium, - ), - color = contentColor, - ) } } diff --git a/samples/camerax-rawcapture/src/main/res/values/strings.xml b/samples/camerax-rawcapture/src/main/res/values/strings.xml index a35069c5..6152486e 100644 --- a/samples/camerax-rawcapture/src/main/res/values/strings.xml +++ b/samples/camerax-rawcapture/src/main/res/values/strings.xml @@ -24,4 +24,9 @@ Full Sensor (%1$s) Default Max + Captured photo + Back + Retake + Done + diff --git a/samples/camerax-slowmotion/src/main/java/com/android/camerax/slowmotion/CameraXSlowMotionController.kt b/samples/camerax-slowmotion/src/main/java/com/android/camerax/slowmotion/CameraXSlowMotionController.kt index e2a93479..35569b71 100644 --- a/samples/camerax-slowmotion/src/main/java/com/android/camerax/slowmotion/CameraXSlowMotionController.kt +++ b/samples/camerax-slowmotion/src/main/java/com/android/camerax/slowmotion/CameraXSlowMotionController.kt @@ -61,10 +61,12 @@ fun rememberCameraXSlowMotionController( onSessionConfigured: (Range) -> Unit, onUnsupported: () -> Unit, onVideoCaptured: (Uri) -> Unit, + onRecordingFailed: () -> Unit = {}, ): CameraXSlowMotionController { val latestOnSessionConfigured by rememberUpdatedState(onSessionConfigured) val latestOnUnsupported by rememberUpdatedState(onUnsupported) val latestOnVideoCaptured by rememberUpdatedState(onVideoCaptured) + val latestOnRecordingFailed by rememberUpdatedState(onRecordingFailed) return remember(context, lifecycleOwner) { CameraXSlowMotionController( context, @@ -72,6 +74,7 @@ fun rememberCameraXSlowMotionController( onSessionConfigured = { range -> latestOnSessionConfigured(range) }, onUnsupported = { latestOnUnsupported() }, onVideoCaptured = { uri -> latestOnVideoCaptured(uri) }, + onRecordingFailed = { latestOnRecordingFailed() }, ) } } @@ -93,6 +96,7 @@ class CameraXSlowMotionController( private val onSessionConfigured: (Range) -> Unit, private val onUnsupported: () -> Unit, private val onVideoCaptured: (Uri) -> Unit, + private val onRecordingFailed: () -> Unit = {}, ) { private val appContext = context.applicationContext @@ -104,21 +108,20 @@ class CameraXSlowMotionController( private var cameraProvider: ProcessCameraProvider? = null private var videoCapture: VideoCapture? = null private var activeRecording: Recording? = null - - private val preview = - Preview.Builder().build().apply { - setSurfaceProvider { request -> surfaceRequest = request } - } + private var isSessionBound = false private val cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA fun openCamera() { + if (isSessionBound) return + isSessionBound = true providerScope.launch { val provider = ProcessCameraProvider.getInstance(appContext).await() cameraProvider = provider try { val cameraInfo = cameraSelector.filter(provider.availableCameraInfos).firstOrNull() if (cameraInfo == null) { + isSessionBound = false onUnsupported() return@launch } @@ -126,6 +129,7 @@ class CameraXSlowMotionController( // High-speed capabilities gate the whole sample. val highSpeedCaps = Recorder.getHighSpeedVideoCapabilities(cameraInfo) if (highSpeedCaps == null) { + isSessionBound = false onUnsupported() return@launch } @@ -133,6 +137,7 @@ class CameraXSlowMotionController( val quality = highSpeedCaps.getSupportedQualities(DynamicRange.SDR).firstOrNull() if (quality == null) { + isSessionBound = false onUnsupported() return@launch } @@ -145,6 +150,14 @@ class CameraXSlowMotionController( val capture = VideoCapture.withOutput(recorder) videoCapture = capture + // Create a fresh Preview per bind; binding a HighSpeedVideoSessionConfig mutates + // the Preview's internal config with the high-speed target frame rate, which + // HighSpeedVideoSessionConfig rejects if the same Preview instance is reused. + val preview = + Preview.Builder().build().apply { + setSurfaceProvider { request -> surfaceRequest = request } + } + // Probe the high-speed frame-rate ranges the camera offers for this use-case combo // and pick the highest (e.g. 120/240 fps) so the slow-motion effect is strongest. val probeConfig = HighSpeedVideoSessionConfig(capture, preview) @@ -153,6 +166,7 @@ class CameraXSlowMotionController( .getSupportedFrameRateRanges(probeConfig) .maxByOrNull { it.upper } if (frameRateRange == null) { + isSessionBound = false onUnsupported() return@launch } @@ -168,6 +182,7 @@ class CameraXSlowMotionController( provider.bindToLifecycle(lifecycleOwner, cameraSelector, sessionConfig) onSessionConfigured(frameRateRange) } catch (e: Exception) { + isSessionBound = false Log.e(TAG, "High-speed session binding failed", e) onUnsupported() } @@ -176,7 +191,11 @@ class CameraXSlowMotionController( @SuppressLint("MissingPermission") fun startRecording(context: Context) { - val capture = videoCapture ?: return + val capture = + videoCapture ?: run { + onRecordingFailed() + return + } if (activeRecording != null) return val name = @@ -200,12 +219,13 @@ class CameraXSlowMotionController( .prepareRecording(context, outputOptions) .start(ContextCompat.getMainExecutor(appContext)) { event -> if (event is VideoRecordEvent.Finalize) { + activeRecording = null if (!event.hasError()) { onVideoCaptured(event.outputResults.outputUri) } else { Log.e(TAG, "Slow-motion recording failed: ${event.error}") + onRecordingFailed() } - activeRecording = null } } } @@ -216,6 +236,7 @@ class CameraXSlowMotionController( } fun closeCamera() { + isSessionBound = false stopRecording() cameraProvider?.unbindAll() } diff --git a/samples/camerax-slowmotion/src/main/java/com/android/camerax/slowmotion/CameraXSlowMotionScreen.kt b/samples/camerax-slowmotion/src/main/java/com/android/camerax/slowmotion/CameraXSlowMotionScreen.kt index 4656b642..0a946fce 100644 --- a/samples/camerax-slowmotion/src/main/java/com/android/camerax/slowmotion/CameraXSlowMotionScreen.kt +++ b/samples/camerax-slowmotion/src/main/java/com/android/camerax/slowmotion/CameraXSlowMotionScreen.kt @@ -123,6 +123,7 @@ private fun BoxScope.CapturingContent( onSessionConfigured = viewModel::onSessionConfigured, onUnsupported = viewModel::setUnsupported, onVideoCaptured = viewModel::videoCaptured, + onRecordingFailed = viewModel::resetToCamera, ) DisposableEffect(lifecycleOwner, controller) { @@ -165,7 +166,7 @@ private fun BoxScope.CapturingContent( Modifier .align(Alignment.TopCenter) .statusBarsPadding() - .padding(top = 96.dp), + .padding(top = 68.dp), ) } diff --git a/samples/camerax-takeaphoto/src/main/java/com/android/camerax/takeaphoto/CameraXTakeAPhotoScreen.kt b/samples/camerax-takeaphoto/src/main/java/com/android/camerax/takeaphoto/CameraXTakeAPhotoScreen.kt index 68cb2dc9..ddf8d1ed 100644 --- a/samples/camerax-takeaphoto/src/main/java/com/android/camerax/takeaphoto/CameraXTakeAPhotoScreen.kt +++ b/samples/camerax-takeaphoto/src/main/java/com/android/camerax/takeaphoto/CameraXTakeAPhotoScreen.kt @@ -18,8 +18,6 @@ package com.android.camerax.takeaphoto import android.graphics.Bitmap import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.BoxScope -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cameraswitch import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -41,7 +39,7 @@ import com.android.camera.core.camerax.CameraXPreview import com.android.camera.core.display.rememberDisplayRotation import com.android.camera.core.permissions.CameraPermissions import com.android.camera.coreui.controls.CameraControlsBar -import com.android.camera.coreui.controls.ScrimIconButton +import com.android.camera.coreui.controls.CameraSwitchButton import com.android.camera.coreui.controls.ShutterButton import com.android.camera.coreui.feedback.ObserveSaveEvents import com.android.camera.coreui.overlay.FocusIndicator @@ -165,13 +163,7 @@ private fun BoxScope.CapturingContent( CameraControlsBar( modifier = Modifier.align(Alignment.BottomCenter), - endSlot = { - ScrimIconButton( - onClick = onSwapCamera, - imageVector = Icons.Filled.Cameraswitch, - contentDescription = stringResource(R.string.takeaphoto_swap_camera), - ) - }, + endSlot = { CameraSwitchButton(onClick = onSwapCamera) }, center = { ShutterButton(onClick = controller::takePicture) }, ) } diff --git a/samples/camerax-takeavideo/src/main/java/com/android/camerax/takeavideo/CameraXTakeAVideoScreen.kt b/samples/camerax-takeavideo/src/main/java/com/android/camerax/takeavideo/CameraXTakeAVideoScreen.kt index 5184870b..d153a4b9 100644 --- a/samples/camerax-takeavideo/src/main/java/com/android/camerax/takeavideo/CameraXTakeAVideoScreen.kt +++ b/samples/camerax-takeavideo/src/main/java/com/android/camerax/takeavideo/CameraXTakeAVideoScreen.kt @@ -186,8 +186,6 @@ private fun BoxScope.CapturingContent( onClick = { isOverlayVisible = true }, imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.takeavideo_settings), - size = 34.dp, - iconSize = 18.dp, ) } }, diff --git a/samples/camerax-ultrahdr/src/main/java/com/android/camerax/ultrahdr/CameraXUltraHdrViewer.kt b/samples/camerax-ultrahdr/src/main/java/com/android/camerax/ultrahdr/CameraXUltraHdrViewer.kt index 6288239e..3177a1c8 100644 --- a/samples/camerax-ultrahdr/src/main/java/com/android/camerax/ultrahdr/CameraXUltraHdrViewer.kt +++ b/samples/camerax-ultrahdr/src/main/java/com/android/camerax/ultrahdr/CameraXUltraHdrViewer.kt @@ -64,9 +64,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.core.graphics.createBitmap -import com.android.camera.coretheme.monoFontFamily -import com.android.camera.coreui.controls.ScrimIconButton -import com.android.camera.coreui.overlay.ViewfinderTitleChip +import com.android.camera.coreui.overlay.ViewfinderTopBar import com.android.camera.coreui.state.LoadingView import com.android.camera.coreui.widget.HdrWindowColorMode import kotlinx.coroutines.Dispatchers @@ -144,34 +142,19 @@ fun BoxScope.UltraHdrViewer( } } - ScrimIconButton( - onClick = onBack, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.ultrahdr_back), - size = 34.dp, - iconSize = 18.dp, - modifier = - Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(16.dp), - ) - - ViewfinderTitleChip( - text = stringResource(R.string.ultrahdr_title_with_mode, stringResource(mode.label)), - modifier = - Modifier - .align(Alignment.TopCenter) - .statusBarsPadding() - .padding(top = 44.dp), + ViewfinderTopBar( + title = stringResource(R.string.ultrahdr_title_with_mode, stringResource(mode.label)), + onClose = onBack, + closeIcon = Icons.AutoMirrored.Filled.ArrowBack, + showApiBadge = false, ) if (assets != null && !hdrAvailable) { Text( text = stringResource(R.string.ultrahdr_sdr_only), - color = Color.White.copy(alpha = 0.6f), + color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center, - style = TextStyle(fontFamily = monoFontFamily, fontSize = 10.sp, letterSpacing = 0.04.em), + style = MaterialTheme.typography.bodySmall, modifier = Modifier .align(Alignment.BottomCenter) @@ -203,8 +186,8 @@ private fun ModeSelector( modifier = modifier .clip(RoundedCornerShape(percent = 50)) - .background(Color.Black.copy(alpha = 0.55f)) - .padding(4.dp), + .background(Color.Black.copy(alpha = 0.6f)) + .padding(6.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { UltraHdrMode.entries.forEach { entry -> @@ -226,12 +209,12 @@ private fun ModePill( enabled: Boolean, onClick: () -> Unit, ) { - val background = if (selected && enabled) MaterialTheme.colorScheme.primary else Color.Transparent + val background = if (selected && enabled) MaterialTheme.colorScheme.primaryContainer else Color.Transparent val textColor = when { - !enabled -> Color.White.copy(alpha = 0.25f) - selected -> MaterialTheme.colorScheme.onPrimary - else -> Color.White.copy(alpha = 0.7f) + !enabled -> Color.White.copy(alpha = 0.3f) + selected -> MaterialTheme.colorScheme.onPrimaryContainer + else -> Color.White.copy(alpha = 0.8f) } Box( modifier = @@ -239,11 +222,11 @@ private fun ModePill( .clip(RoundedCornerShape(percent = 50)) .background(background) .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 18.dp, vertical = 9.dp), + .padding(horizontal = 20.dp, vertical = 10.dp), ) { Text( text = stringResource(label), - style = TextStyle(fontFamily = monoFontFamily, fontSize = 11.sp, letterSpacing = 0.06.em), + style = MaterialTheme.typography.labelLarge, color = textColor, ) } diff --git a/samples/camerax-videostabilization/src/main/java/com/android/camerax/videostabilization/CameraXVideoStabilization.kt b/samples/camerax-videostabilization/src/main/java/com/android/camerax/videostabilization/CameraXVideoStabilization.kt index 04fac0ca..893ca476 100644 --- a/samples/camerax-videostabilization/src/main/java/com/android/camerax/videostabilization/CameraXVideoStabilization.kt +++ b/samples/camerax-videostabilization/src/main/java/com/android/camerax/videostabilization/CameraXVideoStabilization.kt @@ -189,8 +189,6 @@ private fun BoxScope.CapturingContent( onClick = { isOverlayVisible = true }, imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.videostabilization_settings), - size = 34.dp, - iconSize = 18.dp, ) } }, @@ -204,7 +202,7 @@ private fun BoxScope.CapturingContent( Modifier .align(Alignment.TopCenter) .statusBarsPadding() - .padding(top = 96.dp), + .padding(top = 68.dp), ) } diff --git a/samples/camerax-zoomandtorch/src/main/java/com/android/camerax/zoomandtorch/CameraXZoomAndTorchScreen.kt b/samples/camerax-zoomandtorch/src/main/java/com/android/camerax/zoomandtorch/CameraXZoomAndTorchScreen.kt index 1083218d..f758e1fd 100644 --- a/samples/camerax-zoomandtorch/src/main/java/com/android/camerax/zoomandtorch/CameraXZoomAndTorchScreen.kt +++ b/samples/camerax-zoomandtorch/src/main/java/com/android/camerax/zoomandtorch/CameraXZoomAndTorchScreen.kt @@ -16,7 +16,9 @@ package com.android.camerax.zoomandtorch import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -148,41 +150,41 @@ private fun BoxScope.PreviewingContent( ViewfinderTopBar( title = stringResource(R.string.zoomandtorch_title), onClose = onBack, - actions = { - // Torch toggle only makes sense on a back camera that actually has a flash unit. - if (state.hasFlash && !state.isFrontCamera) { - TorchChip( - on = state.torchOn, - onToggle = viewModel::toggleTorch, - ) - } - }, ) val zoomEnabled = state.minZoom < state.maxZoom - // Guard against a degenerate range (e.g. before the camera reports its zoom limits, or on a - // fixed-zoom lens) which would make the Slider divide by zero. Widen it and disable instead. val zoomRange = if (zoomEnabled) { state.minZoom..state.maxZoom } else { state.minZoom..(state.minZoom + 1f) } - ZoomControls( - zoomRatio = state.zoomRatio, - valueRange = zoomRange, - onValueChange = viewModel::setZoom, - enabled = zoomEnabled, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 150.dp), - ) - - CameraControlsBar( + Column( modifier = Modifier.align(Alignment.BottomCenter), - endSlot = { CameraSwitchButton(onClick = viewModel::swapCamera) }, - center = {}, - ) + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ZoomControls( + zoomRatio = state.zoomRatio, + valueRange = zoomRange, + onValueChange = viewModel::setZoom, + enabled = zoomEnabled, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + + CameraControlsBar( + modifier = Modifier.fillMaxWidth(), + center = { + if (state.hasFlash && !state.isFrontCamera) { + TorchChip( + on = state.torchOn, + onToggle = viewModel::toggleTorch, + ) + } + }, + endSlot = { CameraSwitchButton(onClick = viewModel::swapCamera) }, + ) + } }