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 @@ -55,7 +55,7 @@ object StudyQueueBuilder {
filter: ReviewCardFilter,
shuffleSeed: Long? = null,
): List<Card> {
val filtered = cards.filter { !it.isDeleted }.filter { card ->
val filtered = cards.filter { !it.isDeleted && !it.memorized }.filter { card ->
when (filter) {
ReviewCardFilter.All -> true
ReviewCardFilter.OriginalsOnly -> !card.isReverse
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/nart/simpleanki/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import nart.simpleanki.feature.profile.ProfileViewModel
import nart.simpleanki.feature.queue.DailyGoalViewModel
import nart.simpleanki.feature.queue.StudyQueueViewModel
import nart.simpleanki.feature.settings.SettingsViewModel
import nart.simpleanki.feature.review.ReviewViewModel
import nart.simpleanki.feature.study.StudyViewModel
import nart.simpleanki.feature.sync.SyncViewModel
import org.koin.android.ext.koin.androidContext
Expand Down Expand Up @@ -193,6 +194,16 @@ val appModule = module {
logManager = get(),
)
}
viewModel { params ->
val args = params.get<StudyArgs>()
ReviewViewModel(
deckId = args.deckId,
folderId = args.folderId,
cardRepository = get(),
deckRepository = get(),
logManager = get(),
)
}
viewModel {
StudyQueueViewModel(
cardRepository = get(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.School
import androidx.compose.material.icons.filled.Style
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
Expand Down Expand Up @@ -73,6 +75,7 @@ fun DeckDetailScreen(
deckId: String,
onBack: () -> Unit,
onStudy: () -> Unit,
onReview: () -> Unit,
onAddCard: () -> Unit,
onEditCard: (String) -> Unit,
onSettings: () -> Unit,
Expand All @@ -87,6 +90,7 @@ fun DeckDetailScreen(
onQueryChange = viewModel::onQueryChange,
onBack = onBack,
onStudy = onStudy,
onReview = onReview,
onAddCard = onAddCard,
onEditCard = onEditCard,
onSettings = onSettings,
Expand All @@ -112,6 +116,7 @@ fun DeckDetailContent(
onQueryChange: (String) -> Unit,
onBack: () -> Unit,
onStudy: () -> Unit,
onReview: () -> Unit = {},
onAddCard: () -> Unit,
onEditCard: (String) -> Unit,
onSettings: () -> Unit,
Expand Down Expand Up @@ -213,6 +218,20 @@ fun DeckDetailContent(
state.total > 0 -> AllCaughtUp(cards = state.cards, now = now)
else -> Unit // empty deck: the list body below shows "No cards yet."
}
if (state.total > 0) {
OutlinedButton(
onClick = onReview,
modifier = Modifier.fillMaxWidth().height(50.dp),
shape = MaterialTheme.shapes.large,
) {
Icon(Icons.Filled.Style, contentDescription = null)
Text(
"Review",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}

if (state.cards.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Style
import androidx.compose.material.icons.outlined.FolderOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
Expand Down Expand Up @@ -41,6 +42,7 @@ fun FolderDetailScreen(
folderId: String,
onBack: () -> Unit,
onOpenDeck: (String) -> Unit,
onReview: () -> Unit,
onNewDeck: () -> Unit,
onEditFolder: () -> Unit,
viewModel: FolderDetailViewModel = koinViewModel { parametersOf(folderId) },
Expand All @@ -50,6 +52,7 @@ fun FolderDetailScreen(
state = state,
onBack = onBack,
onOpenDeck = onOpenDeck,
onReview = onReview,
onNewDeck = onNewDeck,
onEditFolder = onEditFolder,
)
Expand All @@ -62,6 +65,7 @@ fun FolderDetailContent(
state: FolderDetailUiState,
onBack: () -> Unit,
onOpenDeck: (String) -> Unit,
onReview: () -> Unit = {},
onNewDeck: () -> Unit,
onEditFolder: () -> Unit,
) {
Expand All @@ -78,6 +82,7 @@ fun FolderDetailContent(
}
},
actions = {
IconButton(onClick = onReview) { Icon(Icons.Filled.Style, "Review folder") }
IconButton(onClick = onEditFolder) { Icon(Icons.Default.Edit, "Edit folder") }
IconButton(onClick = onNewDeck) { Icon(Icons.Default.Add, "New deck in folder") }
},
Expand Down
167 changes: 167 additions & 0 deletions app/src/main/java/nart/simpleanki/feature/review/ReviewScreen.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package nart.simpleanki.feature.review

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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.TouchApp
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import nart.simpleanki.core.domain.model.Card
import nart.simpleanki.core.domain.model.CardState
import nart.simpleanki.di.StudyArgs
import nart.simpleanki.ui.components.FlipCard
import nart.simpleanki.ui.theme.AzriTheme
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf

@Composable
fun ReviewScreen(
deckId: String?,
onDone: () -> Unit,
folderId: String? = null,
viewModel: ReviewViewModel = koinViewModel { parametersOf(StudyArgs(deckId = deckId, folderId = folderId)) },
) {
val state by viewModel.uiState.collectAsState()
ReviewContent(state = state, onDone = onDone)
}

/** Stateless review carousel, decoupled from the ViewModel for previews. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReviewContent(state: ReviewUiState, onDone: () -> Unit) {
val pagerState = rememberPagerState(pageCount = { state.cards.size })
Scaffold(
topBar = {
TopAppBar(
title = {
if (state.cards.isNotEmpty()) {
Text("${pagerState.currentPage + 1} of ${state.cards.size}")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
navigationIcon = {
TextButton(onClick = onDone) { Text("Quit") }
},
)
},
) { padding ->
Box(
Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) {
when {
state.loading -> CircularProgressIndicator()
state.cards.isEmpty() -> EmptyReview(onDone)
else -> {
// Flip resets when the page changes (mirrors iOS clearing flips on scroll).
var revealed by remember(pagerState.currentPage) { mutableStateOf(false) }
var showHint by remember { mutableStateOf(true) }
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
val card = state.cards[page]
key(card.id) {
FlipCard(
card = card,
revealed = page == pagerState.currentPage && revealed,
onFlip = {
revealed = true
showHint = false
},
modifier = Modifier.fillMaxSize().padding(20.dp),
)
}
}
if (showHint) {
Row(
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Outlined.TouchApp,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
Text(
"Tap to flip",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}

@Composable
private fun EmptyReview(onDone: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"No cards to review here.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Button(onClick = onDone) { Text("Close") }
}
}

private fun previewCard(id: String, front: String, back: String) = Card(
id = id, front = front, back = back, deckId = "d",
dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.Review.value,
)

@Preview(name = "Review · populated", showBackground = true)
@Composable
private fun ReviewPopulatedPreview() {
AzriTheme {
ReviewContent(
state = ReviewUiState(
loading = false,
cards = listOf(
previewCard("1", "hola", "hello"),
previewCard("2", "adiós", "goodbye"),
),
),
onDone = {},
)
}
}

@Preview(name = "Review · empty", showBackground = true)
@Composable
private fun ReviewEmptyPreview() {
AzriTheme {
ReviewContent(state = ReviewUiState(loading = false, cards = emptyList()), onDone = {})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package nart.simpleanki.feature.review

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import nart.simpleanki.core.analytics.LoggableEvent
import nart.simpleanki.core.analytics.LogManager
import nart.simpleanki.core.data.repository.CardRepository
import nart.simpleanki.core.data.repository.DeckRepository
import nart.simpleanki.core.domain.fsrs.StudyQueueBuilder
import nart.simpleanki.core.domain.model.Card
import nart.simpleanki.core.domain.model.ReviewCardFilter

data class ReviewUiState(
val loading: Boolean = true,
val cards: List<Card> = emptyList(),
)

/**
* Drives a read-only Review (cram) session: snapshots a deck's or folder's cards once, applies the
* review filter (direction) + optional shuffle, and exposes the immutable pool. No rating, no FSRS
* scheduling, no card writes — purely browsing.
*/
class ReviewViewModel(
/** Deck to review; null when reviewing a folder. */
private val deckId: String?,
/** Folder to review (all cards across its decks); null when reviewing a single deck. */
private val folderId: String?,
private val cardRepository: CardRepository,
private val deckRepository: DeckRepository,
private val now: () -> Long = { System.currentTimeMillis() },
private val logManager: LogManager = LogManager(emptyList()),
) : ViewModel() {

private val _uiState = MutableStateFlow(ReviewUiState())
val uiState: StateFlow<ReviewUiState> = _uiState.asStateFlow()

init {
viewModelScope.launch { load() }
}

private suspend fun load() {
val pool = when {
folderId != null -> {
val deckIds = deckRepository.observeDecksInFolder(folderId).first().map { it.id }.toSet()
val cards = cardRepository.observeAllCards().first().filter { it.deckId in deckIds }
StudyQueueBuilder.buildReviewQueue(cards, ReviewCardFilter.All, shuffleSeed = now())
}
deckId != null -> {
val deck = deckRepository.getById(deckId)
val cards = cardRepository.observeCards(deckId).first()
StudyQueueBuilder.buildReviewQueue(
cards = cards,
filter = deck?.reviewFilter ?: ReviewCardFilter.All,
shuffleSeed = if (deck?.shuffled == true) now() else null,
)
}
else -> emptyList()
}
_uiState.value = ReviewUiState(loading = false, cards = pool)
logManager.track(Event.ReviewStart(deckId, folderId, pool.size))
}

private sealed interface Event : LoggableEvent {
data class ReviewStart(val deckId: String?, val folderId: String?, val count: Int) : Event {
override val eventName = "cram_session_start"
override val params get() = buildMap<String, Any?> {
deckId?.let { put("deck_id", it) }
folderId?.let { put("folder_id", it) }
put("count", count)
}
}
}
}
Loading
Loading