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.material3.desktop)
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"
packageVersion = "1.0.0"
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 com.jaytux.grader.App
import com.jaytux.grader.data.Database
import io.github.vinceglb.filekit.FileKit
fun main(){
Database.init()
application {
FileKit.init(appId = "com.jaytux.grader")
Window(
onCloseRequest = ::exitApplication,
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.SoloAssignmentCriterion
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.PeerEvaluationState
import com.jaytux.grader.viewmodel.SoloAssignmentState
import com.mohamedrejeb.richeditor.model.rememberRichTextState
import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor
import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime
@Composable
@ -182,10 +186,37 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG
var critIdx by remember(fdbk) { mutableStateOf(0) }
val criteria by state.criteria.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 {
Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) {
LazyColumn(Modifier.fillMaxHeight().padding(10.dp)) {
Column(Modifier.padding(10.dp)) {
LazyColumn(Modifier.weight(1f)) {
item {
Surface(
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 ->
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])
Column(Modifier.weight(0.75f).padding(10.dp)) {
TabRow(critIdx) {
Tab(critIdx == 0, { critIdx = 0 }) {
Text(
"General feedback",
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(
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
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
)
}
}
}
@Composable
@ -240,6 +293,7 @@ fun groupFeedbackPane(
rawFeedback: GroupAssignmentState.FeedbackEntry?,
autofill: List<String>,
onSave: (String, String) -> Unit,
critGrades: List<Pair<String, String?>>? = null,
modifier: Modifier = Modifier,
key: Any? = null
) {
@ -252,7 +306,7 @@ fun groupFeedbackPane(
Column(modifier) {
Row {
Text("Overall grade: ", Modifier.align(Alignment.CenterVertically))
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f))
Spacer(Modifier.weight(0.6f))
Button(
@ -262,14 +316,29 @@ fun groupFeedbackPane(
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))
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
fun RichTextField(
state: RichTextState,
modifier: Modifier = Modifier,
buttonsModifier: Modifier = Modifier,
modifier: Modifier = Modifier.fillMaxSize(),
buttonsModifier: Modifier = Modifier.fillMaxWidth(),
outerModifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null
) = Column(outerModifier) {

View File

@ -1,6 +1,6 @@
[versions]
androidx-lifecycle = "2.8.4"
compose-multiplatform = "1.7.0"
compose-multiplatform = "1.8.1"
junit = "4.13.2"
kotlin = "2.1.0"
kotlinx-coroutines = "1.10.1"
@ -9,6 +9,7 @@ material3 = "1.7.3"
ui-android = "1.7.8"
foundation-layout-android = "1.7.8"
rtf = "1.0.0-rc11"
filekit = "0.10.0-beta04"
[libraries]
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-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" }
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]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }