Compare commits

..

10 Commits

12 changed files with 337 additions and 142 deletions

View File

@ -31,10 +31,15 @@ kotlin {
implementation(libs.exposed.core)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.dao)
implementation(libs.exposed.migration)
implementation(libs.exposed.kotlin.datetime)
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)
}
}
}
@ -49,6 +54,10 @@ compose.desktop {
packageName = "com.jaytux.grader"
packageVersion = "1.0.0"
includeAllModules = true
linux {
modules("jdk.security.auth")
}
}
}
}

View File

@ -56,6 +56,7 @@ object GroupAssignments : UUIDTable("grpAssgmts") {
val name = varchar("name", 50)
val assignment = text("assignment")
val deadline = datetime("deadline")
val globalCriterion = reference("global_crit", GroupAssignmentCriteria.id)
}
object GroupAssignmentCriteria : UUIDTable("grpAsCr") {
@ -70,6 +71,7 @@ object SoloAssignments : UUIDTable("soloAssgmts") {
val name = varchar("name", 50)
val assignment = text("assignment")
val deadline = datetime("deadline")
val globalCriterion = reference("global_crit", SoloAssignmentCriteria.id)
}
object SoloAssignmentCriteria : UUIDTable("soloAsCr") {
@ -86,7 +88,7 @@ object PeerEvaluations : UUIDTable("peerEvals") {
object GroupFeedbacks : CompositeIdTable("grpFdbks") {
val assignmentId = reference("group_assignment_id", GroupAssignments.id)
val criterionId = reference("criterion_id", GroupAssignmentCriteria.id).nullable()
val criterionId = reference("criterion_id", GroupAssignmentCriteria.id)
val groupId = reference("group_id", Groups.id)
val feedback = text("feedback")
val grade = varchar("grade", 32)
@ -96,7 +98,7 @@ object GroupFeedbacks : CompositeIdTable("grpFdbks") {
object IndividualFeedbacks : CompositeIdTable("indivFdbks") {
val assignmentId = reference("group_assignment_id", GroupAssignments.id)
val criterionId = reference("criterion_id", GroupAssignmentCriteria.id).nullable()
val criterionId = reference("criterion_id", GroupAssignmentCriteria.id)
val groupId = reference("group_id", Groups.id)
val studentId = reference("student_id", Students.id)
val feedback = text("feedback")
@ -107,7 +109,7 @@ object IndividualFeedbacks : CompositeIdTable("indivFdbks") {
object SoloFeedbacks : CompositeIdTable("soloFdbks") {
val assignmentId = reference("solo_assignment_id", SoloAssignments.id)
val criterionId = reference("criterion_id", SoloAssignmentCriteria.id).nullable()
val criterionId = reference("criterion_id", SoloAssignmentCriteria.id)
val studentId = reference("student_id", Students.id)
val feedback = text("feedback")
val grade = varchar("grade", 32)

View File

@ -1,8 +1,12 @@
package com.jaytux.grader.data
import MigrationUtils
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
object Database {
val db by lazy {
@ -17,15 +21,19 @@ object Database {
StudentToGroupEvaluation
)
val addMissing = SchemaUtils.addMissingColumnsStatements(
val migrate = MigrationUtils.statementsRequiredForDatabaseMigration(
Courses, Editions, Groups,
Students, GroupStudents, EditionStudents,
GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria,
GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks,
PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation,
StudentToGroupEvaluation
StudentToGroupEvaluation,
withLogs = true
)
addMissing.forEach { exec(it) }
println(" --- Migration --- ")
migrate.forEach { println(it); exec(it) }
println(" --- End migration --- ")
}
actual
}

View File

@ -60,6 +60,7 @@ class GroupAssignment(id: EntityID<UUID>) : Entity<UUID>(id) {
var name by GroupAssignments.name
var assignment by GroupAssignments.assignment
var deadline by GroupAssignments.deadline
var globalCriterion by GroupAssignmentCriterion referencedOn GroupAssignments.globalCriterion
val criteria by GroupAssignmentCriterion referrersOn GroupAssignmentCriteria.assignmentId
}
@ -98,6 +99,7 @@ class SoloAssignment(id: EntityID<UUID>) : Entity<UUID>(id) {
var name by SoloAssignments.name
var assignment by SoloAssignments.assignment
var deadline by SoloAssignments.deadline
var globalCriterion by SoloAssignmentCriterion referencedOn SoloAssignments.globalCriterion
val criteria by SoloAssignmentCriterion referrersOn SoloAssignmentCriteria.assignmentId
}

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

@ -12,21 +12,22 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.jaytux.grader.data.GroupAssignment
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
import org.jetbrains.exposed.sql.transactions.inTopLevelTransaction
@Composable
fun GroupAssignmentView(state: GroupAssignmentState) {
@ -126,14 +127,7 @@ fun groupTaskWidget(
Row {
DateTimePicker(deadline, onSetDeadline)
}
RichTextStyleRow(state = updTask)
OutlinedRichTextEditor(
state = updTask,
modifier = Modifier.fillMaxWidth().weight(1f),
singleLine = false,
minLines = 5,
label = { Text("Task") }
)
RichTextField(updTask, Modifier.fillMaxWidth().weight(1f)) { Text("Task") }
CancelSaveRow(
true,
{ updTask.setMarkdown(taskMD) },
@ -188,18 +182,45 @@ fun groupTaskWidget(
@Composable
fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalGFeedback) {
val (group, feedback, individual) = fdbk
var idx by remember(fdbk) { mutableStateOf(0) }
var studentIdx by remember(fdbk) { mutableStateOf(0) }
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 { idx = 0 },
tonalElevation = if (idx == 0) 50.dp else 0.dp,
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)
@ -209,57 +230,59 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG
itemsIndexed(individual.toList()) { i, (student, details) ->
val (role, _) = details
Surface(
Modifier.fillMaxWidth().clickable { idx = i + 1 },
tonalElevation = if (idx == i + 1) 50.dp else 0.dp,
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))
}
}
}
}
val updateGrade = { grade: String ->
if(idx == 0) {
state.upsertGroupFeedback(group, feedback.global?.feedback ?: "", grade)
Button(
{ exporter.launch("${state.assignment.name} (${fdbk.group.name})", "md") },
Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()
) {
Text("Export group feedback")
}
else {
val ind = individual[idx - 1]
val glob = ind.second.second.global
state.upsertIndividualFeedback(ind.first, group, glob?.feedback ?: "", grade)
}
}
val updateFeedback = { fdbk: String ->
if(idx == 0) {
if(critIdx == 0) {
state.upsertGroupFeedback(group, fdbk, feedback.global?.grade ?: "", null)
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
)
}
else {
val current = feedback.byCriterion[critIdx - 1]
state.upsertGroupFeedback(group, fdbk, current.entry?.grade ?: "", current.criterion)
}
}
else {
val ind = individual[idx - 1]
if(critIdx == 0) {
val entry = ind.second.second
state.upsertIndividualFeedback(ind.first, group, fdbk, entry.global?.grade ?: "", null)
}
else {
val entry = ind.second.second.byCriterion[critIdx - 1]
state.upsertIndividualFeedback(ind.first, group, fdbk, entry.entry?.grade ?: "", entry.criterion)
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 }, feedback.global,
if(critIdx == 0) feedback.global else feedback.byCriterion[critIdx - 1].entry,
suggestions, updateGrade, updateFeedback, Modifier.weight(0.75f).padding(10.dp),
key = idx to critIdx
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
)
}
}
}
@Composable
@ -267,40 +290,54 @@ fun groupFeedbackPane(
criteria: List<GroupAssignmentCriterion>,
currentCriterion: Int,
onSelectCriterion: (Int) -> Unit,
globFeedback: GroupAssignmentState.FeedbackEntry?,
criterionFeedback: GroupAssignmentState.FeedbackEntry?,
rawFeedback: GroupAssignmentState.FeedbackEntry?,
autofill: List<String>,
onSetGrade: (String) -> Unit,
onSetFeedback: (String) -> Unit,
onSave: (String, String) -> Unit,
critGrades: List<Pair<String, String?>>? = null,
modifier: Modifier = Modifier,
key: Any? = null
) {
var grade by remember(globFeedback, key) { mutableStateOf(globFeedback?.grade ?: "") }
var feedback by remember(currentCriterion, criteria, criterionFeedback, key) { mutableStateOf(TextFieldValue(criterionFeedback?.feedback ?: "")) }
var grade by remember(rawFeedback, key) { mutableStateOf(rawFeedback?.grade ?: "") }
val feedback = rememberRichTextState()
LaunchedEffect(currentCriterion, criteria, rawFeedback, key) {
feedback.setMarkdown(rawFeedback?.feedback ?: "")
}
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(
{ onSetGrade(grade); onSetFeedback(feedback.text) },
Modifier.weight(0.2f).align(Alignment.CenterVertically),
enabled = grade.isNotBlank() || feedback.text.isNotBlank()
{ onSave(grade, feedback.toMarkdown()) },
Modifier.weight(0.2f).align(Alignment.CenterVertically)
) {
Text("Save")
}
}
TabRow(currentCriterion) {
Tab(currentCriterion == 0, { onSelectCriterion(0) }) { Text("General feedback", fontStyle = FontStyle.Italic) }
criteria.forEachIndexed { i, c ->
Tab(currentCriterion == i + 1, { onSelectCriterion(i + 1) }) { Text(c.name) }
}
}
Spacer(Modifier.height(5.dp))
AutocompleteLineField(
feedback, { feedback = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") }
) { filter ->
autofill.filter { x -> x.trim().startsWith(filter.trim()) }
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))
}
}
}
}
}
}
}
@ -480,16 +517,19 @@ fun soloFeedbackPane(
key: Any? = null
) {
var grade by remember(globFeedback, key) { mutableStateOf(globFeedback?.grade ?: "") }
var feedback by remember(currentCriterion, criteria, key) { mutableStateOf(TextFieldValue(criterionFeedback?.feedback ?: "")) }
val feedback = rememberRichTextState()
LaunchedEffect(currentCriterion, criteria, criterionFeedback, key) {
feedback.setMarkdown(criterionFeedback?.feedback ?: "")
}
Column(modifier) {
Row {
Text("Overall grade: ", Modifier.align(Alignment.CenterVertically))
OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f))
Spacer(Modifier.weight(0.6f))
Button(
{ onSetGrade(grade); onSetFeedback(feedback.text) },
Modifier.weight(0.2f).align(Alignment.CenterVertically),
enabled = grade.isNotBlank() || feedback.text.isNotBlank()
{ onSetGrade(grade); onSetFeedback(feedback.toMarkdown()) },
Modifier.weight(0.2f).align(Alignment.CenterVertically)
) {
Text("Save")
}
@ -501,11 +541,7 @@ fun soloFeedbackPane(
}
}
Spacer(Modifier.height(5.dp))
AutocompleteLineField(
feedback, { feedback = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") }
) { filter ->
autofill.filter { x -> x.trim().startsWith(filter.trim()) }
}
RichTextField(feedback, Modifier.fillMaxWidth().weight(1f)) { Text("Feedback") }
}
}

View File

@ -64,14 +64,14 @@ fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) {
{ name, note, contact, add -> state.newStudent(name, contact, note, add) },
{ students -> state.addToCourse(students) },
{ s, name -> state.setStudentName(s, name) }
) { s -> state.delete(s) }
) { s, idx -> state.delete(s); if(id == idx) state.clearHistoryIndex() }
OpenPanel.Group -> GroupPanel(
course, edition, groups, id,
{ state.navTo(it) },
{ name -> state.newGroup(name) },
{ g, name -> state.setGroupName(g, name) }
) { g -> state.delete(g) }
) { g, idx -> state.delete(g); if(id == idx) state.clearHistoryIndex() }
OpenPanel.Assignment -> AssignmentPanel(
course, edition, mergedAssignments, id,
@ -79,7 +79,7 @@ fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) {
{ type, name -> state.newAssignment(type, name) },
{ a, name -> state.setAssignmentTitle(a, name) },
{ a1, a2 -> state.swapOrder(a1, a2) }
) { a -> state.delete(a) }
) { a, idx -> state.delete(a); if(id == idx) state.clearHistoryIndex() }
}
}
}
@ -133,7 +133,7 @@ fun StudentPanel(
course: Course, edition: Edition, students: List<Student>, available: List<Student>,
selected: Int, onSelect: (Int) -> Unit,
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit,
onImport: (List<Student>) -> Unit, onUpdate: (Student, String) -> Unit, onDelete: (Student) -> Unit
onImport: (List<Student>) -> Unit, onUpdate: (Student, String) -> Unit, onDelete: (Student, Int) -> Unit
) = Column(Modifier.padding(10.dp)) {
var showDialog by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(-1) }
@ -171,7 +171,7 @@ fun StudentPanel(
ConfirmDeleteDialog(
"a student",
{ deleting = -1 },
{ onDelete(students[deleting]) }
{ onDelete(students[deleting], deleting) }
) { Text(students[deleting].name) }
}
}
@ -180,7 +180,7 @@ fun StudentPanel(
fun GroupPanel(
course: Course, edition: Edition, groups: List<Group>,
selected: Int, onSelect: (Int) -> Unit,
onAdd: (String) -> Unit, onUpdate: (Group, String) -> Unit, onDelete: (Group) -> Unit
onAdd: (String) -> Unit, onUpdate: (Group, String) -> Unit, onDelete: (Group, Int) -> Unit
) = Column(Modifier.padding(10.dp)) {
var showDialog by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(-1) }
@ -218,7 +218,7 @@ fun GroupPanel(
ConfirmDeleteDialog(
"a group",
{ deleting = -1 },
{ onDelete(groups[deleting]) }
{ onDelete(groups[deleting], deleting) }
) { Text(groups[deleting].name) }
}
}
@ -228,7 +228,7 @@ fun AssignmentPanel(
course: Course, edition: Edition, assignments: List<Assignment>,
selected: Int, onSelect: (Int) -> Unit,
onAdd: (AssignmentType, String) -> Unit, onUpdate: (Assignment, String) -> Unit,
onSwapOrder: (Assignment, Assignment) -> Unit, onDelete: (Assignment) -> Unit
onSwapOrder: (Assignment, Assignment) -> Unit, onDelete: (Assignment, Int) -> Unit
) = Column(Modifier.padding(10.dp)) {
var showDialog by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(-1) }
@ -315,8 +315,8 @@ fun AssignmentPanel(
ConfirmDeleteDialog(
"an assignment",
{ deleting = -1 },
{ onDelete(assignments[deleting]) }
) { Text(assignments[deleting].name()) }
{ onDelete(assignments[deleting], deleting) }
) { if(deleting != -1) Text(assignments[deleting].name()) }
}
}

View File

@ -28,6 +28,7 @@ import androidx.compose.ui.unit.sp
import com.jaytux.grader.loadClipboard
import com.jaytux.grader.toClipboard
import com.mohamedrejeb.richeditor.model.RichTextState
import com.mohamedrejeb.richeditor.ui.material.OutlinedRichTextEditor
@Composable
fun RichTextStyleRow(
@ -238,3 +239,17 @@ fun RichTextStyleButton(
)
}
}
@Composable
fun RichTextField(
state: RichTextState,
modifier: Modifier = Modifier.fillMaxSize(),
buttonsModifier: Modifier = Modifier.fillMaxWidth(),
outerModifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null
) = Column(outerModifier) {
RichTextStyleRow(buttonsModifier, state)
OutlinedRichTextEditor(
state = state, modifier = modifier, singleLine = false, minLines = 5, label = label
)
}

View File

@ -34,6 +34,7 @@ import androidx.compose.ui.window.*
import com.jaytux.grader.data.Course
import com.jaytux.grader.data.Edition
import com.jaytux.grader.viewmodel.PeerEvaluationState
import com.mohamedrejeb.richeditor.model.RichTextState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.*
@ -211,7 +212,8 @@ fun PaneHeader(name: String, type: String, courseEdition: Pair<Course, Edition>)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AutocompleteLineField(
fun AutocompleteLineField__(
// state: RichTextState,
value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier, label: @Composable (() -> Unit)? = null,
onFilter: (String) -> List<String>

View File

@ -171,10 +171,14 @@ class EditionState(val edition: Edition) {
fun newSoloAssignment(name: String) {
transaction {
SoloAssignment.new {
val assign = SoloAssignment.new {
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
this.number = nextIdx()
}
val global = SoloAssignmentCriterion.new {
this.name = "_global"; this.description = "[Global] Meta-criterion for $name"; this.assignment = assign
}
assign.globalCriterion = global
solo.refresh()
}
}
@ -186,10 +190,14 @@ class EditionState(val edition: Edition) {
}
fun newGroupAssignment(name: String) {
transaction {
GroupAssignment.new {
val assign = GroupAssignment.new {
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
this.number = nextIdx()
}
val global = GroupAssignmentCriterion.new {
this.name = "_global"; this.description = "[Global] Meta-criterion for $name"; this.assignment = assign
}
assign.globalCriterion = global
groupAs.refresh()
}
}
@ -354,6 +362,10 @@ class EditionState(val edition: Edition) {
while(temp.last().first == -1 && temp.size >= 2) temp = temp.dropLast(1)
_history.value = temp
}
fun clearHistoryIndex() {
val last = _history.value.lastOrNull() ?: return
_history.value = _history.value.filter { (i, panel) -> panel != last.second || i != last.first } + (-1 to last.second)
}
}
class StudentState(val student: Student, edition: Edition) {
@ -373,12 +385,12 @@ class StudentState(val student: Student, edition: Edition) {
val asGroup = (GroupAssignments innerJoin GroupAssignmentCriteria innerJoin GroupFeedbacks innerJoin Groups).selectAll().where {
(GroupFeedbacks.groupId inList groupsForEdition.keys.toList()) and
(GroupAssignmentCriteria.name eq "")
(GroupAssignmentCriteria.id eq GroupAssignments.globalCriterion)
}.map { it[GroupAssignments.id] to it }
val asIndividual = (GroupAssignments innerJoin GroupAssignmentCriteria innerJoin IndividualFeedbacks innerJoin Groups).selectAll().where {
(IndividualFeedbacks.studentId eq student.id) and
(GroupAssignmentCriteria.name eq "")
(GroupAssignmentCriteria.id eq GroupAssignments.globalCriterion)
}.map { it[GroupAssignments.id] to it }
val res = mutableMapOf<EntityID<UUID>, LocalGroupGrade>()
@ -475,7 +487,7 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable()
private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable()
val criteria = RawDbState {
assignment.criteria.orderBy(GroupAssignmentCriteria.name to SortOrder.ASC).toList()
assignment.criteria.orderBy(GroupAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.id }
}
val feedback = RawDbState { loadFeedback() }
@ -494,7 +506,7 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
private fun Transaction.loadFeedback(): List<Pair<Group, LocalGFeedback>> {
val allCrit = GroupAssignmentCriterion.find {
GroupAssignmentCriteria.assignmentId eq assignment.id
}
}.orderBy(GroupAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.id }
return Group.find {
(Groups.editionId eq assignment.edition.id)
@ -502,16 +514,19 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
val forGroup = (GroupFeedbacks innerJoin Groups).selectAll().where {
(GroupFeedbacks.assignmentId eq assignment.id) and (Groups.id eq group.id)
}.map { row ->
val crit = row[GroupFeedbacks.criterionId]?.let { GroupAssignmentCriterion[it] }
val crit = GroupAssignmentCriterion[row[GroupFeedbacks.criterionId]]
val fdbk = row[GroupFeedbacks.feedback]
val grade = row[GroupFeedbacks.grade]
crit to FeedbackEntry(fdbk, grade)
}
val global = forGroup.firstOrNull { it.first == null }?.second
val byCrit_ = forGroup.map { it.first?.let { k -> LocalCriterionFeedback(k, it.second) } }
.filterNotNull().associateBy { it.criterion.id }
val global = forGroup.firstOrNull { it.first.id == assignment.globalCriterion.id }?.second
val byCrit_ = forGroup
.filter{ it.first.id != assignment.globalCriterion.id }
.map { LocalCriterionFeedback(it.first, it.second) }
.associateBy { it.criterion.id }
val byCrit = allCrit.map { c ->
byCrit_[c.id] ?: LocalCriterionFeedback(c, null)
}
@ -522,21 +537,25 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
val student = it.student
val role = it.role
val forSt = (IndividualFeedbacks innerJoin Groups innerJoin GroupStudents)
val forSt = (IndividualFeedbacks innerJoin Groups)
.selectAll().where {
(IndividualFeedbacks.assignmentId eq assignment.id) and
(GroupStudents.studentId eq student.id) and (Groups.id eq group.id)
(IndividualFeedbacks.studentId eq student.id) and (Groups.id eq group.id)
}.map { row ->
val crit = row[IndividualFeedbacks.criterionId]?.let { id -> GroupAssignmentCriterion[id] }
val stdId = row[IndividualFeedbacks.studentId]
val crit = GroupAssignmentCriterion[row[IndividualFeedbacks.criterionId]]
val fdbk = row[IndividualFeedbacks.feedback]
val grade = row[IndividualFeedbacks.grade]
crit to FeedbackEntry(fdbk, grade)
}
val global = forSt.firstOrNull { it.first == null }?.second
val byCrit_ = forSt.map { it.first?.let { k -> LocalCriterionFeedback(k, it.second) } }
.filterNotNull().associateBy { it.criterion.id }
val global = forSt.firstOrNull { it.first.id == assignment.globalCriterion.id }?.second
val byCrit_ = forSt
.filter { it.first != assignment.globalCriterion.id }
.map { LocalCriterionFeedback(it.first, it.second) }
.associateBy { it.criterion.id }
val byCrit = allCrit.map { c ->
byCrit_[c.id] ?: LocalCriterionFeedback(c, null)
}
@ -556,7 +575,7 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
it[groupId] = group.id
it[this.feedback] = msg
it[this.grade] = grd
it[criterionId] = criterion?.id
it[criterionId] = criterion?.id ?: assignment.globalCriterion.id
}
}
feedback.refresh(); autofill.refresh()
@ -570,7 +589,7 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
it[studentId] = student.id
it[this.feedback] = msg
it[this.grade] = grd
it[criterionId] = criterion?.id
it[criterionId] = criterion?.id ?: assignment.globalCriterion.id
}
}
feedback.refresh(); autofill.refresh()
@ -628,7 +647,7 @@ class SoloAssignmentState(val assignment: SoloAssignment) {
private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable()
private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable()
val criteria = RawDbState {
assignment.criteria.orderBy(SoloAssignmentCriteria.name to SortOrder.ASC).toList()
assignment.criteria.orderBy(SoloAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.id }
}
val feedback = RawDbState { loadFeedback() }
@ -638,25 +657,28 @@ class SoloAssignmentState(val assignment: SoloAssignment) {
}.flatten().distinct().sorted()
}
private fun Transaction.loadFeedback(): List<Pair<Student, FullFeedback>> {
private fun Transaction.loadFeedback(): List<Pair<Student, FullFeedback>> {3
val allCrit = SoloAssignmentCriterion.find {
SoloAssignmentCriteria.assignmentId eq assignment.id
}
}.orderBy(SoloAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.id }
return editionCourse.second.soloStudents.sortAsc(Students.name).map { student ->
val forStudent = (IndividualFeedbacks innerJoin Students).selectAll().where {
(IndividualFeedbacks.assignmentId eq assignment.id) and (Students.id eq student.id)
}.map { row ->
val crit = row[IndividualFeedbacks.criterionId]?.let { SoloAssignmentCriterion[it] }
val crit = SoloAssignmentCriterion[row[IndividualFeedbacks.criterionId]]
val fdbk = row[IndividualFeedbacks.feedback]
val grade = row[IndividualFeedbacks.grade]
crit to LocalFeedback(fdbk, grade)
}
val global = forStudent.firstOrNull { it.first == null }?.second
val byCrit_ = forStudent.map { it.first?.let { k -> Pair(k, it.second) } }
.filterNotNull().associateBy { it.first.id }
val global = forStudent.firstOrNull { it.first == assignment.globalCriterion.id }?.second
val byCrit_ = forStudent
.filter { it.first != assignment.globalCriterion.id }
.map { Pair(it.first, it.second) }
.associateBy { it.first.id }
val byCrit = allCrit.map { c ->
byCrit_[c.id] ?: Pair(c, null)
}
@ -672,7 +694,7 @@ class SoloAssignmentState(val assignment: SoloAssignment) {
it[studentId] = student.id
it[this.feedback] = msg ?: ""
it[this.grade] = grd ?: ""
it[criterionId] = criterion?.id
it[criterionId] = criterion?.id ?: assignment.globalCriterion.id
}
}
feedback.refresh(); autofill.refresh()

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" }
@ -20,6 +21,7 @@ kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-co
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" }
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }
exposed-migration = { group = "org.jetbrains.exposed", name = "exposed-migration", version.ref = "exposed" }
exposed-kotlin-datetime = { group = "org.jetbrains.exposed", name = "exposed-kotlin-datetime", version.ref = "exposed" }
sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.34.0" }
sl4j = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.12" }
@ -29,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" }