Arcology Engine

Quiz Screens — Card Review UI & Settings

Contents

Introduction

The quiz screens are the Compose UI that drives the spaced repetition experience on Android. QuizScreen is the centerpiece — a card-flip interface with fade transitions, four rating buttons (Again/Hard/Good/Easy), swipe-on-back for quick AGAIN/GOOD, context filtering by tag or backlink, session progress tracking, and embedded statistics. QuizSettingsScreen configures daily reminders (via QuizNotificationManager), session limits, progress bar visibility, and auto-flip delay.

Both screens consume the domain models from quiz/models.org, the services from quiz/flashcard.org, and the ViewModel from quiz/viewmodel.org. QuizScreen also depends on OrgDocumentRenderer from editor/renderer.org and ClozeState for cloze hole rendering.

Design Decisions

Card flip as alpha fade instead of rotation.

The front and back of the card are rendered as two Card composables in a Box stack, with animated alpha values: frontAlpha goes 1→0 and backAlpha goes 0→1 over 300ms. This is simpler than a 3D rotation animation (which would need graphicsLayer transforms and backface visibility) and visually cleaner — the content fades in its place rather than sliding away. The flip is triggered by tapping the card.

Swipe-on-back for gesture-based rating.

When the card is flipped to the back, horizontal drag gestures on the card surface trigger ratings: swipe left (< -100px) → AGAIN, swipe right (> +100px) → GOOD. A translucent overlay appears during the drag (red for left/AGAIN, green for right/GOOD) to provide visual feedback before the swipe commits. This is an ergonomic shortcut — the rating buttons are still available below the card, but swipe is faster for experienced users.

Dynamic font size by word count.

calculateQuizFontSize scales from 28sp (≤20 words) down to 14sp (≥100 words), with linear interpolation in between. This ensures short cards (single words, vocabulary terms) use large readable text and long cards (paragraph cloze deletions) don't overflow. The function is duplicated in QuizScreen.kt rather than imported from FontUtils.kt because the quiz screen's font needs are different from the editor's.

Compact rating buttons with color coding.

The four rating buttons use FilledTonalIconButton with per-rating colors (error/red for AGAIN, tertiary for HARD, primary for GOOD, onPrimaryContainer for EASY) at 20% container opacity. Each is 56dp square with a 28dp icon — compact enough to fit four across a phone screen.

Pre-parsed AST from cache, not re-parsed on every recomposition.

QuizCardState carries questionParseResult and answerParseResult from the ViewModel. QuizScreen passes them directly to OrgDocumentRenderer via =renderMode=BODY_ONLY= and clozeState for cloze cards. If the parse result is null (shouldn't happen in practice), the composable falls back to parsing on the composition thread using remember caching.

Implementation

The screen is composed from four narrative blocks using noweb composition for the main QuizScreen.kt file, plus a standalone block for QuizSettingsScreen.kt.

QuizScreen composable — root scaffold

QuizScreen is the entry point. It receives a QuizViewModel and SearchViewModel via koinViewModel(), collects all state flows (sessionState, currentCard, statistics, progress, etc.), dispatches startSession on idle, and shows snackbar on review. The scaffold routes to NoCardsDueContent, SessionCompletedContent, ErrorContent, or QuizContent based on sessionState.

Dialogs for TagSelector and NodeSelector are shown when the user taps filter buttons — these provide the context filtering UI (filter by tag or by backlink node).

kotlin#+name: quiz-screen-entry
package computer.whatthefuck.arcology.app.ui.screens

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.abs
import computer.whatthefuck.arcology.app.viewmodel.QuizSessionState
import computer.whatthefuck.arcology.app.viewmodel.QuizViewModel
import computer.whatthefuck.arcology.app.viewmodel.SearchViewModel
import computer.whatthefuck.arcology.app.viewmodel.TagWithCount
import computer.whatthefuck.arcology.app.ui.components.NodeBreadcrumb
import computer.whatthefuck.arcology.app.ui.components.renderer.OrgDocumentRenderer
import computer.whatthefuck.arcology.app.ui.components.renderer.RenderMode
import computer.whatthefuck.arcology.editor.BreadcrumbEntry
import computer.whatthefuck.arcology.app.ui.components.renderer.ClozeState as RendererClozeState
import computer.whatthefuck.arcology.domain.ClozeType
import computer.whatthefuck.arcology.domain.FlashcardStatistics
import computer.whatthefuck.arcology.domain.FlashcardReview
import computer.whatthefuck.arcology.domain.QuizContext
import computer.whatthefuck.arcology.domain.ReviewRating
import org.koin.androidx.compose.koinViewModel
import xyz.lepisma.orgmode.lexer.OrgLexer
import xyz.lepisma.orgmode.parseWithDetails
import xyz.lepisma.orgmode.OrgParseResult

/**
 * Calculate font size based on word count (copied from FontUtils.kt).
 * Uses sensible defaults tuned for quiz content.
 */
private fun calculateQuizFontSize(
    content: String,
    minWords: Int = 100,
    maxWords: Int = 20,
    minSize: Float = 14f,
    maxSize: Float = 28f
): Float {
    val wordCount = if (content.isBlank()) 0 else content.trim().split("\\s+".toRegex()).filter { it.isNotBlank() }.size
    require(minWords >= maxWords) { "minWords ($minWords) must be >= maxWords ($maxWords)" }
    require(minSize > 0) { "minSize must be positive" }
    require(maxSize > 0) { "maxSize must be positive" }

    return when {
        wordCount <= maxWords -> maxSize
        wordCount >= minWords -> minSize
        else -> {
            val progress = (wordCount - maxWords).toFloat() / (minWords - maxWords).toFloat()
            val sizeDiff = maxSize - minSize
            (maxSize - progress * sizeDiff).coerceIn(minSize, maxSize)
        }
    }
}

/**
 * Quiz screen for spaced repetition flashcard review.
 * Implements the full quiz session flow with card flipping,
 * rating buttons, and progress tracking.
 */
@Composable
fun QuizScreen(
    viewModel: QuizViewModel = koinViewModel(),
    searchViewModel: SearchViewModel = koinViewModel(),
    onNavigateToNode: (nodeId: String) -> Unit = {}
) {
    val sessionState by viewModel.sessionState.collectAsState()
    val currentCard by viewModel.currentCard.collectAsState()
    val statistics by viewModel.statistics.collectAsState()
    val errorMessage by viewModel.errorMessage.collectAsState()
    val progress by viewModel.progress.collectAsState()
    val totalCards by viewModel.totalCards.collectAsState()
    val parentNodes by viewModel.parentNodes.collectAsState()
    val context by viewModel.contextFilter.collectAsState()
    val availableTags by searchViewModel.availableTags.collectAsState()
    val lastReview by viewModel.lastReview.collectAsState()

    val snackbarHostState = remember { SnackbarHostState() }
    var isTagSelectorOpen by remember { mutableStateOf(false) }
    var isNodeSelectorOpen by remember { mutableStateOf(false) }

    LaunchedEffect(sessionState) {
        if (sessionState == QuizSessionState.Idle) {
            viewModel.startSession()
        }
    }

    LaunchedEffect(lastReview) {
        lastReview?.let { review ->
            val dueDateStr = review.dueDate.toString().substringBefore("T")
            val message = "Box: ${review.box} | Ease: ${"%.2f".format(review.easeFactor)} | Next: $dueDateStr"
            snackbarHostState.showSnackbar(message)
            viewModel.clearLastReview()
        }
    }

    MaterialTheme {
        Scaffold(
            snackbarHost = { SnackbarHost(snackbarHostState) }
        ) { paddingValues ->
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues)
                    .background(MaterialTheme.colorScheme.background)
            ) {
                when (sessionState) {
                QuizSessionState.NoCardsDue -> {
                    NoCardsDueContent(
                        statistics = statistics,
                        context = context,
                        onClearContext = { viewModel.clearContext() },
                        onRefresh = { viewModel.startSession() },
                        availableTags = availableTags,
                        onOpenTagSelector = { isTagSelectorOpen = true },
                        onOpenNodeSelector = { isNodeSelectorOpen = true }
                    )
                }
                QuizSessionState.Completed -> {
                    SessionCompletedContent(
                        statistics = statistics,
                        context = context,
                        availableTags = availableTags,
                        onClearContext = { viewModel.clearContext() },
                        onOpenTagSelector = { isTagSelectorOpen = true },
                        onOpenNodeSelector = { isNodeSelectorOpen = true },
                        onRestart = { viewModel.startSession() }
                    )
                }
                QuizSessionState.Error -> {
                    ErrorContent(errorMessage) {
                        viewModel.startSession()
                    }
                }
                else -> {
                    QuizContent(
                        currentCard = currentCard,
                        parentNodes = parentNodes,
                        statistics = statistics,
                        progress = progress,
                        totalCards = totalCards,
                        onRate = { rating -> viewModel.rateCard(rating) },
                        onSkip = { viewModel.skipCard() },
                        onSuspend = { viewModel.suspendCurrentCard() },
                        onNavigateToNode = onNavigateToNode,
                        onEndSession = { viewModel.endSession() }
                    )
                }
            }
        }
    }
    }

    // Dialogs
    if (isTagSelectorOpen) {
        TagSelectorDialog(
            availableTags = availableTags,
            onTagSelected = { tag ->
                viewModel.setContext(QuizContext(computer.whatthefuck.arcology.domain.QuizContextType.TAG, tag))
                isTagSelectorOpen = false
            },
            onDismiss = { isTagSelectorOpen = false }
        )
    }

    if (isNodeSelectorOpen) {
        NodeSelectorDialog(
            searchViewModel = searchViewModel,
            onNodeSelected = { nodeId ->
                viewModel.setContext(QuizContext(computer.whatthefuck.arcology.domain.QuizContextType.BACKLINK, nodeId))
                isNodeSelectorOpen = false
            },
            onDismiss = { isNodeSelectorOpen = false }
        )
    }
}

QuizContent — the card review layout

The main review UI: header with progress bar and due count, card area with front/back flip animation, and footer with statistics. The card flip uses animated alpha: front fades out, back fades in. QuizCard renders the front and back as stacked Card composables with verticalScroll for long content.

kotlin#+name: quiz-screen-content
@Composable
private fun QuizContent(
    currentCard: computer.whatthefuck.arcology.app.viewmodel.QuizCardState?,
    statistics: FlashcardStatistics?,
    progress: Int,
    totalCards: Int,
    parentNodes: List<BreadcrumbEntry>,
    onRate: (ReviewRating) -> Unit,
    onSkip: () -> Unit,
    onSuspend: () -> Unit,
    onNavigateToNode: (String) -> Unit,
    onEndSession: () -> Unit
) {
    // Animation for card flip - fade transition
    val isFlipped = remember { mutableStateOf(false) }
    val alpha by animateFloatAsState(
        targetValue = if (isFlipped.value) 1f else 0f,
        animationSpec = tween(durationMillis = 300)
    )

    val cardScrollState = rememberScrollState()

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        // Header with progress and breadcrumbs
        QuizHeader(
            progress = progress,
            total = totalCards,
            dueCount = statistics?.dueToday?.toInt() ?: 0,
            parentNodes = parentNodes,
            currentTitle = currentCard?.title ?: "",
            currentNodeId = currentCard?.nodeId,
            onRate = onRate,
            onSkip = onSkip,
            onNavigateToNode = onNavigateToNode
        )

        Spacer(modifier = Modifier.height(16.dp))

        // Card display area
        if (currentCard != null) {
            QuizCard(
                currentCard = currentCard,
                frontAlpha = 1f - alpha,
                backAlpha = alpha,
                isFlipped = isFlipped.value,
                scrollState = cardScrollState,
                parentNodes = parentNodes,
                onFlip = { isFlipped.value = !isFlipped.value },
                onRate = { rating ->
                    onRate(rating)
                    isFlipped.value = false  // Reset to front for next card
                },
                onSkip = {
                    onSkip()
                    isFlipped.value = false  // Reset to front for next card
                },
                onSuspend = {
                    onSuspend()
                    isFlipped.value = false  // Reset to front for next card
                },
                onNavigateToNode = onNavigateToNode
            )
        } else {
            // Loading state
            CircularProgressIndicator()
        }

        Spacer(modifier = Modifier.height(24.dp))

        // Statistics footer with context
        statistics?.let {
            QuizFooter(
                statistics = it,
                onEndSession = onEndSession
            )
        }
    }
}

The card rendering stack: QuizCard is a Box with front and back Card composables stacked. The front shows title + question content with CardFrontContent. The back shows answer + rating buttons + swipe overlay. QuizHeader shows session progress, title breadcrumbs, and due count. CompactRatingButtons renders the four FilledTonalIconButton buttons. QuizFooter shows statistics and context filter chips.

kotlin#+name: quiz-screen-card
@Composable
private fun QuizHeader(
    progress: Int,
    total: Int,
    dueCount: Int,
    parentNodes: List<BreadcrumbEntry>,
    currentTitle: String,
    currentNodeId: String?,
    onRate: (ReviewRating) -> Unit,
    onSkip: () -> Unit,
    onNavigateToNode: (String) -> Unit
) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = "Quiz Session",
                style = MaterialTheme.typography.headlineSmall,
                fontWeight = FontWeight.Bold
            )

            Spacer(modifier = Modifier.height(8.dp))

            // Progress indicator
            if (total > 0) {
                Text(
                    text = "Card $progress of $total",
                    style = MaterialTheme.typography.bodyMedium
                )
                Spacer(modifier = Modifier.height(4.dp))
                LinearProgressIndicator(
                    progress = progress.coerceIn(0, total) / total.toFloat(),
                    modifier = Modifier.fillMaxWidth(0.8f),
                    trackColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
                )
            }

            Spacer(modifier = Modifier.height(8.dp))

            // Due cards count
            Text(
                text = "${dueCount.coerceAtLeast(0)} cards due",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )

            Spacer(modifier = Modifier.height(8.dp))

        }
    }
}

@Composable
private fun QuizCard(
    currentCard: computer.whatthefuck.arcology.app.viewmodel.QuizCardState,
    frontAlpha: Float,
    backAlpha: Float,
    isFlipped: Boolean,
    scrollState: ScrollState,
    parentNodes: List<BreadcrumbEntry>,
    onFlip: () -> Unit,
    onRate: (ReviewRating) -> Unit,
    onSkip: () -> Unit,
    onSuspend: () -> Unit,
    onNavigateToNode: (String) -> Unit
) {
    // Swipe state for back of card
    var swipeOffset by remember { mutableFloatStateOf(0f) }
    val swipeThreshold = 100f
    val indicatorThreshold = 20f

    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth(0.9f)
                .height(400.dp),
            contentAlignment = Alignment.Center
        ) {
            // Front of card (fade out when flipped)
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .alpha(frontAlpha)
                    .clickable { onFlip() },
                shape = RoundedCornerShape(16.dp),
                elevation = CardDefaults.cardElevation(
                    defaultElevation = 4.dp,
                    focusedElevation = 8.dp
                )
            ) {
                Column(
                    modifier = Modifier.fillMaxWidth().verticalScroll(scrollState),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                        CardFrontContent(
                            title = currentCard.title,
                            content = currentCard.questionContent,
                            parseResult = currentCard.questionParseResult,
                            cardType = currentCard.cardType,
                            parents = parentNodes,
                            currentTitle = currentCard.title ?: "",
                            onNavigateToNode = onNavigateToNode,
                            onSuspend = onSuspend,
                            currentClozeId = currentCard.positionName.toIntOrNull(),
                            clozeType = currentCard.clozeType
                        )
                }
            }

            // Back of card (fade in when flipped) - with swipe gesture
            // Only interactive when flipped to prevent intercepting front card touches
            if (isFlipped) {
                Card(
                    modifier = Modifier
                        .fillMaxWidth()
                        .alpha(backAlpha)
                        .clickable { onFlip() }
                        .pointerInput(Unit) {
                            detectHorizontalDragGestures(
                                onDragEnd = {
                                    when {
                                        swipeOffset < -swipeThreshold -> onRate(ReviewRating.AGAIN)
                                        swipeOffset > swipeThreshold -> onRate(ReviewRating.GOOD)
                                    }
                                    swipeOffset = 0f
                                },
                                onDragCancel = { swipeOffset = 0f },
                                onHorizontalDrag = { _, dragAmount ->
                                    swipeOffset += dragAmount
                                }
                            )
                        },
                    shape = RoundedCornerShape(16.dp),
                    elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
                ) {
                    Column(
                        modifier = Modifier.fillMaxWidth().verticalScroll(scrollState),
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        CardBackContent(
                            title = currentCard.title,
                            content = currentCard.answerContent,
                            parseResult = currentCard.answerParseResult,
                            cardType = currentCard.cardType,
                            parents = parentNodes,
                            currentTitle = currentCard.title ?: "",
                            onNavigateToNode = onNavigateToNode,
                            currentClozeId = currentCard.positionName.toIntOrNull(),
                            clozeType = currentCard.clozeType
                        )

                        // Swipe indicator overlay (only visible during swipe on back)
                        if (abs(swipeOffset) > indicatorThreshold) {
                            SwipeIndicatorOverlay(swipeOffset = swipeOffset)
                        }
                    }
                }
            }
        }

        // Rating buttons - only visible when flipped to back
        AnimatedVisibility(visible = isFlipped) {
            Column(
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Spacer(modifier = Modifier.height(16.dp))

                CompactRatingButtons(onRate = onRate)

                Spacer(modifier = Modifier.height(8.dp))
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            // Spacer(modifier = Modifier.height(8.dp))

            // Skip button
            TextButton(
                onClick = onSkip,
            ) {
                Icon(
                    imageVector = Icons.Default.SkipNext,
                    contentDescription = null,
                    modifier = Modifier.padding(4.dp)
                )
                Spacer(modifier = Modifier.width(4.dp))
                Text("Skip Card")
            }

            // Suspend button
            SuspendButton(
                onSuspend = onSuspend
            )

            // Navigate to current node button
            currentCard.nodeId?.let { nodeId ->
                TextButton(
                    onClick = { onNavigateToNode(nodeId) },
                ) {
                    Icon(
                        imageVector = Icons.Default.OpenInNew,
                        contentDescription = null,
                        modifier = Modifier.padding(4.dp)
                    )
                    Spacer(modifier = Modifier.width(4.dp))
                    Text(
                        text = "Open",
                        style = MaterialTheme.typography.labelMedium
                    )
                }
            }

            // Spacer(modifier = Modifier.height(8.dp))
        }

    }
}

/**
 * Swipe indicator overlay shown during horizontal drag on back of card.
 * Shows directional hints for Again (left) and Good (right).
 */
@Composable
private fun SwipeIndicatorOverlay(swipeOffset: Float) {
    Box(modifier = Modifier.fillMaxSize()) {
        // Left indicator (Again) - red tint
        if (swipeOffset < 0) {
            val alpha = (abs(swipeOffset) / 150f).coerceIn(0f, 0.5f)
            Box(
                modifier = Modifier
                    .align(Alignment.CenterStart)
                    .fillMaxHeight()
                    .width(100.dp)
                    .background(
                        MaterialTheme.colorScheme.error.copy(alpha = alpha),
                        RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp)
                    )
                    .padding(16.dp),
                contentAlignment = Alignment.CenterStart
            ) {
                Text(
                    "← Again",
                    color = MaterialTheme.colorScheme.error,
                    style = MaterialTheme.typography.labelLarge
                )
            }
        }
        // Right indicator (Good) - green tint
        if (swipeOffset > 0) {
            val alpha = (swipeOffset / 150f).coerceIn(0f, 0.5f)
            Box(
                modifier = Modifier
                    .align(Alignment.CenterEnd)
                    .fillMaxHeight()
                    .width(100.dp)
                    .background(
                        MaterialTheme.colorScheme.primary.copy(alpha = alpha),
                        RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp)
                    )
                    .padding(16.dp),
                contentAlignment = Alignment.CenterEnd
            ) {
                Text(
                    "Good →",
                    color = MaterialTheme.colorScheme.primary,
                    style = MaterialTheme.typography.labelLarge
                )
            }
        }
    }
}

@Composable
private fun CardFrontContent(
    title: String,
    content: String,
    parseResult: OrgParseResult?,
    cardType: String,
    parents: List<BreadcrumbEntry>,
    currentTitle: String,
    onNavigateToNode: (String) -> Unit,
    onSuspend: () -> Unit,
    currentClozeId: Int? = null,
    clozeType: ClozeType? = null
) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Top
    ) {
        Badge(
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text(
                text = "front",
                style = MaterialTheme.typography.labelSmall,
                fontWeight = FontWeight.Bold
            )
        }

        // Breadcrumbs for current node
        if (parents.isNotEmpty()) {
            NodeBreadcrumb(
                parents = parents,
                currentTitle = currentTitle,
                onParentClick = onNavigateToNode,
                modifier = Modifier.padding(top = 8.dp)
            )
        }

        Box(
            modifier = Modifier
                .fillMaxWidth()
                .padding(24.dp)
        ) {
            val fontSize = calculateQuizFontSize(content.ifEmpty { title })
            
            // Use pre-parsed AST from ViewModel (computed off main thread using cache)
            val displayResult = parseResult ?: run {
                val tokens = remember(content) {
                    if (content.isEmpty()) emptyList() else OrgLexer(content).tokenize()
                }
                remember(tokens) {
                    if (tokens.isEmpty()) OrgParseResult.Failure("Empty content", -1, emptyList())
                    else parseWithDetails(tokens)
                }
            }
            
            when (displayResult) {
                is OrgParseResult.Success -> {
                    OrgDocumentRenderer(
                        document = displayResult.document,
                        mode = RenderMode.BODY_ONLY,
                        onLinkClick = { /* no navigation in quiz */ },
                        clozeState = if (cardType.lowercase() == "cloze") {
                            RendererClozeState(
                                currentClozeId = currentClozeId,
                                isRevealed = false,
                                clozeType = clozeType
                            )
                        } else {
                            null
                        },
                        baseFontSize = fontSize
                    )
                }
                is OrgParseResult.Failure -> {
                    // Fallback to raw text on parse failure
                    Text(
                        text = content,
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }
    }
}

@Composable
private fun CardBackContent(
    title: String,
    content: String,
    parseResult: OrgParseResult?,
    cardType: String,
    parents: List<BreadcrumbEntry>,
    currentTitle: String,
    onNavigateToNode: (String) -> Unit,
    currentClozeId: Int? = null,
    clozeType: ClozeType? = null
) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Top
    ) {
        Badge(
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text(
                text = "back",
                style = MaterialTheme.typography.labelSmall,
                fontWeight = FontWeight.Bold
            )
        }

        // Breadcrumbs for current node
        if (parents.isNotEmpty()) {
            NodeBreadcrumb(
                parents = parents,
                currentTitle = currentTitle,
                onParentClick = onNavigateToNode,
                modifier = Modifier.padding(top = 8.dp)
            )
        }


        Box(
            modifier = Modifier
                .fillMaxWidth()
                .padding(24.dp)
        ) {
            val fontSize = calculateQuizFontSize(content.ifEmpty { title })
            
            // Use pre-parsed AST from ViewModel (computed off main thread using cache)
            val displayResult = parseResult ?: run {
                val tokens = remember(content) {
                    if (content.isEmpty()) emptyList() else OrgLexer(content).tokenize()
                }
                remember(tokens) {
                    if (tokens.isEmpty()) OrgParseResult.Failure("Empty content", -1, emptyList())
                    else parseWithDetails(tokens)
                }
            }
            
            when (displayResult) {
                is OrgParseResult.Success -> {
                    OrgDocumentRenderer(
                        document = displayResult.document,
                        mode = RenderMode.BODY_ONLY,
                        onLinkClick = { /* no navigation in quiz */ },
                        clozeState = if (cardType.lowercase() == "cloze" && currentClozeId != null) {
                            // For cloze cards on back, reveal the current cloze with highlight
                            RendererClozeState(
                                currentClozeId = currentClozeId,
                                isRevealed = true,
                                clozeType = clozeType
                            )
                        } else {
                            null
                        },
                        baseFontSize = fontSize
                    )
                }
                is OrgParseResult.Failure -> {
                    // Fallback to raw text on parse failure
                    Text(
                        text = content,
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }

        Badge(
            modifier = Modifier.padding(top = 16.dp)
        ) {
            Text(
                text = cardType.uppercase(),
                style = MaterialTheme.typography.labelSmall,
                fontWeight = FontWeight.Bold
            )
        }
    }
}

/**
 * Compact rating buttons using icon-only buttons.
 * Only shown when card is flipped to back side.
 */
@Composable
private fun CompactRatingButtons(onRate: (ReviewRating) -> Unit) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceEvenly
    ) {
        RatingIconButton(
            icon = Icons.Default.Close,
            label = "Again",
            color = MaterialTheme.colorScheme.error,
            onRate = { onRate(ReviewRating.AGAIN) }
        )
        RatingIconButton(
            icon = Icons.Default.KeyboardArrowDown,
            label = "Hard",
            color = MaterialTheme.colorScheme.tertiary,
            onRate = { onRate(ReviewRating.HARD) }
        )
        RatingIconButton(
            icon = Icons.Default.Check,
            label = "Good",
            color = MaterialTheme.colorScheme.primary,
            onRate = { onRate(ReviewRating.GOOD) }
        )
        RatingIconButton(
            icon = Icons.Default.CheckCircle,
            label = "Easy",
            color = MaterialTheme.colorScheme.onPrimaryContainer,
            onRate = { onRate(ReviewRating.EASY) }
        )
    }
}

@Composable
private fun RatingIconButton(
    icon: androidx.compose.ui.graphics.vector.ImageVector,
    label: String,
    color: Color,
    onRate: () -> Unit
) {
    var showTooltip by remember { mutableStateOf(false) }

    Box {
        FilledTonalIconButton(
            onClick = onRate,
            colors = IconButtonDefaults.filledTonalIconButtonColors(
                containerColor = color.copy(alpha = 0.2f),
                contentColor = color
            ),
            modifier = Modifier.size(56.dp)
        ) {
            Icon(
                imageVector = icon,
                contentDescription = label,
                modifier = Modifier.size(28.dp)
            )
        }

        // Long-press tooltip
        DropdownMenu(
            expanded = showTooltip,
            onDismissRequest = { showTooltip = false }
        ) {
            Text(
                text = label,
                modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
            )
        }
    }
}

/**
 * Button to suspend the current flashcard.
 * Suspend cards are temporarily removed from the review queue.
 */
@Composable
private fun SuspendButton(
    onSuspend: () -> Unit
) {
    TextButton(
        onClick = onSuspend,
    ) {
        Icon(
            imageVector = Icons.Default.Pause,
            contentDescription = null,
            modifier = Modifier.padding(4.dp)
        )
        Spacer(modifier = Modifier.width(4.dp))
        Text(
            text = "Suspend",
            style = MaterialTheme.typography.labelMedium
        )
    }
}

Session state screens — no cards due, completed, error, statistics

The non-review screens: NoCardsDueContent shows a checkmark and "All caught up!" with context filter buttons and statistics. SessionCompletedContent shows a star with full statistics and a "Start New Session" button. ErrorContent shows a red error icon with the error message and retry. Supporting composables FlashcardStatisticsSection, RatingDistributionChart, StatRow, ContextChip, ContextSelector, TagSelectorDialog, and NodeSelectorDialog handle statistics display and context filtering UI.

kotlin#+name: quiz-screen-states
@Composable
private fun QuizFooter(
    statistics: FlashcardStatistics,
    onEndSession: () -> Unit
) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        // End Session button
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.Center
        ) {
            OutlinedButton(onClick = onEndSession) {
                Icon(
                    Icons.Default.ExitToApp,
                    contentDescription = "End session",
                    modifier = Modifier.size(16.dp)
                )
                Spacer(modifier = Modifier.width(8.dp))
                Text("End Session")
            }
        }

        // Statistics footer
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            StatItem(
                label = "Total",
                value = statistics.totalFlashcards.toString()
            )
            StatItem(
                label = "Due",
                value = statistics.dueToday.toString()
            )
            StatItem(
                label = "Reviews",
                value = statistics.totalReviews.toString()
            )
            StatItem(
                label = "Avg Ease",
                value = String.format("%.2f", statistics.averageEase ?: 0.0)
            )
        }
    }
}

@Composable
private fun StatItem(label: String, value: String) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ) {
        Text(
            text = value,
            style = MaterialTheme.typography.titleLarge,
            fontWeight = FontWeight.Bold
        )
        Text(
            text = label,
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}

/**
 * Shared context filter row used by both the quiz footer and no-cards-due screen.
 * Shows an active context chip or tag/backlink filter buttons.
 */
@Composable
private fun ContextFilterRow(
    context: computer.whatthefuck.arcology.domain.QuizContext?,
    onClearContext: () -> Unit,
    onOpenTagSelector: () -> Unit,
    onOpenNodeSelector: () -> Unit
) {
    if (context != null) {
        ContextChip(context = context, onClear = onClearContext)
    } else {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            OutlinedButton(
                onClick = onOpenTagSelector,
                modifier = Modifier.weight(1f)
            ) {
                Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp))
                Spacer(modifier = Modifier.width(4.dp))
                Text("Filter by Tag", style = MaterialTheme.typography.labelMedium)
            }
            OutlinedButton(
                onClick = onOpenNodeSelector,
                modifier = Modifier.weight(1f)
            ) {
                Icon(Icons.Default.Link, contentDescription = null, modifier = Modifier.size(16.dp))
                Spacer(modifier = Modifier.width(4.dp))
                Text("Filter by Backlink", style = MaterialTheme.typography.labelMedium)
            }
        }
    }
}

@Composable
private fun NoCardsDueContent(
    statistics: FlashcardStatistics?,
    context: computer.whatthefuck.arcology.domain.QuizContext?,
    onClearContext: () -> Unit,
    onRefresh: () -> Unit,
    availableTags: List<computer.whatthefuck.arcology.app.viewmodel.TagWithCount>,
    onOpenTagSelector: () -> Unit,
    onOpenNodeSelector: () -> Unit
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Icon(
            imageVector = Icons.Default.CheckCircle,
            contentDescription = null,
            modifier = Modifier.size(96.dp),
            tint = MaterialTheme.colorScheme.primary
        )

        Spacer(modifier = Modifier.height(24.dp))

        Text(
            text = "All caught up!",
            style = MaterialTheme.typography.headlineMedium,
            fontWeight = FontWeight.Bold
        )

        Spacer(modifier = Modifier.height(8.dp))

        Text(
            text = "No cards are due for review right now.",
            style = MaterialTheme.typography.bodyLarge,
            textAlign = TextAlign.Center,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )

        statistics?.let {
            Spacer(modifier = Modifier.height(32.dp))
            FlashcardStatisticsSection(statistics = it)
        }

        Spacer(modifier = Modifier.height(24.dp))

        // Context filter (shared with QuizFooter)
        ContextFilterRow(
            context = context,
            onClearContext = onClearContext,
            onOpenTagSelector = onOpenTagSelector,
            onOpenNodeSelector = onOpenNodeSelector
        )

        Spacer(modifier = Modifier.height(24.dp))

        Button(onClick = onRefresh) {
            Icon(Icons.Default.Refresh, contentDescription = null)
            Spacer(modifier = Modifier.width(8.dp))
            Text("Check Again")
        }
    }
}

@Composable
private fun StatRow(label: String, value: String) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(
            text = label,
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
        Text(
            text = value,
            style = MaterialTheme.typography.bodyMedium,
            fontWeight = FontWeight.Bold
        )
    }
}

@Composable
private fun RatingDistributionChart(ratingDistribution: Map<computer.whatthefuck.arcology.domain.ReviewRating, Long>) {
    val totalReviews = ratingDistribution.values.sum()
    if (totalReviews == 0L) return

    // Sort ratings in order: AGAIN, HARD, GOOD, EASY
    val sortedRatings = listOf(
        computer.whatthefuck.arcology.domain.ReviewRating.AGAIN,
        computer.whatthefuck.arcology.domain.ReviewRating.HARD,
        computer.whatthefuck.arcology.domain.ReviewRating.GOOD,
        computer.whatthefuck.arcology.domain.ReviewRating.EASY
    ).filter { it in ratingDistribution.keys }

    // Calculate percentages for each rating
    val ratingPercentages = sortedRatings.associate { rating ->
        val count = ratingDistribution[rating] ?: 0
        val percentage = count.toDouble() / totalReviews
        rating to percentage
    }

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .height(32.dp)
    ) {
        sortedRatings.forEach { rating ->
            val percentage = ratingPercentages[rating] ?: 0.0

            Box(
                modifier = Modifier
                    .weight(percentage.toFloat())
                    .background(getRatingColor(rating))
                    .padding(horizontal = 1.dp),
                contentAlignment = Alignment.Center
            ) {
                if (percentage > 0.05) {
                    // Always show percentage label in center of bar
                    Text(
                        text = "${(percentage * 100).toInt()}%",
                        style = MaterialTheme.typography.labelSmall,
                        color = MaterialTheme.colorScheme.onPrimary
                    )
                }
            }
        }
    }

    // Show rating labels below the chart
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        sortedRatings.forEach { rating ->
            val percentage = ratingPercentages[rating] ?: 0.0
            Row(
                horizontalArrangement = Arrangement.spacedBy(4.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Box(
                    modifier = Modifier
                        .size(12.dp)
                        .background(getRatingColor(rating))
                )
                Text(
                    text = "${rating.label} ${(percentage * 100).toInt()}%",
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

@Composable
private fun getRatingColor(rating: computer.whatthefuck.arcology.domain.ReviewRating): Color {
    return when (rating) {
        computer.whatthefuck.arcology.domain.ReviewRating.AGAIN -> MaterialTheme.colorScheme.error
        computer.whatthefuck.arcology.domain.ReviewRating.HARD -> MaterialTheme.colorScheme.tertiary
        computer.whatthefuck.arcology.domain.ReviewRating.GOOD -> MaterialTheme.colorScheme.primary
        computer.whatthefuck.arcology.domain.ReviewRating.EASY -> MaterialTheme.colorScheme.onPrimaryContainer
    }
}

@Composable
private fun SessionCompletedContent(
    statistics: FlashcardStatistics?,
    context: computer.whatthefuck.arcology.domain.QuizContext?,
    availableTags: List<computer.whatthefuck.arcology.app.viewmodel.TagWithCount>,
    onClearContext: () -> Unit,
    onOpenTagSelector: () -> Unit,
    onOpenNodeSelector: () -> Unit,
    onRestart: () -> Unit
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Icon(
            imageVector = Icons.Default.Star,
            contentDescription = null,
            modifier = Modifier.size(96.dp),
            tint = MaterialTheme.colorScheme.primary
        )

        Spacer(modifier = Modifier.height(24.dp))

        Text(
            text = "Session Complete!",
            style = MaterialTheme.typography.headlineMedium,
            fontWeight = FontWeight.Bold
        )

        statistics?.let {
            Spacer(modifier = Modifier.height(32.dp))
            FlashcardStatisticsSection(statistics = it)
        }

        Spacer(modifier = Modifier.height(24.dp))

        // Context filter
        ContextFilterRow(
            context = context,
            onClearContext = onClearContext,
            onOpenTagSelector = onOpenTagSelector,
            onOpenNodeSelector = onOpenNodeSelector
        )

        Spacer(modifier = Modifier.height(24.dp))

        Button(onClick = onRestart) {
            Text("Start New Session")
        }
    }
}

@Composable
private fun FlashcardStatisticsSection(statistics: FlashcardStatistics) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Text(
            text = "Flashcard Statistics",
            style = MaterialTheme.typography.titleMedium,
            fontWeight = FontWeight.Bold
        )

        // Overview stats (total, suspended, new, due)
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            Text(
                text = "Overview",
                style = MaterialTheme.typography.labelLarge,
                fontWeight = FontWeight.Bold,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )

            StatRow(
                label = "Total Flashcards",
                value = statistics.totalFlashcards.toString()
            )
            StatRow(
                label = "Suspended",
                value = statistics.suspendedCount.toString()
            )
            StatRow(
                label = "Total Positions",
                value = statistics.totalPositions.toString()
            )
            StatRow(
                label = "Total Reviews",
                value = statistics.totalReviews.toString()
            )
            StatRow(
                label = "Due (now/day/week/month)",
                value = "${statistics.dueByTime["now"] ?: 0}/${statistics.dueByTime["day"] ?: 0}/${statistics.dueByTime["week"] ?: 0}/${statistics.dueByTime["month"] ?: 0}"
            )
        }

        // Review rating distribution
        statistics.ratingDistribution.takeIf { it.isNotEmpty() }?.let {
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                Text(
                    text = "Review Ratings",
                    style = MaterialTheme.typography.labelLarge,
                    fontWeight = FontWeight.Bold,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                RatingDistributionChart(ratingDistribution = it)
            }
        }
    }
}

@Composable
private fun ErrorContent(errorMessage: String?, onRetry: () -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Icon(
            imageVector = Icons.Default.Error,
            contentDescription = null,
            modifier = Modifier.size(96.dp),
            tint = MaterialTheme.colorScheme.error
        )

        Spacer(modifier = Modifier.height(24.dp))

        Text(
            text = "Something went wrong",
            style = MaterialTheme.typography.headlineMedium,
            fontWeight = FontWeight.Bold
        )

        Spacer(modifier = Modifier.height(16.dp))

        errorMessage?.let {
            Text(
                text = it,
                style = MaterialTheme.typography.bodyMedium,
                textAlign = TextAlign.Center,
                color = MaterialTheme.colorScheme.error
            )
        }

        Spacer(modifier = Modifier.height(32.dp))

        Button(onClick = onRetry) {
            Text("Try Again")
        }
    }
}

/**
 * Context chip that displays the active quiz context filter.
 * Allows user to clear the context filter.
 */
@Composable
private fun ContextChip(
    context: computer.whatthefuck.arcology.domain.QuizContext,
    onClear: () -> Unit
) {
    val (icon, label) = when (context.type) {
        computer.whatthefuck.arcology.domain.QuizContextType.TAG -> Pair(Icons.Default.Tag, "Tag: ${context.value}")
        computer.whatthefuck.arcology.domain.QuizContextType.BACKLINK -> Pair(Icons.Default.Link, "Backlink: ${context.value}")
        computer.whatthefuck.arcology.domain.QuizContextType.ALL -> Pair(Icons.Default.FilterNone, "All Cards")
    }

    OutlinedButton(
        onClick = onClear,
        colors = ButtonDefaults.outlinedButtonColors(
            containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
        ),
        shape = RoundedCornerShape(16.dp),
        modifier = Modifier.padding(horizontal = 8.dp)
    ) {
        Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp))
        Spacer(modifier = Modifier.width(4.dp))
        Text(
            text = label,
            style = MaterialTheme.typography.labelMedium
        )
        Spacer(modifier = Modifier.width(8.dp))
        Icon(
            Icons.Default.Close,
            contentDescription = "Clear context",
            modifier = Modifier.size(14.dp)
        )
    }
}

/**
 * Context selector dropdown menu for choosing quiz context filter.
 */
@Composable
private fun ContextSelector(
    context: computer.whatthefuck.arcology.domain.QuizContext?,
    availableTags: List<computer.whatthefuck.arcology.app.viewmodel.TagWithCount>,
    onOpenTagSelector: () -> Unit,
    onOpenNodeSelector: () -> Unit
) {
    var isExpanded by remember { mutableStateOf(false) }

    OutlinedButton(
        onClick = { isExpanded = true },
        modifier = Modifier.fillMaxWidth(1.0f)
    ) {
        Icon(
            Icons.Default.FilterList,
            contentDescription = null,
            modifier = Modifier.size(16.dp)
        )
        Spacer(modifier = Modifier.width(8.dp))
        Text(
            text = when (context) {
                null -> "All Cards"
                else -> when (context.type) {
                    computer.whatthefuck.arcology.domain.QuizContextType.TAG -> "Tag: ${context.value}"
                    computer.whatthefuck.arcology.domain.QuizContextType.BACKLINK -> "Backlink"
                    computer.whatthefuck.arcology.domain.QuizContextType.ALL -> "All Cards"
                }
            },
            style = MaterialTheme.typography.labelMedium
        )
        Spacer(modifier = Modifier.width(8.dp))
        Icon(
            Icons.Default.ArrowDropDown,
            contentDescription = null,
            modifier = Modifier.size(16.dp)
        )
    }

    DropdownMenu(
        expanded = isExpanded,
        onDismissRequest = { isExpanded = false },
        modifier = Modifier.width(200.dp)
    ) {
        DropdownMenuItem(
            text = { Text("All Cards") },
            onClick = {
                isExpanded = false
                onOpenTagSelector() // Default to tag selector
            },
            leadingIcon = {
                Icon(
                    Icons.Default.FilterNone,
                    contentDescription = null,
                    modifier = Modifier.size(16.dp)
                )
            }
        )
        DropdownMenuItem(
            text = { Text("Filter by Tag") },
            onClick = {
                isExpanded = false
                onOpenTagSelector()
            },
            leadingIcon = {
                Icon(
                    Icons.Default.Tag,
                    contentDescription = null,
                    modifier = Modifier.size(16.dp)
                )
            }
        )
        DropdownMenuItem(
            text = { Text("Filter by Backlink") },
            onClick = {
                isExpanded = false
                onOpenNodeSelector()
            },
            leadingIcon = {
                Icon(
                    Icons.Default.Link,
                    contentDescription = null,
                    modifier = Modifier.size(16.dp)
                )
            }
        )
    }
}

/**
 * Dialog for selecting a tag to use as quiz context filter.
 * Shows all available tags with their flashcard counts.
 */
@Composable
fun TagSelectorDialog(
    availableTags: List<computer.whatthefuck.arcology.app.viewmodel.TagWithCount>,
    onTagSelected: (String) -> Unit,
    onDismiss: () -> Unit
) {
    var searchQuery by remember { mutableStateOf("") }

    val filteredTags = remember(availableTags, searchQuery) {
        availableTags.filter { tag ->
            tag.tag.contains(searchQuery, ignoreCase = true)
        }
    }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = {
            Text("Select Tag")
        },
        text = {
            Column(
                modifier = Modifier
                    .heightIn(max = 300.dp)
                    .verticalScroll(rememberScrollState())
            ) {
                // Search box
                OutlinedTextField(
                    value = searchQuery,
                    onValueChange = { searchQuery = it },
                    label = { Text("Search tags") },
                    modifier = Modifier.fillMaxWidth(),
                    singleLine = true
                )

                Spacer(modifier = Modifier.height(16.dp))

                // Tag list
                if (filteredTags.isEmpty()) {
                    Text(
                        text = "No tags found",
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                } else {
                    filteredTags.forEach { tag ->
                        ListItem(
                            headlineContent = { Text(tag.tag) },
                            supportingContent = {
                                Text("${tag.count} cards")
                            },
                            leadingContent = {
                                Icon(
                                    Icons.Default.Tag,
                                    contentDescription = null,
                                    modifier = Modifier.size(20.dp)
                                )
                            },
                            modifier = Modifier
                                .clickable { onTagSelected(tag.tag) }
                                .padding(horizontal = 8.dp)
                        )
                    }
                }
            }
        },
        confirmButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancel")
            }
        }
    )
}

/**
 * Dialog for selecting a node to use as backlink context filter.
 * Shows nodes that have flashcards that link to them.
 */
@Composable
fun NodeSelectorDialog(
    searchViewModel: computer.whatthefuck.arcology.app.viewmodel.SearchViewModel,
    onNodeSelected: (String) -> Unit,
    onDismiss: () -> Unit
) {
    var searchQuery by remember { mutableStateOf("") }

    // Get all nodes with backlinks from flashcards
    val allNodesWithBacklinks = searchViewModel.getNodesWithBacklinksFromFlashcards()

    // Filter nodes by search query
    val filteredNodes = remember(allNodesWithBacklinks, searchQuery) {
        if (searchQuery.isBlank()) {
            allNodesWithBacklinks
        } else {
            allNodesWithBacklinks.filter { node ->
                node.title.contains(searchQuery, ignoreCase = true) ||
                    node.nodeId.contains(searchQuery, ignoreCase = true)
            }
        }
    }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = {
            Text("Select Node for Backlink Quiz")
        },
        text = {
            Column(
                modifier = Modifier
                    .heightIn(max = 300.dp)
                    .verticalScroll(rememberScrollState())
            ) {
                Text(
                    text = "Nodes with backlinks from flashcards:",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )

                Spacer(modifier = Modifier.height(8.dp))

                OutlinedTextField(
                    value = searchQuery,
                    onValueChange = { searchQuery = it },
                    label = { Text("Search nodes") },
                    modifier = Modifier.fillMaxWidth(),
                    singleLine = true
                )

                Spacer(modifier = Modifier.height(16.dp))

                if (filteredNodes.isEmpty()) {
                    if (searchQuery.isBlank()) {
                        Text(
                            text = "No nodes with backlinks found.",
                            style = MaterialTheme.typography.bodyMedium,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                    } else {
                        Text(
                            text = "No nodes match \"${searchQuery}\"",
                            style = MaterialTheme.typography.bodyMedium,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                    }
                } else {
                    filteredNodes.forEach { node ->
                        ListItem(
                            headlineContent = { Text(node.title) },
                            supportingContent = {
                                Text("ID: ${node.nodeId}")
                            },
                            leadingContent = {
                                Icon(
                                    Icons.Default.Link,
                                    contentDescription = null,
                                    modifier = Modifier.size(20.dp)
                                )
                            },
                            modifier = Modifier
                                .clickable {
                                    onNodeSelected(node.nodeId)
                                    onDismiss()
                                }
                                .padding(horizontal = 8.dp)
                        )
                    }
                }
            }
        },
        confirmButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancel")
            }
        }
    )
}

Composed Tangle Targets

kotlin#+name: quiz-screen-file:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/QuizScreen.kt:noweb yes
<<quiz-screen-entry>>

<<quiz-screen-content>>

<<quiz-screen-card>>

<<quiz-screen-states>>

QuizSettingsScreen — daily reminders, session limits, display preferences

QuizSettingsScreen is a settings page with three sections: Daily Reminder (toggle for scheduled QuizReminderWorker with time picker), Session Settings (max cards per session slider 10–100, progress bar toggle), and Card Display (auto-flip delay slider 0–30s). Settings are persisted immediately via LaunchedEffect on each value change. The screen uses custom SwitchPreference and SliderPreference composables with Row layouts rather than the Material3 Settings components — the Material3 settings composables weren't available when this was written.

kotlin#+name: quiz-settings-screen-file:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/QuizSettingsScreen.kt
@file:OptIn(ExperimentalMaterial3Api::class)

package computer.whatthefuck.arcology.app.ui.screens

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.app.notification.QuizNotificationManager
import computer.whatthefuck.arcology.app.viewmodel.QuizViewModel

/**
 * Settings screen for quiz/spaced repetition configuration.
 * Allows users to configure daily reminders, session limits, and other preferences.
 */
@Composable
fun QuizSettingsScreen(
    appPreferences: AppPreferences,
    notificationManager: QuizNotificationManager,
    viewModel: QuizViewModel,
    onNavigateUp: () -> Unit
) {
    // Daily reminder state
    val dailyReminderEnabled = remember {
        mutableStateOf(isDailyReminderScheduled(notificationManager))
    }
    val reminderHour = remember { mutableStateOf(9) } // Default to 9 AM
    val reminderMinute = remember { mutableStateOf(0) }

    // Session settings state - initialized from preferences
    val maxCardsPerSession = remember {
        mutableStateOf(appPreferences.getMaxCardsPerSession())
    }
    val showProgressBar = remember {
        mutableStateOf(appPreferences.isProgressBarEnabled())
    }
    val autoFlipDelay = remember {
        mutableStateOf(appPreferences.getAutoFlipDelay())
    }

    // Load current settings from notification manager
    LaunchedEffect(notificationManager) {
        dailyReminderEnabled.value = isDailyReminderScheduled(notificationManager)
    }

    // Save settings when they change
    LaunchedEffect(maxCardsPerSession.value) {
        appPreferences.setMaxCardsPerSession(maxCardsPerSession.value)
    }
    LaunchedEffect(showProgressBar.value) {
        appPreferences.setProgressBarEnabled(showProgressBar.value)
    }
    LaunchedEffect(autoFlipDelay.value) {
        appPreferences.setAutoFlipDelay(autoFlipDelay.value)
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Quiz Settings") },
                navigationIcon = {
                    IconButton(onClick = onNavigateUp) {
                        Icon(Icons.Default.ArrowBack, contentDescription = "Back")
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .padding(16.dp)
                .verticalScroll(rememberScrollState()),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = "Daily Reminder",
                style = MaterialTheme.typography.titleLarge,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 16.dp)
            )

            // Daily reminder toggle
            SwitchPreference(
                icon = Icons.Default.Alarm,
                title = "Enable Daily Reminder",
                summary = if (dailyReminderEnabled.value) {
                    "Remind at ${reminderHour.value}:${reminderMinute.value.toString().padStart(2, '0')}"
                } else {
                    "Disabled"
                },
                checked = dailyReminderEnabled.value,
                onCheckedChange = { checked ->
                    dailyReminderEnabled.value = checked
                    if (checked) {
                        // Schedule daily reminder at the configured time
                        notificationManager.scheduleDailyReminder(reminderHour.value, reminderMinute.value)
                    } else {
                        notificationManager.cancelDailyReminder()
                    }
                }
            )

            Spacer(modifier = Modifier.height(24.dp))

            Text(
                text = "Session Settings",
                style = MaterialTheme.typography.titleLarge,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 16.dp)
            )

            // Max cards per session slider
            SliderPreference(
                icon = Icons.Default.Numbers,
                title = "Max Cards Per Session",
                summary = "${maxCardsPerSession.value} cards",
                value = maxCardsPerSession.value.toFloat(),
                valueRange = 10f..100f,
                onValueChange = { maxCardsPerSession.value = it.toInt() }
            )

            // Progress bar toggle
            SwitchPreference(
                icon = Icons.Default.TrendingUp,
                title = "Show Progress Bar",
                summary = "Display progress during quiz sessions",
                checked = showProgressBar.value,
                onCheckedChange = { showProgressBar.value = it }
            )

            Spacer(modifier = Modifier.height(24.dp))

            Text(
                text = "Card Display",
                style = MaterialTheme.typography.titleLarge,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(bottom = 16.dp)
            )

            // Auto-flip delay slider
            SliderPreference(
                icon = Icons.Default.AutoDelete,
                title = "Auto-Flip Delay",
                summary = when (autoFlipDelay.value) {
                    0 -> "Disabled"
                    else -> "${autoFlipDelay.value}s"
                },
                value = autoFlipDelay.value.toFloat(),
                valueRange = 0f..30f,
                onValueChange = { autoFlipDelay.value = it.toInt() }
            )
        }
    }
}

@Composable
private fun SwitchPreference(
    icon: androidx.compose.ui.graphics.vector.ImageVector,
    title: String,
    summary: String,
    checked: Boolean,
    onCheckedChange: ((Boolean) -> Unit)
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 8.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Row(
            modifier = Modifier.weight(1f),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(
                imageVector = icon,
                contentDescription = null,
                tint = MaterialTheme.colorScheme.primary
            )

            Column {
                Text(
                    text = title,
                    style = MaterialTheme.typography.bodyLarge,
                    fontWeight = FontWeight.Medium
                )
                Text(
                    text = summary,
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }

        Switch(
            checked = checked,
            onCheckedChange = onCheckedChange
        )
    }
}

@Composable
private fun SliderPreference(
    icon: androidx.compose.ui.graphics.vector.ImageVector,
    title: String,
    summary: String,
    value: Float,
    valueRange: ClosedFloatingPointRange<Float>,
    onValueChange: (Float) -> Unit
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 8.dp)
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(
                imageVector = icon,
                contentDescription = null,
                tint = MaterialTheme.colorScheme.primary
            )

            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = title,
                    style = MaterialTheme.typography.bodyLarge,
                    fontWeight = FontWeight.Medium
                )
                Text(
                    text = summary,
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }

        Spacer(modifier = Modifier.height(8.dp))

        Slider(
            value = value,
            valueRange = valueRange,
            onValueChange = onValueChange,
            modifier = Modifier.fillMaxWidth()
        )
    }
}

private fun isDailyReminderScheduled(notificationManager: QuizNotificationManager): Boolean {
    return notificationManager.isDailyReminderScheduled()
}

Related Modules

  • quiz/viewmodel.org — QuizViewModel (state machine consumed by QuizScreen)

  • quiz/flashcard.org — FlashcardService, ReviewService (indirect via ViewModel)

  • quiz/notifications.org — QuizNotificationManager (consumed by QuizSettingsScreen)

  • editor/renderer.org — OrgDocumentRenderer, ClozeState, RenderMode (card content rendering)

  • app/bootstrap.org — Koin module (provides QuizViewModel)

  • find/search-screen.org — SearchViewModel (provides availableTags and getNodesWithBacklinksFromFlashcards)