diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ca8648c..68d12ab 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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") + } } } } diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Exporter.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Exporter.kt new file mode 100644 index 0000000..3e02a8f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Exporter.kt @@ -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>) { + 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()) +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/main.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/main.kt index 44df480..0d4cd5d 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/main.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/main.kt @@ -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", diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt index c7cb088..4ddc5a7 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt @@ -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,53 +186,102 @@ 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)) { - item { - Surface( - Modifier.fillMaxWidth().clickable { studentIdx = 0 }, - tonalElevation = if (studentIdx == 0) 50.dp else 0.dp, - shape = MaterialTheme.shapes.medium - ) { - Text("Group feedback", Modifier.padding(5.dp), fontStyle = FontStyle.Italic) + Column(Modifier.padding(10.dp)) { + LazyColumn(Modifier.weight(1f)) { + item { + Surface( + Modifier.fillMaxWidth().clickable { studentIdx = 0 }, + tonalElevation = if (studentIdx == 0) 50.dp else 0.dp, + shape = MaterialTheme.shapes.medium + ) { + Text("Group feedback", Modifier.padding(5.dp), fontStyle = FontStyle.Italic) + } + } + + itemsIndexed(individual.toList()) { i, (student, details) -> + val (role, _) = details + Surface( + Modifier.fillMaxWidth().clickable { studentIdx = i + 1 }, + tonalElevation = if (studentIdx == i + 1) 50.dp else 0.dp, + shape = MaterialTheme.shapes.medium + ) { + Text("${student.name} (${role ?: "no role"})", Modifier.padding(5.dp)) + } } } - itemsIndexed(individual.toList()) { i, (student, details) -> - val (role, _) = details - Surface( - Modifier.fillMaxWidth().clickable { studentIdx = i + 1 }, - tonalElevation = if (studentIdx == i + 1) 50.dp else 0.dp, - shape = MaterialTheme.shapes.medium - ) { - Text("${student.name} (${role ?: "no role"})", Modifier.padding(5.dp)) - } + 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) + ) + } + } } - } - groupFeedbackPane( - criteria, critIdx, { critIdx = it }, - when { - studentIdx == 0 && critIdx == 0 -> feedback.global - studentIdx == 0 && critIdx != 0 -> feedback.byCriterion[critIdx - 1].entry - 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), - key = studentIdx to critIdx - ) + Spacer(Modifier.height(5.dp)) + + groupFeedbackPane( + criteria, critIdx, { critIdx = it }, + when { + studentIdx == 0 && critIdx == 0 -> feedback.global + studentIdx == 0 && critIdx != 0 -> feedback.byCriterion[critIdx - 1].entry + studentIdx != 0 && critIdx == 0 -> individual[studentIdx - 1].second.second.global + else -> individual[studentIdx - 1].second.second.byCriterion[critIdx - 1].entry + }, + suggestions, onSave, + if(critIdx == 0 && criteria.isNotEmpty()) criteria.mapIndexed { idx, it -> it.name to critGrade(idx + 1) } else null, + key = studentIdx to critIdx + ) + } } } @@ -240,6 +293,7 @@ fun groupFeedbackPane( rawFeedback: GroupAssignmentState.FeedbackEntry?, autofill: List, onSave: (String, String) -> Unit, + critGrades: List>? = 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)) + 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)) + } + } + } } } - Spacer(Modifier.height(5.dp)) - RichTextField(feedback, Modifier.fillMaxWidth().weight(1f)) { Text("Feedback") } } } diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/RichText.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/RichText.kt index 838fa87..d4c878a 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/RichText.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/RichText.kt @@ -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) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0408ea0..914e2fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }