Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package nart.simpleanki.feature.cardform

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import org.junit.Assert.assertEquals
Expand All @@ -14,7 +15,7 @@ class CardFormContentTest {
val composeRule = createComposeRule()

@Test
fun newCard_showsReverseChip_andEditsInvokeCallbacks() {
fun newCard_showsReverseToggle_andEditsInvokeCallbacks() {
var front = ""
composeRule.setContent {
CardFormContent(
Expand All @@ -33,8 +34,8 @@ class CardFormContentTest {
)
}
composeRule.onNodeWithText("New card").assertIsDisplayed()
composeRule.onNodeWithText("Also create reverse card").assertExists() // FilterChip, new cards only
composeRule.onNodeWithText("Add image").assertExists()
composeRule.onNodeWithContentDescription("Also create reverse card").assertExists() // toggle, new cards only
composeRule.onNodeWithContentDescription("Add image").assertExists()
composeRule.onNodeWithText("Front").performTextInput("hola")
assertEquals("hola", front)
}
Expand All @@ -58,6 +59,50 @@ class CardFormContentTest {
)
}
composeRule.onNodeWithText("Edit card").assertIsDisplayed()
composeRule.onNodeWithText("Also create reverse card").assertDoesNotExist()
composeRule.onNodeWithContentDescription("Also create reverse card").assertDoesNotExist()
}

@Test
fun newCard_reverseOn_showsHint() {
composeRule.setContent {
CardFormContent(
state = CardFormUiState(isEdit = false, createReverse = true),
onFrontChange = {},
onBackChange = {},
onSelectDeck = {},
isRecording = false,
onToggleReverse = {},
onAddImage = {},
onRemoveImage = {},
onToggleRecording = {},
onRemoveAudio = {},
onSave = {},
onBack = {},
)
}
composeRule.onNodeWithText("A reverse card (Back → Front) will also be created.")
.assertExists()
}

@Test
fun newCard_reverseOff_hidesHint() {
composeRule.setContent {
CardFormContent(
state = CardFormUiState(isEdit = false, createReverse = false),
onFrontChange = {},
onBackChange = {},
onSelectDeck = {},
isRecording = false,
onToggleReverse = {},
onAddImage = {},
onRemoveImage = {},
onToggleRecording = {},
onRemoveAudio = {},
onSave = {},
onBack = {},
)
}
composeRule.onNodeWithText("A reverse card (Back → Front) will also be created.")
.assertDoesNotExist()
}
}
153 changes: 98 additions & 55 deletions app/src/main/java/nart/simpleanki/feature/cardform/CardFormScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
Expand All @@ -28,17 +27,18 @@ import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.AssistChip
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
Expand All @@ -47,7 +47,6 @@ import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
Expand All @@ -65,6 +64,8 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
Expand Down Expand Up @@ -149,7 +150,7 @@ fun CardFormScreen(
}

/** Stateless card-form UI, decoupled from the ViewModel for testing. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CardFormContent(
state: CardFormUiState,
Expand Down Expand Up @@ -194,8 +195,66 @@ fun CardFormContent(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
bottomBar = {
BottomAppBar(
// imePadding() is on the bar (not the Scaffold/content) so it rises above the keyboard;
// edge-to-edge is enabled in MainActivity. Scaffold then pads content by the bar's height.
modifier = Modifier.imePadding(),
actions = {
TextButton(onClick = onSave, enabled = state.canSave) { Text("Save") }
if (state.imageName == null) {
IconButton(onClick = onAddImage, enabled = !state.uploadingImage) {
if (state.uploadingImage) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Image, contentDescription = "Add image")
}
}
}
if (state.audioName == null) {
IconButton(onClick = onToggleRecording, enabled = !state.uploadingAudio) {
when {
state.uploadingAudio ->
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
isRecording ->
Icon(Icons.Default.Stop, contentDescription = "Stop recording", tint = MaterialTheme.colorScheme.error)
else ->
Icon(Icons.Default.Mic, contentDescription = "Record audio")
}
}
}
if (!state.isEdit) {
IconToggleButton(
checked = state.createReverse,
onCheckedChange = onToggleReverse,
) {
Icon(
if (state.createReverse) Icons.Default.Check else Icons.Default.SwapHoriz,
contentDescription = "Also create reverse card",
)
}
}
},
floatingActionButton = {
// M3 FABs have no `enabled` flag: show disabled via muted colors + a gated click.
val saveEnabled = state.canSave
FloatingActionButton(
onClick = { if (saveEnabled) onSave() },
modifier = if (saveEnabled) Modifier else Modifier.semantics { disabled() },
containerColor = if (saveEnabled) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (saveEnabled) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
},
) {
Icon(Icons.Default.Check, contentDescription = "Save")
}
},
)
},
Expand Down Expand Up @@ -297,59 +356,26 @@ fun CardFormContent(
}
}
}

// Attachment actions (Material chips)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.imageName == null) {
AssistChip(
onClick = onAddImage,
enabled = !state.uploadingImage,
leadingIcon = {
if (state.uploadingImage) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Image, contentDescription = null, Modifier.height(18.dp))
}
},
label = { Text(if (state.uploadingImage) "Saving…" else "Add image") },
// Reverse-card hint: shown while the bottom-bar reverse toggle is on (new cards only).
if (state.createReverse && !state.isEdit) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.SwapHoriz,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
if (state.audioName == null) {
AssistChip(
onClick = onToggleRecording,
enabled = !state.uploadingAudio,
leadingIcon = {
when {
state.uploadingAudio -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
isRecording -> Icon(Icons.Default.Stop, contentDescription = null, Modifier.height(18.dp), tint = MaterialTheme.colorScheme.error)
else -> Icon(Icons.Default.Mic, contentDescription = null, Modifier.height(18.dp))
}
},
label = {
Text(
when {
state.uploadingAudio -> "Saving…"
isRecording -> "Stop recording"
else -> "Record audio"
},
)
},
Text(
"A reverse card (Back → Front) will also be created.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

if (!state.isEdit) {
FilterChip(
selected = state.createReverse,
onClick = { onToggleReverse(!state.createReverse) },
label = { Text("Also create reverse card") },
leadingIcon = if (state.createReverse) {
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.height(18.dp)) }
} else {
{ Icon(Icons.Default.SwapHoriz, contentDescription = null, Modifier.height(18.dp)) }
},
)
}
}
}
}
Expand Down Expand Up @@ -416,3 +442,20 @@ private fun CardFormPickDeckSelectedPreview() {
)
}
}

@Preview(name = "Card form · with attachments", showBackground = true)
@Composable
private fun CardFormWithAttachmentsPreview() {
AzriTheme {
CardFormContent(
state = CardFormUiState(
front = "dog", back = "perro",
imageName = "img.jpg", audioName = "audio.m4a",
),
isRecording = false,
onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {},
onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {},
onSave = {}, onBack = {},
)
}
}
30 changes: 25 additions & 5 deletions app/src/main/java/nart/simpleanki/ui/navigation/AzriNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height
Expand All @@ -40,6 +43,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavBackStackEntry
Expand Down Expand Up @@ -149,14 +153,30 @@ fun AzriNavHost() {
val fadeThroughIn =
fadeIn(tween(durationMillis = 195, delayMillis = 105, easing = FastOutSlowInEasing))
val fadeThroughOut = fadeOut(tween(durationMillis = 105, easing = FastOutSlowInEasing))
// Apply the outer Scaffold's insets AND consume them, so each screen's own Scaffold
// (its TopAppBar's status-bar inset, its content's nav-bar inset) doesn't reserve
// them a second time. Without consuming: the status bar gets reserved twice (titles
// pushed down) and a nav-bar-height gap appears above the bottom bar.
// Exception: the card editor hosts its own BottomAppBar that must paint into the
// navigation-bar inset (else a blank strip shows below it). For that route we release
// the bottom inset — apply only top + horizontal — and let the editor's bar own the bottom.
val isCardEditor = currentRoute?.startsWith("cardForm") == true
val contentModifier = if (isCardEditor) {
val ld = LocalLayoutDirection.current
val topAndSides = PaddingValues(
start = padding.calculateStartPadding(ld),
end = padding.calculateEndPadding(ld),
top = padding.calculateTopPadding(),
bottom = 0.dp,
)
Modifier.padding(topAndSides).consumeWindowInsets(topAndSides)
} else {
Modifier.padding(padding).consumeWindowInsets(padding)
}
NavHost(
navController = nav,
startDestination = QUEUE,
// Apply the outer Scaffold's insets AND consume them, so each screen's own Scaffold
// (its TopAppBar's status-bar inset, its content's nav-bar inset) doesn't reserve
// them a second time. Without consuming: the status bar gets reserved twice (titles
// pushed down) and a nav-bar-height gap appears above the bottom bar.
modifier = Modifier.padding(padding).consumeWindowInsets(padding),
modifier = contentModifier,
// Forward: incoming enters from the right (+30dp), outgoing exits left (−30dp).
enterTransition = { slideInHorizontally(slideSpec) { slide } + fadeThroughIn },
exitTransition = { slideOutHorizontally(slideSpec) { -slide } + fadeThroughOut },
Expand Down
Loading
Loading