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 @@ -21,6 +21,7 @@ class CardFormContentTest {
state = CardFormUiState(isEdit = false),
onFrontChange = { front = it },
onBackChange = {},
onSelectDeck = {},
isRecording = false,
onToggleReverse = {},
onAddImage = {},
Expand All @@ -45,6 +46,7 @@ class CardFormContentTest {
state = CardFormUiState(front = "a", back = "b", isEdit = true),
onFrontChange = {},
onBackChange = {},
onSelectDeck = {},
isRecording = false,
onToggleReverse = {},
onAddImage = {},
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/java/nart/simpleanki/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import org.koin.dsl.module
import java.io.File

/** Injection args for screens that take optional ids (unambiguous vs. positional params). */
data class CardFormArgs(val deckId: String, val cardId: String? = null)
data class CardFormArgs(val deckId: String? = null, val cardId: String? = null)
data class DeckEditArgs(val deckId: String? = null, val folderId: String? = null)
data class FolderEditArgs(val folderId: String? = null)
data class StudyArgs(val deckId: String? = null, val folderId: String? = null)
Expand Down Expand Up @@ -232,8 +232,9 @@ val appModule = module {
deckId = a.deckId,
cardRepository = get(),
mediaManager = get(),
deckRepository = get(),
editingCardId = a.cardId,
logManager = get()
logManager = get(),
)
}
viewModel { params ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,17 @@ import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.AssistChip
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
Expand All @@ -56,6 +60,8 @@ 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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
Expand All @@ -72,7 +78,7 @@ import org.koin.core.parameter.parametersOf

@Composable
fun CardFormScreen(
deckId: String,
deckId: String?,
cardId: String?,
onClose: () -> Unit,
viewModel: CardFormViewModel = koinViewModel { parametersOf(CardFormArgs(deckId, cardId)) },
Expand Down Expand Up @@ -131,6 +137,7 @@ fun CardFormScreen(
isRecording = isRecording,
onFrontChange = viewModel::onFrontChange,
onBackChange = viewModel::onBackChange,
onSelectDeck = viewModel::onSelectDeck,
onToggleReverse = viewModel::onToggleReverse,
onAddImage = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
onRemoveImage = viewModel::onRemoveImage,
Expand All @@ -149,6 +156,7 @@ fun CardFormContent(
isRecording: Boolean,
onFrontChange: (String) -> Unit,
onBackChange: (String) -> Unit,
onSelectDeck: (String) -> Unit,
onToggleReverse: (Boolean) -> Unit,
onAddImage: () -> Unit,
onRemoveImage: () -> Unit,
Expand All @@ -158,6 +166,12 @@ fun CardFormContent(
onBack: () -> Unit,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val frontFocus = remember { FocusRequester() }
// Autofocus Front on open (and re-focus after each save) so the user can type immediately.
LaunchedEffect(Unit) { runCatching { frontFocus.requestFocus() } }
LaunchedEffect(state.savedTick) {
if (state.savedTick > 0) runCatching { frontFocus.requestFocus() }
}
Scaffold(
snackbarHost = {
SnackbarHost(snackbarHostState) { data ->
Expand Down Expand Up @@ -194,14 +208,48 @@ fun CardFormContent(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
var deckMenuExpanded by remember { mutableStateOf(false) }
if (state.pickDeck) {
val selectedDeckName =
state.decks.firstOrNull { it.id == state.selectedDeckId }?.name ?: ""
ExposedDropdownMenuBox(
expanded = deckMenuExpanded,
onExpandedChange = { deckMenuExpanded = it },
) {
OutlinedTextField(
value = selectedDeckName,
onValueChange = {},
readOnly = true,
label = { Text("Deck") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(deckMenuExpanded) },
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = deckMenuExpanded,
onDismissRequest = { deckMenuExpanded = false },
) {
state.decks.forEach { deck ->
DropdownMenuItem(
text = { Text(deck.name) },
onClick = { onSelectDeck(deck.id); deckMenuExpanded = false },
)
}
}
}
}
OutlinedTextField(
value = state.front,
onValueChange = onFrontChange,
label = { Text("Front") },
placeholder = { Text("Question") },
minLines = 3,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.focusRequester(frontFocus),
)
OutlinedTextField(
value = state.back,
Expand Down Expand Up @@ -313,7 +361,7 @@ private fun CardFormNewPreview() {
CardFormContent(
state = CardFormUiState(front = "Bonjour", back = "Hello", createReverse = true),
isRecording = false,
onFrontChange = {}, onBackChange = {}, onToggleReverse = {},
onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {},
onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {},
onSave = {}, onBack = {},
)
Expand All @@ -327,7 +375,42 @@ private fun CardFormRecordingPreview() {
CardFormContent(
state = CardFormUiState(front = "cat", back = "gato", isEdit = true),
isRecording = true,
onFrontChange = {}, onBackChange = {}, onToggleReverse = {},
onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {},
onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {},
onSave = {}, onBack = {},
)
}
}

@Preview(name = "Card form · pick deck (empty)", showBackground = true)
@Composable
private fun CardFormPickDeckPreview() {
AzriTheme {
CardFormContent(
state = CardFormUiState(
pickDeck = true,
decks = listOf(DeckOption("d1", "French"), DeckOption("d2", "Spanish")),
),
isRecording = false,
onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {},
onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {},
onSave = {}, onBack = {},
)
}
}

@Preview(name = "Card form · pick deck (selected)", showBackground = true)
@Composable
private fun CardFormPickDeckSelectedPreview() {
AzriTheme {
CardFormContent(
state = CardFormUiState(
front = "bonjour", back = "hello",
pickDeck = true, selectedDeckId = "d2",
decks = listOf(DeckOption("d1", "French"), DeckOption("d2", "Spanish")),
),
isRecording = false,
onFrontChange = {}, onBackChange = {}, onSelectDeck = {}, onToggleReverse = {},
onAddImage = {}, onRemoveImage = {}, onToggleRecording = {}, onRemoveAudio = {},
onSave = {}, onBack = {},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import nart.simpleanki.core.analytics.LogManager
import nart.simpleanki.core.analytics.LoggableEvent
import nart.simpleanki.core.data.media.MediaManager
import nart.simpleanki.core.data.repository.CardRepository
import nart.simpleanki.core.data.repository.DeckRepository
import nart.simpleanki.core.domain.model.Card
import nart.simpleanki.core.domain.model.CardState
import java.util.UUID

/** A deck choice for the in-editor selector (queue-path "picker mode"). */
data class DeckOption(val id: String, val name: String)

data class CardFormUiState(
val front: String = "",
val back: String = "",
Expand All @@ -25,32 +29,48 @@ data class CardFormUiState(
val audioName: String? = null,
val audioPath: String? = null,
val uploadingAudio: Boolean = false,
/** True ⇒ the user picks the destination deck in-editor (opened from the Study tab). */
val pickDeck: Boolean = false,
val decks: List<DeckOption> = emptyList(),
val selectedDeckId: String? = null,
/** Increments on each successful new-card save; drives the "Card saved" toast (re-triggerable). */
val savedTick: Int = 0,
/** Set once after editing an existing card, signaling the screen to close. */
val finished: Boolean = false,
) {
val canSave: Boolean
get() = front.isNotBlank() && back.isNotBlank() && !uploadingImage && !uploadingAudio
get() = front.isNotBlank() && back.isNotBlank() &&
!uploadingImage && !uploadingAudio && selectedDeckId != null
}

/**
* Add or edit a card. Supports attaching an image and an audio clip, saved on-device via
* [MediaManager]; upload to the cloud happens later during premium sync. When
* [CardFormUiState.createReverse] is set for a new card, a second reversed card is created
* with swapped front/back and a shared [Card.pairId].
*
* Pass [deckId] = null to enter "picker mode": the user selects the destination deck
* in-editor (e.g. when opening the editor from the Study tab without a deck context).
* Picker mode ([deckId] == null) requires a non-null [deckRepository]; fixed-deck mode leaves it null.
*/
class CardFormViewModel(
private val deckId: String,
private val deckId: String?,
private val cardRepository: CardRepository,
private val mediaManager: MediaManager,
private val deckRepository: DeckRepository? = null,
private val editingCardId: String? = null,
private val idGenerator: () -> String = { UUID.randomUUID().toString() },
private val now: () -> Long = { System.currentTimeMillis() },
private val logManager: LogManager = LogManager(emptyList()),
) : ViewModel() {

private val _uiState = MutableStateFlow(CardFormUiState(isEdit = editingCardId != null))
private val _uiState = MutableStateFlow(
CardFormUiState(
isEdit = editingCardId != null,
pickDeck = deckId == null,
selectedDeckId = deckId,
),
)
val uiState: StateFlow<CardFormUiState> = _uiState.asStateFlow()

private var editingCard: Card? = null
Expand All @@ -68,6 +88,16 @@ class CardFormViewModel(
}
}
}
if (deckId == null) {
checkNotNull(deckRepository) { "deckRepository is required in picker mode (deckId == null)" }
viewModelScope.launch {
deckRepository.observeDecks().collect { decks ->
_uiState.value = _uiState.value.copy(
decks = decks.map { DeckOption(it.id, it.name) },
)
}
}
}
}

fun onFrontChange(value: String) {
Expand All @@ -78,6 +108,10 @@ class CardFormViewModel(
_uiState.value = _uiState.value.copy(back = value)
}

fun onSelectDeck(id: String) {
_uiState.value = _uiState.value.copy(selectedDeckId = id)
}

fun onToggleReverse(value: Boolean) {
_uiState.value = _uiState.value.copy(createReverse = value)
}
Expand Down Expand Up @@ -130,28 +164,23 @@ class CardFormViewModel(
// Editing targets one card: close the editor once saved.
_uiState.value = _uiState.value.copy(finished = true)
} else {
val targetDeckId = state.selectedDeckId ?: return@launch
val baseId = idGenerator()
cardRepository.upsert(
newCard(
baseId, state.front, state.back, isReverse = false,
baseId, targetDeckId, state.front, state.back, isReverse = false,
pairId = if (state.createReverse) baseId else null,
image = state.imageName, imagePath = state.imagePath,
audioName = state.audioName, audioPath = state.audioPath
audioName = state.audioName, audioPath = state.audioPath,
),
)
if (state.createReverse) {
// Reverse cards are intentionally audio-free (mirrors iOS).
cardRepository.upsert(
newCard(
idGenerator(),
state.back,
state.front,
isReverse = true,
pairId = baseId,
image = null,
imagePath = null,
audioName = null,
audioPath = null
idGenerator(), targetDeckId, state.back, state.front,
isReverse = true, pairId = baseId,
image = null, imagePath = null, audioName = null, audioPath = null,
),
)
}
Expand All @@ -163,13 +192,20 @@ class CardFormViewModel(
)
)
// Keep the editor open for rapid entry: clear inputs and bump the toast counter.
_uiState.value = CardFormUiState(isEdit = false, savedTick = state.savedTick + 1)
// In picker mode, preserve the deck selection and list for the next card.
_uiState.value = CardFormUiState(
isEdit = false,
savedTick = state.savedTick + 1,
pickDeck = state.pickDeck,
decks = state.decks,
selectedDeckId = state.selectedDeckId,
)
}
}
}

private fun newCard(
id: String, front: String, back: String, isReverse: Boolean, pairId: String?,
id: String, deckId: String, front: String, back: String, isReverse: Boolean, pairId: String?,
image: String?, imagePath: String?, audioName: String?, audioPath: String?,
): Card {
val t = now()
Expand Down
Loading
Loading