Small UI rework, show criteria grades when writing global feedback, export evaluation per group per assignment to MD

This commit is contained in:
2025-06-12 23:03:35 +02:00
parent 0d6f361a45
commit eca161b251
6 changed files with 220 additions and 45 deletions

View File

@ -36,6 +36,10 @@ kotlin {
implementation(libs.sqlite) implementation(libs.sqlite)
implementation(libs.material3.desktop) implementation(libs.material3.desktop)
implementation(libs.rtfield) implementation(libs.rtfield)
implementation(libs.filekit.core)
implementation(libs.filekit.dialogs)
implementation(libs.filekit.dialogs.compose)
implementation(libs.filekit.coil)
} }
} }
} }
@ -50,6 +54,10 @@ compose.desktop {
packageName = "com.jaytux.grader" packageName = "com.jaytux.grader"
packageVersion = "1.0.0" packageVersion = "1.0.0"
includeAllModules = true includeAllModules = true
linux {
modules("jdk.security.auth")
}
} }
} }
} }

View File

@ -0,0 +1,90 @@
package com.jaytux.grader.data
import com.jaytux.grader.viewmodel.Assignment
import com.jaytux.grader.viewmodel.GroupAssignmentState
import io.github.vinceglb.filekit.PlatformFile
class MdBuilder {
private val content = StringBuilder()
fun appendHeader(text: String, level: Int = 1) {
require(level in 1..6) { "Header level must be between 1 and 6" }
content.appendLine()
content.appendLine("#".repeat(level) + " $text")
content.appendLine()
}
fun appendMd(text: String) { content.appendLine(text) }
fun appendParagraph(text: String, bold: Boolean = false, italic: Boolean = false) {
val formattedText = buildString {
if (bold) append("**")
if (italic) append("_")
append(text)
if (italic) append("_")
if (bold) append("**")
}
content.appendLine(formattedText)
content.appendLine()
}
fun build(): String = content.toString()
}
fun GroupAssignmentState.LocalGFeedback.exportTo(path: PlatformFile, assignment: GroupAssignment) {
val builder = MdBuilder()
builder.appendHeader("${assignment.name} Feedback for ${group.name}")
if(feedback.global != null && feedback.global.grade.isNotBlank()) {
val global = feedback.global.grade
builder.appendParagraph("Overall grade: ${feedback.global.grade}", true, true)
individuals.forEach { (student, it) ->
val (_, data) = it
if(data.global != null && data.global.grade.isNotBlank() && data.global.grade != global) {
builder.appendParagraph("${student.name} grade: ${data.global.grade}", true, true)
}
}
}
fun appendFeedback(heading: String, group: GroupAssignmentState.FeedbackEntry?, byStudent: List<Pair<Student, GroupAssignmentState.FeedbackEntry>>) {
if(group != null || byStudent.isNotEmpty()) {
builder.appendHeader(heading, 2)
if(group != null) {
if(group.grade.isNotBlank()) {
builder.appendParagraph("Group grade: ${group.grade}", true, true)
}
if(group.feedback.isNotBlank()) {
builder.appendMd(group.feedback)
}
}
byStudent.forEach { (student, it) ->
if(it.grade.isNotBlank() || it.feedback.isNotBlank()) builder.appendHeader(student.name, 3)
if(it.grade.isNotBlank()) {
builder.appendParagraph("Grade: ${it.grade}", true, true)
}
if(it.feedback.isNotBlank()) {
builder.appendMd(it.feedback)
}
}
}
}
appendFeedback("Overall Feedback", feedback.global,
individuals.mapNotNull { it.second.second.global?.let { g -> it.first to g } }
)
val criteria = (feedback.byCriterion.map { (c, _) -> c } +
individuals.flatMap { (_, it) -> it.second.byCriterion.map { (c, _) -> c } }).distinctBy { it.id.value }
criteria.forEach { c ->
appendFeedback(
c.name,
feedback.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry,
individuals.mapNotNull { (student, it) ->
val entry = it.second.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry
entry?.let { student to it }
}
)
}
path.file.writeText(builder.build())
}

View File

@ -4,10 +4,13 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import com.jaytux.grader.App import com.jaytux.grader.App
import com.jaytux.grader.data.Database import com.jaytux.grader.data.Database
import io.github.vinceglb.filekit.FileKit
fun main(){ fun main(){
Database.init() Database.init()
application { application {
FileKit.init(appId = "com.jaytux.grader")
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "Grader", title = "Grader",

View File

@ -18,11 +18,15 @@ import androidx.compose.ui.unit.dp
import com.jaytux.grader.data.GroupAssignmentCriterion import com.jaytux.grader.data.GroupAssignmentCriterion
import com.jaytux.grader.data.SoloAssignmentCriterion import com.jaytux.grader.data.SoloAssignmentCriterion
import com.jaytux.grader.data.Student import com.jaytux.grader.data.Student
import com.jaytux.grader.data.exportTo
import com.jaytux.grader.maxN
import com.jaytux.grader.viewmodel.GroupAssignmentState import com.jaytux.grader.viewmodel.GroupAssignmentState
import com.jaytux.grader.viewmodel.PeerEvaluationState import com.jaytux.grader.viewmodel.PeerEvaluationState
import com.jaytux.grader.viewmodel.SoloAssignmentState import com.jaytux.grader.viewmodel.SoloAssignmentState
import com.mohamedrejeb.richeditor.model.rememberRichTextState import com.mohamedrejeb.richeditor.model.rememberRichTextState
import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor
import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
@Composable @Composable
@ -182,10 +186,37 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG
var critIdx by remember(fdbk) { mutableStateOf(0) } var critIdx by remember(fdbk) { mutableStateOf(0) }
val criteria by state.criteria.entities val criteria by state.criteria.entities
val suggestions by state.autofill.entities val suggestions by state.autofill.entities
val exporting by remember { mutableStateOf(false) }
val onSave = { grade: String, fdbk: String ->
when {
studentIdx == 0 && critIdx == 0 -> state.upsertGroupFeedback(group, fdbk, grade)
studentIdx == 0 && critIdx != 0 -> state.upsertGroupFeedback(group, fdbk, grade, criteria[critIdx - 1])
studentIdx != 0 && critIdx == 0 -> state.upsertIndividualFeedback(individual[studentIdx - 1].first, group, fdbk, grade)
else -> state.upsertIndividualFeedback(individual[studentIdx - 1].first, group, fdbk, grade, criteria[critIdx - 1])
}
}
val scope = rememberCoroutineScope()
val exporter = rememberFileSaverLauncher { file ->
file?.let {
scope.launch { fdbk.exportTo(it, state.assignment) }
}
}
val critGrade: (Int) -> String? = { crit: Int ->
when {
studentIdx == 0 && crit == 0 -> feedback.global?.grade?.ifBlank { null }
studentIdx == 0 && crit != 0 -> feedback.byCriterion[crit - 1].entry?.grade?.ifBlank { null }
studentIdx != 0 && crit == 0 -> individual[studentIdx - 1].second.second.global?.grade?.ifBlank { null }
else -> individual[studentIdx - 1].second.second.byCriterion[crit - 1].entry?.grade?.ifBlank { null }
}.also { println("Mapping criterion #${crit} to grade ${it}") }
}
Row { Row {
Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) { Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) {
LazyColumn(Modifier.fillMaxHeight().padding(10.dp)) { Column(Modifier.padding(10.dp)) {
LazyColumn(Modifier.weight(1f)) {
item { item {
Surface( Surface(
Modifier.fillMaxWidth().clickable { studentIdx = 0 }, Modifier.fillMaxWidth().clickable { studentIdx = 0 },
@ -207,16 +238,36 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG
} }
} }
} }
Button(
{ exporter.launch("${state.assignment.name} (${fdbk.group.name})", "md") },
Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()
) {
Text("Export group feedback")
}
}
} }
val onSave = { grade: String, fdbk: String -> Column(Modifier.weight(0.75f).padding(10.dp)) {
when { TabRow(critIdx) {
studentIdx == 0 && critIdx == 0 -> state.upsertGroupFeedback(group, fdbk, grade) Tab(critIdx == 0, { critIdx = 0 }) {
studentIdx == 0 && critIdx != 0 -> state.upsertGroupFeedback(group, fdbk, grade, criteria[critIdx - 1]) Text(
studentIdx != 0 && critIdx == 0 -> state.upsertIndividualFeedback(individual[studentIdx - 1].first, group, fdbk, grade) "General feedback",
else -> state.upsertIndividualFeedback(individual[studentIdx - 1].first, group, fdbk, grade, criteria[critIdx - 1]) Modifier.padding(5.dp),
fontStyle = FontStyle.Italic
)
}
criteria.forEachIndexed { i, c ->
Tab(critIdx == i + 1, { critIdx = i + 1 }) {
Text(
c.name,
Modifier.padding(5.dp)
)
} }
} }
}
Spacer(Modifier.height(5.dp))
groupFeedbackPane( groupFeedbackPane(
criteria, critIdx, { critIdx = it }, criteria, critIdx, { critIdx = it },
@ -226,10 +277,12 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG
studentIdx != 0 && critIdx == 0 -> individual[studentIdx - 1].second.second.global studentIdx != 0 && critIdx == 0 -> individual[studentIdx - 1].second.second.global
else -> individual[studentIdx - 1].second.second.byCriterion[critIdx - 1].entry else -> individual[studentIdx - 1].second.second.byCriterion[critIdx - 1].entry
}, },
suggestions, onSave, Modifier.weight(0.75f).padding(10.dp), suggestions, onSave,
if(critIdx == 0 && criteria.isNotEmpty()) criteria.mapIndexed { idx, it -> it.name to critGrade(idx + 1) } else null,
key = studentIdx to critIdx key = studentIdx to critIdx
) )
} }
}
} }
@Composable @Composable
@ -240,6 +293,7 @@ fun groupFeedbackPane(
rawFeedback: GroupAssignmentState.FeedbackEntry?, rawFeedback: GroupAssignmentState.FeedbackEntry?,
autofill: List<String>, autofill: List<String>,
onSave: (String, String) -> Unit, onSave: (String, String) -> Unit,
critGrades: List<Pair<String, String?>>? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
key: Any? = null key: Any? = null
) { ) {
@ -252,7 +306,7 @@ fun groupFeedbackPane(
Column(modifier) { Column(modifier) {
Row { Row {
Text("Overall grade: ", Modifier.align(Alignment.CenterVertically)) Text("Grade: ", Modifier.align(Alignment.CenterVertically))
OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f)) OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f))
Spacer(Modifier.weight(0.6f)) Spacer(Modifier.weight(0.6f))
Button( Button(
@ -262,14 +316,29 @@ fun groupFeedbackPane(
Text("Save") Text("Save")
} }
} }
TabRow(currentCriterion) {
Tab(currentCriterion == 0, { onSelectCriterion(0) }) { Text("General feedback", Modifier.padding(5.dp), fontStyle = FontStyle.Italic) }
criteria.forEachIndexed { i, c ->
Tab(currentCriterion == i + 1, { onSelectCriterion(i + 1) }) { Text(c.name, Modifier.padding(5.dp)) }
}
}
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))
RichTextField(feedback, Modifier.fillMaxWidth().weight(1f)) { Text("Feedback") } Row {
RichTextField(feedback, outerModifier = Modifier.weight(0.7f).fillMaxHeight()) { Text("Feedback") }
critGrades?.let { grades ->
Spacer(Modifier.width(10.dp))
LazyColumn(Modifier.weight(0.3f)) {
item {
Text("Criteria grades", Modifier.padding(5.dp), style = MaterialTheme.typography.headlineMedium)
}
items(grades) { (crit, grade) ->
Column {
Text(crit, Modifier.padding(5.dp), fontWeight = FontWeight.Bold)
Row {
Spacer(Modifier.width(5.dp))
if(grade == null) Text("(no grade yet)", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
else Text(grade, Modifier.padding(5.dp))
}
Spacer(Modifier.width(10.dp))
}
}
}
}
}
} }
} }

View File

@ -243,8 +243,8 @@ fun RichTextStyleButton(
@Composable @Composable
fun RichTextField( fun RichTextField(
state: RichTextState, state: RichTextState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier.fillMaxSize(),
buttonsModifier: Modifier = Modifier, buttonsModifier: Modifier = Modifier.fillMaxWidth(),
outerModifier: Modifier = Modifier, outerModifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null label: @Composable (() -> Unit)? = null
) = Column(outerModifier) { ) = Column(outerModifier) {

View File

@ -1,6 +1,6 @@
[versions] [versions]
androidx-lifecycle = "2.8.4" androidx-lifecycle = "2.8.4"
compose-multiplatform = "1.7.0" compose-multiplatform = "1.8.1"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.1.0" kotlin = "2.1.0"
kotlinx-coroutines = "1.10.1" kotlinx-coroutines = "1.10.1"
@ -9,6 +9,7 @@ material3 = "1.7.3"
ui-android = "1.7.8" ui-android = "1.7.8"
foundation-layout-android = "1.7.8" foundation-layout-android = "1.7.8"
rtf = "1.0.0-rc11" rtf = "1.0.0-rc11"
filekit = "0.10.0-beta04"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@ -30,6 +31,10 @@ material-icons = { group = "org.jetbrains.compose.material", name = "material-ic
androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "ui-android" } androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "ui-android" }
androidx-foundation-layout-android = { group = "androidx.compose.foundation", name = "foundation-layout-android", version.ref = "foundation-layout-android" } androidx-foundation-layout-android = { group = "androidx.compose.foundation", name = "foundation-layout-android", version.ref = "foundation-layout-android" }
rtfield = { group = "com.mohamedrejeb.richeditor", name = "richeditor-compose", version.ref = "rtf" } rtfield = { group = "com.mohamedrejeb.richeditor", name = "richeditor-compose", version.ref = "rtf" }
filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" }
filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" }
filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" }
filekit-coil = { group = "io.github.vinceglb", name = "filekit-coil", version.ref = "filekit" }
[plugins] [plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }