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).
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.
@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
)
}
}
}Quiz card composables — front, back, header, footer, rating
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.
@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.
@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
<<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.
@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)