Compare commits

...

10 Commits

14 changed files with 1873 additions and 487 deletions

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ captures
!*.xcworkspace/contents.xcworkspacedata !*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
**/grader.db **/grader.db
**/*.backup

View File

@ -48,6 +48,7 @@ compose.desktop {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.jaytux.grader" packageName = "com.jaytux.grader"
packageVersion = "1.0.0" packageVersion = "1.0.0"
includeAllModules = true
} }
} }
} }

View File

@ -1,5 +1,11 @@
package com.jaytux.grader package com.jaytux.grader
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import com.mohamedrejeb.richeditor.model.RichTextState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
fun String.maxN(n: Int): String { fun String.maxN(n: Int): String {
return if (this.length > n) { return if (this.length > n) {
this.substring(0, n - 3) + "..." this.substring(0, n - 3) + "..."
@ -7,3 +13,11 @@ fun String.maxN(n: Int): String {
this this
} }
} }
fun RichTextState.toClipboard(clip: ClipboardManager) {
clip.setText(AnnotatedString(this.toMarkdown()))
}
fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) {
scope.launch { setMarkdown(clip.getText()?.text ?: "") }
}

View File

@ -1,6 +1,5 @@
package com.jaytux.grader.data package com.jaytux.grader.data
import kotlinx.datetime.*
import org.jetbrains.exposed.dao.id.CompositeIdTable import org.jetbrains.exposed.dao.id.CompositeIdTable
import org.jetbrains.exposed.dao.id.UUIDTable import org.jetbrains.exposed.dao.id.UUIDTable
import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.Table
@ -53,42 +52,92 @@ object EditionStudents : Table("editionStudents") {
object GroupAssignments : UUIDTable("grpAssgmts") { object GroupAssignments : UUIDTable("grpAssgmts") {
val editionId = reference("edition_id", Editions.id) val editionId = reference("edition_id", Editions.id)
val number = integer("number").nullable()
val name = varchar("name", 50) val name = varchar("name", 50)
val assignment = text("assignment") val assignment = text("assignment")
val deadline = datetime("deadline") val deadline = datetime("deadline")
} }
object GroupAssignmentCriteria : UUIDTable("grpAsCr") {
val assignmentId = reference("group_assignment_id", GroupAssignments.id)
val name = varchar("name", 50)
val desc = text("description")
}
object SoloAssignments : UUIDTable("soloAssgmts") { object SoloAssignments : UUIDTable("soloAssgmts") {
val editionId = reference("edition_id", Editions.id) val editionId = reference("edition_id", Editions.id)
val number = integer("number").nullable()
val name = varchar("name", 50) val name = varchar("name", 50)
val assignment = text("assignment") val assignment = text("assignment")
val deadline = datetime("deadline") val deadline = datetime("deadline")
} }
object SoloAssignmentCriteria : UUIDTable("soloAsCr") {
val assignmentId = reference("solo_assignment_id", SoloAssignments.id)
val name = varchar("name", 50)
val desc = text("description")
}
object PeerEvaluations : UUIDTable("peerEvals") {
val editionId = reference("edition_id", Editions.id)
val number = integer("number").nullable()
val name = varchar("name", 50)
}
object GroupFeedbacks : CompositeIdTable("grpFdbks") { object GroupFeedbacks : CompositeIdTable("grpFdbks") {
val groupAssignmentId = reference("group_assignment_id", GroupAssignments.id) val assignmentId = reference("group_assignment_id", GroupAssignments.id)
val criterionId = reference("criterion_id", GroupAssignmentCriteria.id).nullable()
val groupId = reference("group_id", Groups.id) val groupId = reference("group_id", Groups.id)
val feedback = text("feedback") val feedback = text("feedback")
val grade = varchar("grade", 32) val grade = varchar("grade", 32)
override val primaryKey = PrimaryKey(groupAssignmentId, groupId) override val primaryKey = PrimaryKey(groupId, criterionId)
} }
object IndividualFeedbacks : CompositeIdTable("indivFdbks") { object IndividualFeedbacks : CompositeIdTable("indivFdbks") {
val groupAssignmentId = reference("group_assignment_id", GroupAssignments.id) val assignmentId = reference("group_assignment_id", GroupAssignments.id)
val criterionId = reference("criterion_id", GroupAssignmentCriteria.id).nullable()
val groupId = reference("group_id", Groups.id) val groupId = reference("group_id", Groups.id)
val studentId = reference("student_id", Students.id) val studentId = reference("student_id", Students.id)
val feedback = text("feedback") val feedback = text("feedback")
val grade = varchar("grade", 32) val grade = varchar("grade", 32)
override val primaryKey = PrimaryKey(groupAssignmentId, studentId) override val primaryKey = PrimaryKey(studentId, criterionId)
} }
object SoloFeedbacks : CompositeIdTable("soloFdbks") { object SoloFeedbacks : CompositeIdTable("soloFdbks") {
val soloAssignmentId = reference("solo_assignment_id", SoloAssignments.id) val assignmentId = reference("solo_assignment_id", SoloAssignments.id)
val criterionId = reference("criterion_id", SoloAssignmentCriteria.id).nullable()
val studentId = reference("student_id", Students.id) val studentId = reference("student_id", Students.id)
val feedback = text("feedback") val feedback = text("feedback")
val grade = varchar("grade", 32) val grade = varchar("grade", 32)
override val primaryKey = PrimaryKey(soloAssignmentId, studentId) override val primaryKey = PrimaryKey(studentId, criterionId)
}
object PeerEvaluationContents : CompositeIdTable("peerEvalCnts") {
val peerEvaluationId = reference("peer_evaluation_id", PeerEvaluations.id)
val groupId = reference("group_id", Groups.id)
val content = text("content")
override val primaryKey = PrimaryKey(peerEvaluationId, groupId)
}
object StudentToGroupEvaluation : CompositeIdTable("stToGrEv") {
val peerEvaluationId = reference("peer_evaluation_id", PeerEvaluations.id)
val studentId = reference("student_id", Students.id)
val grade = varchar("grade", 32)
val note = text("note")
override val primaryKey = PrimaryKey(peerEvaluationId, studentId)
}
object StudentToStudentEvaluation : CompositeIdTable("stToStEv") {
val peerEvaluationId = reference("peer_evaluation_id", PeerEvaluations.id)
val studentIdFrom = reference("student_id_from", Students.id)
val studentIdTo = reference("student_id_to", Students.id)
val grade = varchar("grade", 32)
val note = text("note")
override val primaryKey = PrimaryKey(peerEvaluationId, studentIdFrom, studentIdTo)
} }

View File

@ -11,15 +11,19 @@ object Database {
SchemaUtils.create( SchemaUtils.create(
Courses, Editions, Groups, Courses, Editions, Groups,
Students, GroupStudents, EditionStudents, Students, GroupStudents, EditionStudents,
GroupAssignments, SoloAssignments, GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria,
GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks,
PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation,
StudentToGroupEvaluation
) )
val addMissing = SchemaUtils.addMissingColumnsStatements( val addMissing = SchemaUtils.addMissingColumnsStatements(
Courses, Editions, Groups, Courses, Editions, Groups,
Students, GroupStudents, EditionStudents, Students, GroupStudents, EditionStudents,
GroupAssignments, SoloAssignments, GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria,
GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks,
PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation,
StudentToGroupEvaluation
) )
addMissing.forEach { exec(it) } addMissing.forEach { exec(it) }
} }

View File

@ -1,18 +1,16 @@
package com.jaytux.grader.data package com.jaytux.grader.data
import com.jaytux.grader.data.GroupAssignment.Companion.referrersOn
import org.jetbrains.exposed.dao.Entity import org.jetbrains.exposed.dao.Entity
import org.jetbrains.exposed.dao.EntityClass import org.jetbrains.exposed.dao.EntityClass
import org.jetbrains.exposed.dao.id.CompositeID
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.UUID import java.util.UUID
class Course(id: EntityID<UUID>) : Entity<UUID>(id) { class Course(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, Course>(Courses) companion object : EntityClass<UUID, Course>(Courses)
fun loadEditions() = transaction { Edition.find { Editions.courseId eq this@Course.id }.toList() }
var name by Courses.name var name by Courses.name
val editions by Edition referrersOn Editions.courseId
} }
class Edition(id: EntityID<UUID>) : Entity<UUID>(id) { class Edition(id: EntityID<UUID>) : Entity<UUID>(id) {
@ -24,6 +22,7 @@ class Edition(id: EntityID<UUID>) : Entity<UUID>(id) {
val soloStudents by Student via EditionStudents val soloStudents by Student via EditionStudents
val soloAssignments by SoloAssignment referrersOn SoloAssignments.editionId val soloAssignments by SoloAssignment referrersOn SoloAssignments.editionId
val groupAssignments by GroupAssignment referrersOn GroupAssignments.editionId val groupAssignments by GroupAssignment referrersOn GroupAssignments.editionId
val peerEvaluations by PeerEvaluation referrersOn PeerEvaluations.editionId
} }
class Group(id: EntityID<UUID>) : Entity<UUID>(id) { class Group(id: EntityID<UUID>) : Entity<UUID>(id) {
@ -57,43 +56,82 @@ class GroupAssignment(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, GroupAssignment>(GroupAssignments) companion object : EntityClass<UUID, GroupAssignment>(GroupAssignments)
var edition by Edition referencedOn GroupAssignments.editionId var edition by Edition referencedOn GroupAssignments.editionId
var number by GroupAssignments.number
var name by GroupAssignments.name var name by GroupAssignments.name
var assignment by GroupAssignments.assignment var assignment by GroupAssignments.assignment
var deadline by GroupAssignments.deadline var deadline by GroupAssignments.deadline
val criteria by GroupAssignmentCriterion referrersOn GroupAssignmentCriteria.assignmentId
}
class GroupAssignmentCriterion(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, GroupAssignmentCriterion>(GroupAssignmentCriteria)
var assignment by GroupAssignment referencedOn GroupAssignmentCriteria.assignmentId
var name by GroupAssignmentCriteria.name
var description by GroupAssignmentCriteria.desc
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as GroupAssignmentCriterion
if (name != other.name) return false
if (description != other.description) return false
return true
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + description.hashCode()
return result
}
} }
class SoloAssignment(id: EntityID<UUID>) : Entity<UUID>(id) { class SoloAssignment(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, SoloAssignment>(SoloAssignments) companion object : EntityClass<UUID, SoloAssignment>(SoloAssignments)
var edition by Edition referencedOn SoloAssignments.editionId var edition by Edition referencedOn SoloAssignments.editionId
var number by SoloAssignments.number
var name by SoloAssignments.name var name by SoloAssignments.name
var assignment by SoloAssignments.assignment var assignment by SoloAssignments.assignment
var deadline by SoloAssignments.deadline var deadline by SoloAssignments.deadline
val criteria by SoloAssignmentCriterion referrersOn SoloAssignmentCriteria.assignmentId
} }
class GroupFeedback(id: EntityID<CompositeID>) : Entity<CompositeID>(id) { class SoloAssignmentCriterion(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<CompositeID, GroupFeedback>(GroupFeedbacks) companion object : EntityClass<UUID, SoloAssignmentCriterion>(SoloAssignmentCriteria)
var group by Group referencedOn GroupFeedbacks.groupId var assignment by SoloAssignment referencedOn SoloAssignmentCriteria.assignmentId
var assignment by GroupAssignment referencedOn GroupFeedbacks.groupAssignmentId var name by SoloAssignmentCriteria.name
var feedback by GroupFeedbacks.feedback var description by SoloAssignmentCriteria.desc
var grade by GroupFeedbacks.grade
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SoloAssignmentCriterion
if (name != other.name) return false
if (description != other.description) return false
return true
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + description.hashCode()
return result
}
} }
class IndividualFeedback(id: EntityID<CompositeID>) : Entity<CompositeID>(id) { class PeerEvaluation(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<CompositeID, IndividualFeedback>(IndividualFeedbacks) companion object : EntityClass<UUID, PeerEvaluation>(PeerEvaluations)
var student by Student referencedOn IndividualFeedbacks.studentId var edition by Edition referencedOn PeerEvaluations.editionId
var assignment by SoloAssignment referencedOn IndividualFeedbacks.groupAssignmentId var number by PeerEvaluations.number
var feedback by IndividualFeedbacks.feedback var name by PeerEvaluations.name
var grade by IndividualFeedbacks.grade
}
class SoloFeedback(id: EntityID<CompositeID>) : Entity<CompositeID>(id) {
companion object : EntityClass<CompositeID, SoloFeedback>(SoloFeedbacks)
var student by Student referencedOn SoloFeedbacks.studentId
var assignment by SoloAssignment referencedOn SoloFeedbacks.soloAssignmentId
var feedback by SoloFeedbacks.feedback
var grade by SoloFeedbacks.grade
} }

View File

@ -2,33 +2,41 @@ package com.jaytux.grader.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
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.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue 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 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.viewmodel.GroupAssignmentState 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.model.rememberRichTextState
import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor
import kotlinx.datetime.LocalDateTime
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun GroupAssignmentView(state: GroupAssignmentState) { fun GroupAssignmentView(state: GroupAssignmentState) {
val (course, edition) = state.editionCourse
val name by state.name
val task by state.task val task by state.task
val deadline by state.deadline val deadline by state.deadline
val allFeedback by state.feedback.entities val allFeedback by state.feedback.entities
val criteria by state.criteria.entities
var idx by remember(state) { mutableStateOf(0) } var idx by remember(state) { mutableStateOf(0) }
Column(Modifier.padding(10.dp)) { Column(Modifier.padding(10.dp)) {
PaneHeader(name, "group assignment", course, edition)
if(allFeedback.any { it.second.feedback == null }) { if(allFeedback.any { it.second.feedback == null }) {
Text("Groups in bold have no feedback yet.", fontStyle = FontStyle.Italic) Text("Groups in bold have no feedback yet.", fontStyle = FontStyle.Italic)
} }
@ -37,7 +45,7 @@ fun GroupAssignmentView(state: GroupAssignmentState) {
} }
TabRow(idx) { TabRow(idx) {
Tab(idx == 0, { idx = 0 }) { Text("Assignment") } Tab(idx == 0, { idx = 0 }) { Text("Task and Criteria") }
allFeedback.forEachIndexed { i, it -> allFeedback.forEachIndexed { i, it ->
val (group, feedback) = it val (group, feedback) = it
Tab(idx == i + 1, { idx = i + 1 }) { Tab(idx == i + 1, { idx = i + 1 }) {
@ -47,12 +55,75 @@ fun GroupAssignmentView(state: GroupAssignmentState) {
} }
if(idx == 0) { if(idx == 0) {
val updTask = rememberRichTextState() groupTaskWidget(
task, deadline, criteria,
onSetTask = { state.updateTask(it) },
onSetDeadline = { state.updateDeadline(it) },
onAddCriterion = { state.addCriterion(it) },
onModCriterion = { c, n, d -> state.updateCriterion(c, n, d) },
onRmCriterion = { state.deleteCriterion(it) }
)
}
else {
groupFeedback(state, allFeedback[idx - 1].second)
}
}
}
LaunchedEffect(task) { updTask.setMarkdown(task) } @OptIn(ExperimentalMaterial3Api::class)
@Composable
fun groupTaskWidget(
taskMD: String,
deadline: LocalDateTime,
criteria: List<GroupAssignmentCriterion>,
onSetTask: (String) -> Unit,
onSetDeadline: (LocalDateTime) -> Unit,
onAddCriterion: (name: String) -> Unit,
onModCriterion: (cr: GroupAssignmentCriterion, name: String, desc: String) -> Unit,
onRmCriterion: (cr: GroupAssignmentCriterion) -> Unit
) {
var critIdx by remember { mutableStateOf(0) }
var adding by remember { mutableStateOf(false) }
var confirming by remember { mutableStateOf(false) }
Row { Row {
DateTimePicker(deadline, { state.updateDeadline(it) }) Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) {
Column(Modifier.padding(10.dp)) {
LazyColumn(Modifier.weight(1f)) {
item {
Surface(
Modifier.fillMaxWidth().clickable { critIdx = 0 },
tonalElevation = if (critIdx == 0) 50.dp else 0.dp,
shape = MaterialTheme.shapes.medium
) {
Text("Assignment", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
}
}
itemsIndexed(criteria) { i, crit ->
Surface(
Modifier.fillMaxWidth().clickable { critIdx = i + 1 },
tonalElevation = if (critIdx == i + 1) 50.dp else 0.dp,
shape = MaterialTheme.shapes.medium
) {
Text(crit.name, Modifier.padding(5.dp))
}
}
}
Button({ adding = true }, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) {
Text("Add evaluation criterion")
}
}
}
Box(Modifier.weight(0.75f).padding(10.dp)) {
if (critIdx == 0) {
val updTask = rememberRichTextState()
LaunchedEffect(taskMD) { updTask.setMarkdown(taskMD) }
Column {
Row {
DateTimePicker(deadline, onSetDeadline)
} }
RichTextStyleRow(state = updTask) RichTextStyleRow(state = updTask)
OutlinedRichTextEditor( OutlinedRichTextEditor(
@ -62,10 +133,53 @@ fun GroupAssignmentView(state: GroupAssignmentState) {
minLines = 5, minLines = 5,
label = { Text("Task") } label = { Text("Task") }
) )
CancelSaveRow(true, { updTask.setMarkdown(task) }, "Reset", "Update") { state.updateTask(updTask.toMarkdown()) } CancelSaveRow(
true,
{ updTask.setMarkdown(taskMD) },
"Reset",
"Update"
) { onSetTask(updTask.toMarkdown()) }
}
} }
else { else {
groupFeedback(state, allFeedback[idx - 1].second) val crit = criteria[critIdx - 1]
var name by remember(crit) { mutableStateOf(crit.name) }
var desc by remember(crit) { mutableStateOf(crit.description) }
Column {
Row {
OutlinedTextField(name, { name = it }, Modifier.weight(0.8f))
Spacer(Modifier.weight(0.1f))
Button({ onModCriterion(crit, name, desc) }, Modifier.weight(0.1f)) {
Text("Update")
}
}
OutlinedTextField(
desc, { desc = it }, Modifier.fillMaxWidth().weight(1f),
label = { Text("Description") },
singleLine = false,
minLines = 5
)
Button({ confirming = true }, Modifier.fillMaxWidth()) {
Text("Remove criterion")
}
}
}
}
}
if(adding) {
AddStringDialog(
"Evaluation criterion name", criteria.map{ it.name }, { adding = false }
) { onAddCriterion(it) }
}
if(confirming && critIdx != 0) {
ConfirmDeleteDialog(
"an evaluation criterion",
{ confirming = false }, { onRmCriterion(criteria[critIdx - 1]); critIdx = 0 }
) {
Text(criteria[critIdx - 1].name)
} }
} }
} }
@ -73,9 +187,9 @@ fun GroupAssignmentView(state: GroupAssignmentState) {
@Composable @Composable
fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalGFeedback) { fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalGFeedback) {
val (group, feedback, individual) = fdbk val (group, feedback, individual) = fdbk
var grade by remember(fdbk) { mutableStateOf(feedback?.grade ?: "") }
var msg by remember(fdbk) { mutableStateOf(TextFieldValue(feedback?.feedback ?: "")) }
var idx by remember(fdbk) { mutableStateOf(0) } var idx 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 suggestions by state.autofill.entities
Row { Row {
@ -104,42 +218,453 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG
} }
} }
Column(Modifier.weight(0.75f).padding(10.dp)) { val updateGrade = { grade: String ->
if(idx == 0) { if(idx == 0) {
Row { state.upsertGroupFeedback(group, feedback.global?.feedback ?: "", grade)
Text("Grade: ", Modifier.align(Alignment.CenterVertically)) }
OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f)) else {
Spacer(Modifier.weight(0.6f)) val ind = individual[idx - 1]
Button({ state.upsertGroupFeedback(group, msg.text, grade) }, Modifier.weight(0.2f).align(Alignment.CenterVertically), val glob = ind.second.second.global
enabled = grade.isNotBlank() || msg.text.isNotBlank()) { state.upsertIndividualFeedback(ind.first, group, glob?.feedback ?: "", grade)
Text("Save")
} }
} }
AutocompleteLineField( val updateFeedback = { fdbk: String ->
msg, { msg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") } if(idx == 0) {
) { filter -> if(critIdx == 0) {
suggestions.filter { x -> x.trim().startsWith(filter.trim()) } state.upsertGroupFeedback(group, fdbk, feedback.global?.grade ?: "", null)
}
else {
val current = feedback.byCriterion[critIdx - 1]
state.upsertGroupFeedback(group, fdbk, current.entry?.grade ?: "", current.criterion)
} }
} }
else { else {
val (student, details) = individual[idx - 1] val ind = individual[idx - 1]
var sGrade by remember { mutableStateOf(details.second?.grade ?: "") } if(critIdx == 0) {
var sMsg by remember { mutableStateOf(TextFieldValue(details.second?.feedback ?: "")) } 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)
}
}
}
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
)
}
}
@Composable
fun groupFeedbackPane(
criteria: List<GroupAssignmentCriterion>,
currentCriterion: Int,
onSelectCriterion: (Int) -> Unit,
globFeedback: GroupAssignmentState.FeedbackEntry?,
criterionFeedback: GroupAssignmentState.FeedbackEntry?,
autofill: List<String>,
onSetGrade: (String) -> Unit,
onSetFeedback: (String) -> Unit,
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 ?: "")) }
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()
) {
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()) }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SoloAssignmentView(state: SoloAssignmentState) {
val task by state.task
val deadline by state.deadline
val suggestions by state.autofill.entities
val grades by state.feedback.entities
val criteria by state.criteria.entities
var tab by remember(state) { mutableStateOf(0) }
var idx by remember(state, tab) { mutableStateOf(0) }
var critIdx by remember(state, tab, idx) { mutableStateOf(0) }
var adding by remember(state, tab) { mutableStateOf(false) }
var confirming by remember(state, tab) { mutableStateOf(false) }
val updateGrade = { grade: String ->
state.upsertFeedback(
grades[idx].first,
if(critIdx == 0) grades[idx].second.global?.feedback else grades[idx].second.byCriterion[critIdx - 1].second?.feedback,
grade,
if(critIdx == 0) null else criteria[critIdx - 1]
)
}
val updateFeedback = { feedback: String ->
state.upsertFeedback(
grades[idx].first,
feedback,
if(critIdx == 0) grades[idx].second.global?.grade else grades[idx].second.byCriterion[critIdx - 1].second?.grade,
if(critIdx == 0) null else criteria[critIdx - 1]
)
}
Column(Modifier.padding(10.dp)) {
Row {
Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) {
Column(Modifier.padding(10.dp)) {
TabRow(tab) {
Tab(tab == 0, { tab = 0 }) { Text("Task/Criteria") }
Tab(tab == 1, { tab = 1 }) { Text("Students") }
}
LazyColumn(Modifier.weight(1f)) {
if (tab == 0) {
item {
Surface(
Modifier.fillMaxWidth().clickable { idx = 0 },
tonalElevation = if (idx == 0) 50.dp else 0.dp,
shape = MaterialTheme.shapes.medium
) {
Text("Assignment", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
}
}
itemsIndexed(criteria) { i, crit ->
Surface(
Modifier.fillMaxWidth().clickable { idx = i + 1 },
tonalElevation = if (idx == i + 1) 50.dp else 0.dp,
shape = MaterialTheme.shapes.medium
) {
Text(crit.name, Modifier.padding(5.dp))
}
}
} else {
itemsIndexed(grades.toList()) { i, (student, _) ->
Surface(
Modifier.fillMaxWidth().clickable { idx = i },
tonalElevation = if (idx == i) 50.dp else 0.dp,
shape = MaterialTheme.shapes.medium
) {
Text(student.name, Modifier.padding(5.dp))
}
}
}
}
if (tab == 0) {
Button({ adding = true }, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) {
Text("Add evaluation criterion")
}
}
}
}
Column(Modifier.weight(0.75f).padding(10.dp)) {
if(tab == 0) {
if (idx == 0) {
val updTask = rememberRichTextState()
LaunchedEffect(task) { updTask.setMarkdown(task) }
Row {
DateTimePicker(deadline, { state.updateDeadline(it) })
}
RichTextStyleRow(state = updTask)
OutlinedRichTextEditor(
state = updTask,
modifier = Modifier.fillMaxWidth().weight(1f),
singleLine = false,
minLines = 5,
label = { Text("Task") }
)
CancelSaveRow(
true,
{ updTask.setMarkdown(task) },
"Reset",
"Update"
) { state.updateTask(updTask.toMarkdown()) }
} else {
val crit = criteria[idx - 1]
var name by remember(crit) { mutableStateOf(crit.name) }
var desc by remember(crit) { mutableStateOf(crit.description) }
Column {
Row {
OutlinedTextField(name, { name = it }, Modifier.weight(0.8f))
Spacer(Modifier.weight(0.1f))
Button({ state.updateCriterion(crit, name, desc) }, Modifier.weight(0.1f)) {
Text("Update")
}
}
OutlinedTextField(
desc, { desc = it }, Modifier.fillMaxWidth().weight(1f),
label = { Text("Description") },
singleLine = false,
minLines = 5
)
Button({ confirming = true }, Modifier.fillMaxWidth()) {
Text("Remove criterion")
}
}
}
}
else {
soloFeedbackPane(
criteria, critIdx, { critIdx = it }, grades[idx].second.global,
if(critIdx == 0) grades[idx].second.global else grades[idx].second.byCriterion[critIdx - 1].second,
suggestions, updateGrade, updateFeedback,
key = tab to idx
)
}
}
}
}
if(adding) {
AddStringDialog(
"Evaluation criterion name", criteria.map{ it.name }, { adding = false }
) { state.addCriterion(it) }
}
if(confirming && idx != 0) {
ConfirmDeleteDialog(
"an evaluation criterion",
{ confirming = false }, { state.deleteCriterion(criteria[idx - 1]); idx = 0 }
) {
Text(criteria[idx - 1].name)
}
}
}
@Composable
fun soloFeedbackPane(
criteria: List<SoloAssignmentCriterion>,
currentCriterion: Int,
onSelectCriterion: (Int) -> Unit,
globFeedback: SoloAssignmentState.LocalFeedback?,
criterionFeedback: SoloAssignmentState.LocalFeedback?,
autofill: List<String>,
onSetGrade: (String) -> Unit,
onSetFeedback: (String) -> Unit,
modifier: Modifier = Modifier,
key: Any? = null
) {
var grade by remember(globFeedback, key) { mutableStateOf(globFeedback?.grade ?: "") }
var feedback by remember(currentCriterion, criteria, key) { mutableStateOf(TextFieldValue(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()
) {
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()) }
}
}
}
@Composable
fun PeerEvaluationView(state: PeerEvaluationState) {
val contents by state.contents.entities
var idx by remember(state) { mutableStateOf(0) }
var editing by remember(state) { mutableStateOf<Triple<Student, Student?, PeerEvaluationState.Student2StudentEntry?>?>(null) }
val measure = rememberTextMeasurer()
val isSelected = { from: Student, to: Student? ->
editing?.let { (f, t, _) -> f == from && t == to } ?: false
}
Column(Modifier.padding(10.dp)) {
TabRow(idx) {
contents.forEachIndexed { i, it ->
Tab(idx == i, { idx = i; editing = null }) { Text(it.group.name) }
}
}
Spacer(Modifier.height(10.dp))
Row {
val current = contents[idx]
val horScroll = rememberLazyListState()
val style = LocalTextStyle.current
val textLenMeasured = remember(state, idx) {
current.students.maxOf { (s, _) ->
measure.measure(s.name, style).size.width
} + 10
}
val cellSize = 75.dp
Column(Modifier.weight(0.5f)) {
Row {
Box { FromTo(textLenMeasured.dp) }
LazyRow(Modifier.height(textLenMeasured.dp), state = horScroll) {
item { VLine() }
items(current.students) { (s, _) ->
Box(
Modifier.width(cellSize).height(textLenMeasured.dp),
contentAlignment = Alignment.TopCenter
) {
var _h: Int = 0
Text(s.name, Modifier.layout{ m, c ->
val p = m.measure(c.copy(minWidth = c.maxWidth, maxWidth = Constraints.Infinity))
_h = p.height
layout(p.height, p.width) { p.place(0, 0) }
}.graphicsLayer {
rotationZ = -90f
transformOrigin = TransformOrigin(0f, 0.5f)
translationX = _h.toFloat() / 2f
translationY = textLenMeasured.dp.value - 15f
})
}
}
item { VLine() }
item {
Box(
Modifier.width(cellSize).height(textLenMeasured.dp),
contentAlignment = Alignment.TopCenter
) {
var _h: Int = 0
Text("Group Rating", Modifier.layout{ m, c ->
val p = m.measure(c.copy(minWidth = c.maxWidth, maxWidth = Constraints.Infinity))
_h = p.height
layout(p.height, p.width) { p.place(0, 0) }
}.graphicsLayer {
rotationZ = -90f
transformOrigin = TransformOrigin(0f, 0.5f)
translationX = _h.toFloat() / 2f
translationY = textLenMeasured.dp.value - 15f
}, fontWeight = FontWeight.Bold)
}
}
item { VLine() }
}
}
MeasuredLazyColumn(key = idx) {
measuredItem { HLine() }
items(current.students) { (from, glob, map) ->
Row(Modifier.height(cellSize)) {
Text(from.name, Modifier.width(textLenMeasured.dp).align(Alignment.CenterVertically))
LazyRow(state = horScroll) {
item { VLine() }
items(map) { (to, entry) ->
PEGradeWidget(entry,
{ editing = Triple(from, to, entry) }, { editing = null },
isSelected(from, to), Modifier.size(cellSize, cellSize)
)
}
item { VLine() }
item {
PEGradeWidget(glob,
{ editing = Triple(from, null, glob) }, { editing = null },
isSelected(from, null), Modifier.size(cellSize, cellSize))
}
item { VLine() }
}
}
}
measuredItem { HLine() }
}
}
Column(Modifier.weight(0.5f)) {
var groupLevel by remember(state, idx) { mutableStateOf(contents[idx].content) }
editing?.let {
Column(Modifier.weight(0.5f)) {
val (from, to, data) = it
var sGrade by remember(editing) { mutableStateOf(data?.grade ?: "") }
var sMsg by remember(editing) { mutableStateOf(data?.feedback ?: "") }
Box(Modifier.padding(5.dp)) {
to?.let { s2 ->
if(from == s2)
Text("Self-evaluation by ${from.name}", fontWeight = FontWeight.Bold)
else
Text("Evaluation of ${s2.name} by ${from.name}", fontWeight = FontWeight.Bold)
} ?: Text("Group-level evaluation by ${from.name}", fontWeight = FontWeight.Bold)
}
Row { Row {
Text("Grade: ", Modifier.align(Alignment.CenterVertically)) Text("Grade: ", Modifier.align(Alignment.CenterVertically))
OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f)) OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f))
Spacer(Modifier.weight(0.6f)) Spacer(Modifier.weight(0.6f))
Button({ state.upsertIndividualFeedback(student, group, sMsg.text, sGrade) }, Modifier.weight(0.2f).align(Alignment.CenterVertically), Button(
enabled = sGrade.isNotBlank() || sMsg.text.isNotBlank()) { { state.upsertIndividualFeedback(from, to, sGrade, sMsg); editing = null },
Modifier.weight(0.2f).align(Alignment.CenterVertically),
enabled = sGrade.isNotBlank() || sMsg.isNotBlank()
) {
Text("Save") Text("Save")
} }
} }
AutocompleteLineField( OutlinedTextField(
sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") } sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f),
) { filter -> label = { Text("Feedback") },
suggestions.filter { x -> x.trim().startsWith(filter.trim()) } singleLine = false,
minLines = 5
)
}
}
Column(Modifier.weight(0.5f)) {
Row {
Text("Group-level notes", Modifier.weight(1f).align(Alignment.CenterVertically), fontWeight = FontWeight.Bold)
Button(
{ state.upsertGroupFeedback(current.group, groupLevel); editing = null },
enabled = groupLevel != contents[idx].content
) { Text("Update") }
}
OutlinedTextField(
groupLevel, { groupLevel = it }, Modifier.fillMaxWidth().weight(1f),
label = { Text("Group-level notes") },
singleLine = false,
minLines = 5
)
} }
} }
} }

View File

@ -27,6 +27,7 @@ fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) {
val data by state.courses.entities val data by state.courses.entities
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
Box(Modifier.padding(15.dp)) {
ListOrEmpty( ListOrEmpty(
data, data,
{ Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) }, { Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) },
@ -36,6 +37,7 @@ fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) {
) { _, it -> ) { _, it ->
CourseWidget(state.getEditions(it), { state.delete(it) }, push) CourseWidget(state.getEditions(it), { state.delete(it) }, push)
} }
}
if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) } if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) }
} }

View File

@ -5,6 +5,9 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -16,153 +19,305 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogWindow import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.rememberDialogState
import com.jaytux.grader.data.* import com.jaytux.grader.data.Course
import com.jaytux.grader.viewmodel.EditionState import com.jaytux.grader.data.Edition
import com.jaytux.grader.viewmodel.GroupAssignmentState import com.jaytux.grader.data.Group
import com.jaytux.grader.viewmodel.GroupState import com.jaytux.grader.data.Student
import com.jaytux.grader.viewmodel.StudentState import com.jaytux.grader.viewmodel.*
enum class Panel { Student, Group, Solo, GroupAs } data class Navigators(
data class Current(val p: Panel, val i: Int) val student: (Student) -> Unit,
fun Current?.studentIdx() = this?.let { if(p == Panel.Student) i else null } val group: (Group) -> Unit,
fun Current?.groupIdx() = this?.let { if(p == Panel.Group) i else null } val assignment: (Assignment) -> Unit
fun Current?.soloIdx() = this?.let { if(p == Panel.Solo) i else null } )
fun Current?.groupAsIdx() = this?.let { if(p == Panel.GroupAs) i else null }
@Composable @Composable
fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) { fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) {
var isGroup by remember { mutableStateOf(false) } val course = state.course; val edition = state.edition
var idx by remember { mutableStateOf<Current?>(null) }
val students by state.students.entities val students by state.students.entities
val availableStudents by state.availableStudents.entities
val groups by state.groups.entities val groups by state.groups.entities
val solo by state.solo.entities val solo by state.solo.entities
val groupAs by state.groupAs.entities val groupAs by state.groupAs.entities
val available by state.availableStudents.entities val peers by state.peer.entities
val mergedAssignments by remember(solo, groupAs, peers) { mutableStateOf(Assignment.merge(groupAs, solo, peers)) }
val toggle = { i: Int, p: Panel -> val hist by state.history
idx = if(idx?.p == p && idx?.i == i) null else Current(p, i)
}
val navs = Navigators(
student = { state.navTo(OpenPanel.Student, students.indexOfFirst{ s -> s.id == it.id }) },
group = { state.navTo(OpenPanel.Group, groups.indexOfFirst { g -> g.id == it.id }) },
assignment = { state.navTo(OpenPanel.Assignment, mergedAssignments.indexOfFirst { a -> a.id() == it.id() }) }
)
val (id, tab) = hist.last()
Surface(Modifier.weight(0.25f), tonalElevation = 5.dp) { Surface(Modifier.weight(0.25f), tonalElevation = 5.dp) {
TabLayout( TabLayout(
listOf("Students", "Groups"), OpenPanel.entries,
if (isGroup) 1 else 0, tab.ordinal,
{ isGroup = it == 1 }, { state.navTo(OpenPanel.entries[it]) },
{ Text(it) } { Text(it.tabName) }
) { ) {
Column(Modifier.fillMaxSize()) { when(tab) {
if (isGroup) { OpenPanel.Student -> StudentPanel(
Box(Modifier.weight(0.5f)) { course, edition, students, availableStudents, id,
GroupsWidget( { state.navTo(it) },
state.course, { name, note, contact, add -> state.newStudent(name, contact, note, add) },
state.edition, { students -> state.addToCourse(students) },
groups, { s, name -> state.setStudentName(s, name) }
idx.groupIdx(), ) { s -> state.delete(s) }
{ toggle(it, Panel.Group) },
{ state.newGroup(it) }) { group, name -> OpenPanel.Group -> GroupPanel(
state.setGroupName(group, name) course, edition, groups, id,
{ state.navTo(it) },
{ name -> state.newGroup(name) },
{ g, name -> state.setGroupName(g, name) }
) { g -> state.delete(g) }
OpenPanel.Assignment -> AssignmentPanel(
course, edition, mergedAssignments, id,
{ state.navTo(it) },
{ type, name -> state.newAssignment(type, name) },
{ a, name -> state.setAssignmentTitle(a, name) },
{ a1, a2 -> state.swapOrder(a1, a2) }
) { a -> state.delete(a) }
} }
} }
Box(Modifier.weight(0.5f)) {
GroupAssignmentsWidget(
state.course, state.edition, groupAs, idx.groupAsIdx(), { toggle(it, Panel.GroupAs) },
{ state.newGroupAssignment(it) }) { assignment, title ->
state.setGroupAssignmentTitle(
assignment,
title
)
} }
Column(Modifier.weight(0.75f)) {
Row {
IconButton({ state.back() }, enabled = hist.size >= 2) {
Icon(ChevronLeft, "Back", Modifier.size(MaterialTheme.typography.headlineMedium.fontSize.toDp()).align(Alignment.CenterVertically))
} }
} else { when(tab) {
Box(Modifier.weight(0.5f)) { OpenPanel.Student -> {
StudentsWidget( if(id == -1) PaneHeader("Nothing selected", "students", course, edition)
state.course, state.edition, students, idx.studentIdx(), { toggle(it, Panel.Student) }, else PaneHeader(students[id].name, "student", course, edition)
available, { state.addToCourse(it) }
) { name, note, contact, addToEdition ->
state.newStudent(name, contact, note, addToEdition)
} }
OpenPanel.Group -> {
if(id == -1) PaneHeader("Nothing selected", "groups", course, edition)
else PaneHeader(groups[id].name, "group", course, edition)
} }
Box(Modifier.weight(0.5f)) { OpenPanel.Assignment -> {
AssignmentsWidget( if(id == -1) PaneHeader("Nothing selected", "assignments", course, edition)
state.course, else {
state.edition, when(val a = mergedAssignments[id]) {
solo, is Assignment.SAssignment -> PaneHeader(a.name(), "individual assignment", course, edition)
idx.soloIdx(), is Assignment.GAssignment -> PaneHeader(a.name(), "group assignment", course, edition)
{ toggle(it, Panel.Solo) }, is Assignment.PeerEval -> PaneHeader(a.name(), "peer evaluation", course, edition)
{ state.newSoloAssignment(it) }) { assignment, title ->
state.setSoloAssignmentTitle(assignment, title)
} }
} }
} }
} }
} }
Box(Modifier.weight(1f)) {
if (id != -1) {
when (tab) {
OpenPanel.Student -> StudentView(StudentState(students[id], edition), navs)
OpenPanel.Group -> GroupView(GroupState(groups[id]), navs)
OpenPanel.Assignment -> {
when (val a = mergedAssignments[id]) {
is Assignment.SAssignment -> SoloAssignmentView(SoloAssignmentState(a.assignment))
is Assignment.GAssignment -> GroupAssignmentView(GroupAssignmentState(a.assignment))
is Assignment.PeerEval -> PeerEvaluationView(PeerEvaluationState(a.evaluation))
}
}
} }
Box(Modifier.weight(0.75f)) {
idx?.let { i ->
when(i.p) {
Panel.Student -> StudentView(StudentState(students[i.i], state.edition))
Panel.Group -> GroupView(GroupState(groups[i.i]))
Panel.GroupAs -> GroupAssignmentView(GroupAssignmentState(groupAs[i.i]))
else -> {}
} }
} }
} }
} }
@Composable @Composable
fun <T> EditionSideWidget( fun StudentPanel(
course: Course, edition: Edition, header: String, hasNoX: String, addX: String, course: Course, edition: Edition, students: List<Student>, available: List<Student>,
data: List<T>, selected: Int?, onSelect: (Int) -> Unit, selected: Int, onSelect: (Int) -> Unit,
singleWidget: @Composable (T) -> Unit, onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit,
editDialog: @Composable ((current: T, onExit: () -> Unit) -> Unit)? = null, onImport: (List<Student>) -> Unit, onUpdate: (Student, String) -> Unit, onDelete: (Student) -> Unit
dialog: @Composable (onExit: () -> Unit) -> Unit
) = Column(Modifier.padding(10.dp)) { ) = Column(Modifier.padding(10.dp)) {
Text(header, style = MaterialTheme.typography.headlineMedium)
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
var current by remember { mutableStateOf<T?>(null) } var deleting by remember { mutableStateOf(-1) }
var editing by remember { mutableStateOf(-1) }
Text("Student list (${students.size})", style = MaterialTheme.typography.headlineMedium)
ListOrEmpty( ListOrEmpty(
data, students,
{ Text("Course ${course.name} (edition ${edition.name})\nhas no $hasNoX yet.", Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) }, { Text(
{ Text("Add $addX") }, "Course ${course.name} (edition ${edition.name})\nhas no students yet.",
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
) },
{ Text("Add a student") },
{ showDialog = true } { showDialog = true }
) { idx, it -> ) { idx, it ->
Surface( SelectEditDeleteRow(
Modifier.fillMaxWidth().clickable { onSelect(idx) }, selected == idx,
tonalElevation = if (selected == idx) 50.dp else 0.dp, { onSelect(idx) }, { onSelect(-1) },
shape = MaterialTheme.shapes.medium { editing = idx }, { deleting = idx }
) { ) {
Row { Text(it.name, Modifier.padding(5.dp))
Box(Modifier.weight(1f).align(Alignment.CenterVertically)) { singleWidget(it) }
editDialog?.let { _ ->
IconButton({ current = it }, Modifier.align(Alignment.CenterVertically)) {
Icon(Icons.Default.Edit, "Edit")
}
}
}
} }
} }
if(showDialog) dialog { showDialog = false } if(showDialog) {
editDialog?.let { d -> StudentDialog(course, edition, { showDialog = false }, available, onImport, onAdd)
current?.let { c ->
d(c) { current = null }
} }
else if(editing != -1) {
AddStringDialog("Student name", students.map { it.name }, { editing = -1 }, students[editing].name) {
onUpdate(students[editing], it)
}
}
else if(deleting != -1) {
ConfirmDeleteDialog(
"a student",
{ deleting = -1 },
{ onDelete(students[deleting]) }
) { Text(students[deleting].name) }
} }
} }
@Composable @Composable
fun StudentsWidget( fun GroupPanel(
course: Course, edition: Edition, students: List<Student>, selected: Int?, onSelect: (Int) -> Unit, course: Course, edition: Edition, groups: List<Group>,
availableStudents: List<Student>, onImport: (List<Student>) -> Unit, selected: Int, onSelect: (Int) -> Unit,
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit onAdd: (String) -> Unit, onUpdate: (Group, String) -> Unit, onDelete: (Group) -> Unit
) = EditionSideWidget( ) = Column(Modifier.padding(10.dp)) {
course, edition, "Student list (${students.size})", "students", "a student", students, selected, onSelect, var showDialog by remember { mutableStateOf(false) }
{ Text(it.name, Modifier.padding(5.dp)) } var deleting by remember { mutableStateOf(-1) }
) { onExit -> var editing by remember { mutableStateOf(-1) }
StudentDialog(course, edition, onExit, availableStudents, onImport, onAdd)
Text("Group list (${groups.size})", style = MaterialTheme.typography.headlineMedium)
ListOrEmpty(
groups,
{ Text(
"Course ${course.name} (edition ${edition.name})\nhas no groups yet.",
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
) },
{ Text("Add a group") },
{ showDialog = true }
) { idx, it ->
SelectEditDeleteRow(
selected == idx,
{ onSelect(idx) }, { onSelect(-1) },
{ editing = idx }, { deleting = idx }
) {
Text(it.name, Modifier.padding(5.dp))
}
}
if(showDialog) {
AddStringDialog("Group name", groups.map{ it.name }, { showDialog = false }) { onAdd(it) }
}
else if(editing != -1) {
AddStringDialog("Group name", groups.map { it.name }, { editing = -1 }, groups[editing].name) {
onUpdate(groups[editing], it)
}
}
else if(deleting != -1) {
ConfirmDeleteDialog(
"a group",
{ deleting = -1 },
{ onDelete(groups[deleting]) }
) { Text(groups[deleting].name) }
}
}
@Composable
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
) = Column(Modifier.padding(10.dp)) {
var showDialog by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(-1) }
var editing by remember { mutableStateOf(-1) }
val dialog: @Composable (String, List<String>, () -> Unit, String, (AssignmentType, String) -> Unit) -> Unit =
{ label, taken, onClose, current, onSave ->
DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
) {
var name by remember(current) { mutableStateOf(current) }
var tab by remember { mutableStateOf(AssignmentType.Solo) }
Surface(Modifier.fillMaxSize()) {
TabLayout(
AssignmentType.entries,
tab.ordinal,
{ tab = AssignmentType.entries[it] },
{ Text(it.show) }
) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
Column(Modifier.align(Alignment.Center)) {
OutlinedTextField(
name,
{ name = it },
Modifier.fillMaxWidth(),
label = { Text(label) },
isError = name in taken
)
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
onSave(tab, name)
onClose()
}
}
}
}
}
}
}
Text("Assignment list (${assignments.size})", style = MaterialTheme.typography.headlineMedium)
ListOrEmpty(
assignments,
{ Text(
"Course ${course.name} (edition ${edition.name})\nhas no assignments yet.",
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
) },
{ Text("Add an assignment") },
{ showDialog = true }
) { idx, it ->
Selectable(
selected == idx,
{ onSelect(idx) }, { onSelect(-1) }
) {
Row {
Text(it.name(), Modifier.padding(5.dp).align(Alignment.CenterVertically).weight(1f))
Column(Modifier.padding(2.dp)) {
Icon(Icons.Default.ArrowUpward, "Move up", Modifier.clickable {
if(idx > 0) onSwapOrder(assignments[idx], assignments[idx - 1])
})
Icon(Icons.Default.ArrowDownward, "Move down", Modifier.clickable {
if(idx < assignments.size - 1) onSwapOrder(assignments[idx], assignments[idx + 1])
})
}
Column(Modifier.padding(2.dp)) {
Icon(Icons.Default.Edit, "Edit", Modifier.clickable { editing = idx })
Icon(Icons.Default.Delete, "Delete", Modifier.clickable { deleting = idx })
}
}
}
}
if(showDialog) {
dialog("Assignment name", assignments.map{ it.name() }, { showDialog = false }, "", onAdd)
}
else if(editing != -1) {
AddStringDialog("Assignment name", assignments.map { it.name() }, { editing = -1 }, assignments[editing].name()) {
onUpdate(assignments[editing], it)
}
}
else if(deleting != -1) {
ConfirmDeleteDialog(
"an assignment",
{ deleting = -1 },
{ onDelete(assignments[deleting]) }
) { Text(assignments[deleting].name()) }
}
} }
@Composable @Composable
@ -262,39 +417,3 @@ fun StudentDialog(
} }
} }
} }
@Composable
fun GroupsWidget(
course: Course, edition: Edition, groups: List<Group>, selected: Int?, onSelect: (Int) -> Unit,
onAdd: (name: String) -> Unit, onUpdate: (Group, String) -> Unit
) = EditionSideWidget(
course, edition, "Group list (${groups.size})", "groups", "a group", groups, selected, onSelect,
{ Text(it.name, Modifier.padding(5.dp)) },
{ current, onExit -> AddStringDialog("Group name", groups.map { it.name }, onExit, current.name) { onUpdate(current, it) } }
) { onExit ->
AddStringDialog("Group name", groups.map { it.name }, onExit) { onAdd(it) }
}
@Composable
fun AssignmentsWidget(
course: Course, edition: Edition, assignments: List<SoloAssignment>, selected: Int?,
onSelect: (Int) -> Unit, onAdd: (name: String) -> Unit, onUpdate: (SoloAssignment, String) -> Unit
) = EditionSideWidget(
course, edition, "Assignment list", "assignments", "an assignment", assignments, selected, onSelect,
{ Text(it.name, Modifier.padding(5.dp)) },
{ current, onExit -> AddStringDialog("Assignment title", assignments.map { it.name }, onExit, current.name) { onUpdate(current, it) } }
) { onExit ->
AddStringDialog("Assignment title", assignments.map { it.name }, onExit) { onAdd(it) }
}
@Composable
fun GroupAssignmentsWidget(
course: Course, edition: Edition, assignments: List<GroupAssignment>, selected: Int?,
onSelect: (Int) -> Unit, onAdd: (name: String) -> Unit, onUpdate: (GroupAssignment, String) -> Unit
) = EditionSideWidget(
course, edition, "Group assignment list", "group assignments", "an assignment", assignments, selected, onSelect,
{ Text(it.name, Modifier.padding(5.dp)) },
{ current, onExit -> AddStringDialog("Assignment title", assignments.map { it.name }, onExit, current.name) { onUpdate(current, it) } }
) { onExit ->
AddStringDialog("Assignment title", assignments.map { it.name }, onExit) { onAdd(it) }
}

View File

@ -0,0 +1,51 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.jaytux.grader.viewmodel.immutable
interface MeasuredLazyListScope : LazyListScope {
fun measuredWidth(): State<Dp>
fun measuredItem(content: @Composable MeasuredLazyItemScope.() -> Unit)
}
interface MeasuredLazyItemScope : LazyItemScope {
fun measuredWidth(): State<Dp>
}
@Composable
fun MeasuredLazyColumn(modifier: Modifier = Modifier, key: Any? = null, content: MeasuredLazyListScope.() -> Unit) {
val measuredWidth = remember(key) { mutableStateOf(0.dp) }
LazyColumn(modifier.onGloballyPositioned {
measuredWidth.value = it.size.width.dp
}) {
val lisToMlis = { lis: LazyItemScope ->
object : MeasuredLazyItemScope, LazyItemScope by lis {
override fun measuredWidth(): State<Dp> = measuredWidth.immutable()
}
}
val scope = object : MeasuredLazyListScope, LazyListScope by this {
override fun measuredWidth(): State<Dp> = measuredWidth.immutable()
override fun measuredItem(content: @Composable MeasuredLazyItemScope.() -> Unit) {
item {
lisToMlis(this).content()
}
}
}
scope.content()
}
}

View File

@ -1,42 +1,46 @@
package com.jaytux.grader.ui package com.jaytux.grader.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi import com.jaytux.grader.loadClipboard
import com.jaytux.grader.toClipboard
import com.mohamedrejeb.richeditor.model.RichTextState import com.mohamedrejeb.richeditor.model.RichTextState
@OptIn(ExperimentalRichTextApi::class)
@Composable @Composable
fun RichTextStyleRow( fun RichTextStyleRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: RichTextState, state: RichTextState,
) { ) {
val clip = LocalClipboardManager.current
val scope = rememberCoroutineScope()
Row(modifier.fillMaxWidth()) {
LazyRow( LazyRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier modifier = Modifier.weight(1f)
) { ) {
item { item {
RichTextStyleButton( RichTextStyleButton(
@ -186,6 +190,14 @@ fun RichTextStyleRow(
) )
} }
} }
IconButton({ state.toClipboard(clip) }) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy markdown")
}
IconButton({ state.loadClipboard(clip, scope) }) {
Icon(Icons.Default.ContentPaste, contentDescription = "Paste markdown")
}
}
} }
@Composable @Composable

View File

@ -22,14 +22,13 @@ import com.jaytux.grader.viewmodel.GroupState
import com.jaytux.grader.viewmodel.StudentState import com.jaytux.grader.viewmodel.StudentState
@Composable @Composable
fun StudentView(state: StudentState) { fun StudentView(state: StudentState, nav: Navigators) {
val groups by state.groups.entities val groups by state.groups.entities
val courses by state.courseEditions.entities val courses by state.courseEditions.entities
val groupGrades by state.groupGrades.entities val groupGrades by state.groupGrades.entities
val soloGrades by state.soloGrades.entities val soloGrades by state.soloGrades.entities
Column(Modifier.padding(10.dp)) { Column(Modifier.padding(10.dp)) {
PaneHeader(state.student.name, "student", state.editionCourse)
Row { Row {
Column(Modifier.weight(0.45f)) { Column(Modifier.weight(0.45f)) {
Column(Modifier.padding(10.dp).weight(0.35f)) { Column(Modifier.padding(10.dp).weight(0.35f)) {
@ -48,9 +47,9 @@ fun StudentView(state: StudentState) {
Column(Modifier.weight(0.45f)) { Column(Modifier.weight(0.45f)) {
Text("Groups", style = MaterialTheme.typography.headlineSmall) Text("Groups", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(groups, { Text("Not a member of any group") }) { _, it -> ListOrEmpty(groups, { Text("Not a member of any group") }) { _, it ->
Row {
val (group, c) = it val (group, c) = it
val (course, ed) = c val (course, ed) = c
Row(Modifier.clickable { nav.group(group) }) {
Text(group.name, style = MaterialTheme.typography.bodyMedium) Text(group.name, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.width(5.dp)) Spacer(Modifier.width(5.dp))
Text( Text(
@ -144,22 +143,20 @@ fun soloGradeWidget(sg: StudentState.LocalSoloGrade) {
} }
@Composable @Composable
fun GroupView(state: GroupState) { fun GroupView(state: GroupState, nav: Navigators) {
val members by state.members.entities val members by state.members.entities
val available by state.availableStudents.entities val available by state.availableStudents.entities
val allRoles by state.roles.entities val allRoles by state.roles.entities
val (course, edition) = state.course
var pickRole: Pair<String?, (String?) -> Unit>? by remember { mutableStateOf(null) } var pickRole: Pair<String?, (String?) -> Unit>? by remember { mutableStateOf(null) }
Column(Modifier.padding(10.dp)) { Column(Modifier.padding(10.dp)) {
PaneHeader(state.group.name, "group", course, edition)
Row { Row {
Column(Modifier.weight(0.5f)) { Column(Modifier.weight(0.5f)) {
Text("Students", style = MaterialTheme.typography.headlineSmall) Text("Students", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(members, { Text("No students in this group") }) { _, it -> ListOrEmpty(members, { Text("No students in this group") }) { _, it ->
val (student, role) = it val (student, role) = it
Row { Row(Modifier.clickable { nav.student(student) }) {
Text( Text(
"${student.name} (${role ?: "no role"})", "${student.name} (${role ?: "no role"})",
Modifier.weight(0.75f).align(Alignment.CenterVertically), Modifier.weight(0.75f).align(Alignment.CenterVertically),
@ -177,7 +174,7 @@ fun GroupView(state: GroupState) {
Column(Modifier.weight(0.5f)) { Column(Modifier.weight(0.5f)) {
Text("Available students", style = MaterialTheme.typography.headlineSmall) Text("Available students", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(available, { Text("No students available") }) { _, it -> ListOrEmpty(available, { Text("No students available") }) { _, it ->
Row(Modifier.padding(5.dp)) { Row(Modifier.padding(5.dp).clickable { nav.student(it) }) {
IconButton({ state.addStudent(it) }) { IconButton({ state.addStudent(it) }) {
Icon(ChevronLeft, "Add student") Icon(ChevronLeft, "Add student")
} }

View File

@ -1,38 +1,43 @@
package com.jaytux.grader.ui package com.jaytux.grader.ui
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontStyle 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.input.TextFieldValue
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.* import androidx.compose.ui.window.*
import com.jaytux.grader.data.Course import com.jaytux.grader.data.Course
import com.jaytux.grader.data.Edition import com.jaytux.grader.data.Edition
import com.jaytux.grader.viewmodel.PeerEvaluationState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.* import kotlinx.datetime.*
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.format.DateTimeFormat
import kotlinx.datetime.format.byUnicodePattern
import java.util.* import java.util.*
@Composable @Composable
@ -74,7 +79,7 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
Box(Modifier.fillMaxSize().padding(10.dp)) { Box(Modifier.fillMaxSize().padding(10.dp)) {
var name by remember(current) { mutableStateOf(current) } var name by remember(current) { mutableStateOf(current) }
Column(Modifier.align(Alignment.Center)) { Column(Modifier.align(Alignment.Center)) {
androidx.compose.material.OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text(label) }, isError = name in taken) OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text(label) }, isError = name in taken)
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) { CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
onSave(name) onSave(name)
onClose() onClose()
@ -84,6 +89,60 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
} }
} }
@Composable
fun ConfirmDeleteDialog(
deleteAWhat: String,
onExit: () -> Unit,
onDelete: () -> Unit,
render: @Composable () -> Unit
) = DialogWindow(
onCloseRequest = onExit,
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
) {
Surface(Modifier.width(400.dp).height(300.dp), tonalElevation = 5.dp) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
Column(Modifier.align(Alignment.Center)) {
Text("You are about to delete $deleteAWhat.", Modifier.padding(10.dp))
render()
CancelSaveRow(true, onExit, "Cancel", "Delete") {
onDelete()
onExit()
}
}
}
}
}
@Composable
fun <T> ListOrEmpty(
data: List<T>,
onEmpty: @Composable ColumnScope.() -> Unit,
addOptions: @Composable ColumnScope.() -> Unit,
addAfterLazy: Boolean = true,
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
) {
if(data.isEmpty()) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center)) {
onEmpty()
addOptions()
}
}
}
else {
Column {
LazyColumn(Modifier.weight(1f)) {
itemsIndexed(data) { idx, it ->
item(idx, it)
}
if(!addAfterLazy) item { addOptions() }
}
if(addAfterLazy) addOptions()
}
}
}
@Composable @Composable
fun <T> ListOrEmpty( fun <T> ListOrEmpty(
data: List<T>, data: List<T>,
@ -92,41 +151,12 @@ fun <T> ListOrEmpty(
onAdd: () -> Unit, onAdd: () -> Unit,
addAfterLazy: Boolean = true, addAfterLazy: Boolean = true,
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
) { ) = ListOrEmpty(
if(data.isEmpty()) { data, emptyText,
Box(Modifier.fillMaxSize()) { { Button(onAdd, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) { addText() } },
Column(Modifier.align(Alignment.Center)) { addAfterLazy,
emptyText() item
Button(onAdd, Modifier.align(Alignment.CenterHorizontally)) { )
addText()
}
}
}
}
else {
Column {
LazyColumn(Modifier.padding(5.dp).weight(1f)) {
itemsIndexed(data) { idx, it ->
item(idx, it)
}
if(!addAfterLazy) {
item {
Button(onAdd, Modifier.fillMaxWidth()) {
addText()
}
}
}
}
if(addAfterLazy) {
Button(onAdd, Modifier.fillMaxWidth()) {
addText()
}
}
}
}
}
@Composable @Composable
fun <T> ListOrEmpty( fun <T> ListOrEmpty(
@ -349,3 +379,82 @@ fun ItalicAndNormal(italic: String, normal: String) = Row{
Text(italic, fontStyle = FontStyle.Italic) Text(italic, fontStyle = FontStyle.Italic)
Text(normal) Text(normal)
} }
@Composable
fun Selectable(
isSelected: Boolean,
onSelect: () -> Unit, onDeselect: () -> Unit,
unselectedElevation: Dp = 0.dp, selectedElevation: Dp = 50.dp,
content: @Composable () -> Unit
) {
Surface(
Modifier.fillMaxWidth().clickable { if(isSelected) onDeselect() else onSelect() },
tonalElevation = if (isSelected) selectedElevation else unselectedElevation,
shape = MaterialTheme.shapes.medium
) {
content()
}
}
@Composable
fun SelectEditDeleteRow(
isSelected: Boolean,
onSelect: () -> Unit, onDeselect: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
content: @Composable BoxScope.() -> Unit
) = Selectable(isSelected, onSelect, onDeselect) {
Row {
Box(Modifier.weight(1f).align(Alignment.CenterVertically)) { content() }
IconButton(onEdit, Modifier.align(Alignment.CenterVertically)) {
Icon(Icons.Default.Edit, "Edit")
}
IconButton(onDelete, Modifier.align(Alignment.CenterVertically)) {
Icon(Icons.Default.Delete, "Delete")
}
}
}
@Composable
fun FromTo(size: Dp) {
var w by remember { mutableStateOf(0) }
var h by remember { mutableStateOf(0) }
Box(Modifier.width(size).height(size).onGloballyPositioned {
w = it.size.width
h = it.size.height
}) {
Box(Modifier.align(Alignment.BottomStart)) {
Text("Evaluator", fontWeight = FontWeight.Bold)
}
Box {
Text("Evaluated", Modifier.graphicsLayer {
rotationZ = -90f
translationX = w - 15f
translationY = h - 15f
transformOrigin = TransformOrigin(0f, 0.5f)
}, fontWeight = FontWeight.Bold)
}
}
}
@Composable
fun PEGradeWidget(
grade: PeerEvaluationState.Student2StudentEntry?,
onSelect: () -> Unit, onDeselect: () -> Unit,
isSelected: Boolean,
modifier: Modifier = Modifier
) = Box(modifier.padding(2.dp)) {
Selectable(isSelected, onSelect, onDeselect) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(grade?.let { if(it.grade.isNotBlank()) it.grade else if(it.feedback.isNotBlank()) "(other)" else null } ?: "none")
}
}
}
@Composable
fun VLine(width: Dp = 1.dp, color: Color = Color.Black) = Spacer(Modifier.fillMaxHeight().width(width).background(color))
@Composable
fun MeasuredLazyItemScope.HLine(height: Dp = 1.dp, color: Color = Color.Black) {
val width by measuredWidth()
Spacer(Modifier.width(width).height(height).background(color))
}

View File

@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import com.jaytux.grader.data.* import com.jaytux.grader.data.*
import com.jaytux.grader.data.EditionStudents.editionId import com.jaytux.grader.data.EditionStudents.editionId
import com.jaytux.grader.data.EditionStudents.studentId import com.jaytux.grader.data.EditionStudents.studentId
import com.jaytux.grader.viewmodel.GroupAssignmentState.*
import kotlinx.datetime.* import kotlinx.datetime.*
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.EntityID
@ -13,10 +14,47 @@ import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import java.util.* import java.util.*
import kotlin.math.max
fun <T> MutableState<T>.immutable(): State<T> = this fun <T> MutableState<T>.immutable(): State<T> = this
fun <T> SizedIterable<T>.sortAsc(vararg columns: Expression<*>) = this.orderBy(*(columns.map { it to SortOrder.ASC }.toTypedArray())) fun <T> SizedIterable<T>.sortAsc(vararg columns: Expression<*>) = this.orderBy(*(columns.map { it to SortOrder.ASC }.toTypedArray()))
enum class AssignmentType(val show: String) { Solo("Solo Assignment"), Group("Group Assignment"), Peer("Peer Evaluation") }
sealed class Assignment {
class GAssignment(val assignment: GroupAssignment) : Assignment() {
override fun name(): String = assignment.name
override fun id(): EntityID<UUID> = assignment.id
override fun index(): Int? = assignment.number
}
class SAssignment(val assignment: SoloAssignment) : Assignment() {
override fun name(): String = assignment.name
override fun id(): EntityID<UUID> = assignment.id
override fun index(): Int? = assignment.number
}
class PeerEval(val evaluation: com.jaytux.grader.data.PeerEvaluation) : Assignment() {
override fun name(): String = evaluation.name
override fun id(): EntityID<UUID> = evaluation.id
override fun index(): Int? = evaluation.number
}
abstract fun name(): String
abstract fun id(): EntityID<UUID>
abstract fun index(): Int?
companion object {
fun from(assignment: GroupAssignment) = GAssignment(assignment)
fun from(assignment: SoloAssignment) = SAssignment(assignment)
fun from(pEval: PeerEvaluation) = PeerEval(pEval)
fun merge(groups: List<GroupAssignment>, solos: List<SoloAssignment>, peers: List<PeerEvaluation>): List<Assignment> {
val g = groups.map { from(it) }
val s = solos.map { from(it) }
val p = peers.map { from(it) }
return (g + s + p).sortedWith(compareBy<Assignment> { it.index() }.thenBy { it.name() })
}
}
}
class RawDbState<T: Any>(private val loader: (Transaction.() -> List<T>)) { class RawDbState<T: Any>(private val loader: (Transaction.() -> List<T>)) {
private val rawEntities by lazy { private val rawEntities by lazy {
@ -59,12 +97,19 @@ class EditionListState(val course: Course) {
} }
} }
enum class OpenPanel(val tabName: String) {
Student("Students"), Group("Groups"), Assignment("Assignments")
}
class EditionState(val edition: Edition) { class EditionState(val edition: Edition) {
val course = transaction { edition.course } val course = transaction { edition.course }
val students = RawDbState { edition.soloStudents.sortAsc(Students.name).toList() } val students = RawDbState { edition.soloStudents.sortAsc(Students.name).toList() }
val groups = RawDbState { edition.groups.sortAsc(Groups.name).toList() } val groups = RawDbState { edition.groups.sortAsc(Groups.name).toList() }
val solo = RawDbState { edition.soloAssignments.sortAsc(SoloAssignments.name).toList() } val solo = RawDbState { edition.soloAssignments.sortAsc(SoloAssignments.name).toList() }
val groupAs = RawDbState { edition.groupAssignments.sortAsc(GroupAssignments.name).toList() } val groupAs = RawDbState { edition.groupAssignments.sortAsc(GroupAssignments.name).toList() }
val peer = RawDbState { edition.peerEvaluations.sortAsc(PeerEvaluations.name).toList() }
private val _history = mutableStateOf(listOf(-1 to OpenPanel.Assignment))
val history = _history.immutable()
val availableStudents = RawDbState { val availableStudents = RawDbState {
Student.find { Student.find {
@ -84,7 +129,12 @@ class EditionState(val edition: Edition) {
if(addToEdition) students.refresh() if(addToEdition) students.refresh()
else availableStudents.refresh() else availableStudents.refresh()
} }
fun setStudentName(student: Student, name: String) {
transaction {
student.name = name
}
students.refresh()
}
fun addToCourse(students: List<Student>) { fun addToCourse(students: List<Student>) {
transaction { transaction {
EditionStudents.batchInsert(students) { EditionStudents.batchInsert(students) {
@ -92,7 +142,7 @@ class EditionState(val edition: Edition) {
this[studentId] = it.id this[studentId] = it.id
} }
} }
availableStudents.refresh(); availableStudents.refresh()
this.students.refresh() this.students.refresh()
} }
@ -114,9 +164,17 @@ class EditionState(val edition: Edition) {
return instant.toLocalDateTime(TimeZone.currentSystemDefault()) return instant.toLocalDateTime(TimeZone.currentSystemDefault())
} }
private fun nextIdx(): Int = max(
solo.entities.value.maxOfOrNull { it.number ?: 0 } ?: 0,
groupAs.entities.value.maxOfOrNull { it.number ?: 0 } ?: 0
) + 1
fun newSoloAssignment(name: String) { fun newSoloAssignment(name: String) {
transaction { transaction {
SoloAssignment.new { this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now() } SoloAssignment.new {
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
this.number = nextIdx()
}
solo.refresh() solo.refresh()
} }
} }
@ -128,7 +186,10 @@ class EditionState(val edition: Edition) {
} }
fun newGroupAssignment(name: String) { fun newGroupAssignment(name: String) {
transaction { transaction {
GroupAssignment.new { this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now() } GroupAssignment.new {
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
this.number = nextIdx()
}
groupAs.refresh() groupAs.refresh()
} }
} }
@ -138,6 +199,161 @@ class EditionState(val edition: Edition) {
} }
groupAs.refresh() groupAs.refresh()
} }
fun newPeerEvaluation(name: String) {
transaction {
PeerEvaluation.new {
this.name = name; this.edition = this@EditionState.edition
this.number = nextIdx()
}
peer.refresh()
}
}
fun setPeerEvaluationTitle(assignment: PeerEvaluation, title: String) {
transaction {
assignment.name = title
}
peer.refresh()
}
fun newAssignment(type: AssignmentType, name: String) = when(type) {
AssignmentType.Solo -> newSoloAssignment(name)
AssignmentType.Group -> newGroupAssignment(name)
AssignmentType.Peer -> newPeerEvaluation(name)
}
fun setAssignmentTitle(assignment: Assignment, title: String) = when(assignment) {
is Assignment.GAssignment -> setGroupAssignmentTitle(assignment.assignment, title)
is Assignment.SAssignment -> setSoloAssignmentTitle(assignment.assignment, title)
is Assignment.PeerEval -> setPeerEvaluationTitle(assignment.evaluation, title)
}
fun swapOrder(a1: Assignment, a2: Assignment) {
transaction {
when(a1) {
is Assignment.GAssignment -> {
when(a2) {
is Assignment.GAssignment -> {
val temp = a1.assignment.number
a1.assignment.number = a2.assignment.number
a2.assignment.number = temp
}
is Assignment.SAssignment -> {
val temp = a1.assignment.number
a1.assignment.number = nextIdx()
a2.assignment.number = temp
}
is Assignment.PeerEval -> {
val temp = a1.assignment.number
a1.assignment.number = nextIdx()
a2.evaluation.number = temp
}
}
}
is Assignment.SAssignment -> {
when(a2) {
is Assignment.GAssignment -> {
val temp = a1.assignment.number
a1.assignment.number = a2.assignment.number
a2.assignment.number = temp
}
is Assignment.SAssignment -> {
val temp = a1.assignment.number
a1.assignment.number = a2.assignment.number
a2.assignment.number = temp
}
is Assignment.PeerEval -> {
val temp = a1.assignment.number
a1.assignment.number = nextIdx()
a2.evaluation.number = temp
}
}
}
is Assignment.PeerEval -> {
when(a2) {
is Assignment.GAssignment -> {
val temp = a1.evaluation.number
a1.evaluation.number = a2.assignment.number
a2.assignment.number = temp
}
is Assignment.SAssignment -> {
val temp = a1.evaluation.number
a1.evaluation.number = a2.assignment.number
a2.assignment.number = temp
}
is Assignment.PeerEval -> {
val temp = a1.evaluation.number
a1.evaluation.number = a2.evaluation.number
a2.evaluation.number = temp
}
}
}
}
}
solo.refresh(); groupAs.refresh()
}
fun delete(s: Student) {
transaction {
EditionStudents.deleteWhere { studentId eq s.id }
GroupStudents.deleteWhere { studentId eq s.id }
IndividualFeedbacks.deleteWhere { studentId eq s.id }
}
students.refresh(); availableStudents.refresh()
}
fun delete(g: Group) {
transaction {
GroupFeedbacks.deleteWhere { groupId eq g.id }
IndividualFeedbacks.deleteWhere { groupId eq g.id }
GroupStudents.deleteWhere { groupId eq g.id }
g.delete()
}
groups.refresh(); groupAs.refresh()
}
fun delete(sa: SoloAssignment) {
transaction {
SoloAssignmentCriteria.selectAll().where { SoloAssignmentCriteria.assignmentId eq sa.id }.forEach { it ->
val id = it[SoloAssignmentCriteria.assignmentId]
SoloFeedbacks.deleteWhere { criterionId eq id }
}
SoloAssignmentCriteria.deleteWhere { assignmentId eq sa.id }
sa.delete()
}
solo.refresh()
}
fun delete(ga: GroupAssignment) {
transaction {
GroupAssignmentCriteria.selectAll().where { GroupAssignmentCriteria.assignmentId eq ga.id }.forEach { it ->
val id = it[GroupAssignmentCriteria.assignmentId]
GroupFeedbacks.deleteWhere { criterionId eq id }
IndividualFeedbacks.deleteWhere { criterionId eq id }
}
GroupAssignmentCriteria.deleteWhere { assignmentId eq ga.id }
ga.delete()
}
groupAs.refresh()
}
fun delete(pe: PeerEvaluation) {
transaction {
PeerEvaluationContents.deleteWhere { peerEvaluationId eq pe.id }
StudentToStudentEvaluation.deleteWhere { peerEvaluationId eq pe.id }
pe.delete()
}
peer.refresh()
}
fun delete(assignment: Assignment) = when(assignment) {
is Assignment.GAssignment -> delete(assignment.assignment)
is Assignment.SAssignment -> delete(assignment.assignment)
is Assignment.PeerEval -> delete(assignment.evaluation)
}
fun navTo(panel: OpenPanel, id: Int = -1) {
_history.value += (id to panel)
}
fun navTo(id: Int) = navTo(_history.value.last().second, id)
fun back() {
var temp = _history.value.dropLast(1)
while(temp.last().first == -1 && temp.size >= 2) temp = temp.dropLast(1)
_history.value = temp
}
} }
class StudentState(val student: Student, edition: Edition) { class StudentState(val student: Student, edition: Edition) {
@ -155,13 +371,15 @@ class StudentState(val student: Student, edition: Edition) {
(Groups.editionId eq edition.id) and (Groups.id inList student.groups.map { it.id }) (Groups.editionId eq edition.id) and (Groups.id inList student.groups.map { it.id })
}.associate { it.id to it.name } }.associate { it.id to it.name }
val asGroup = (GroupAssignments innerJoin GroupFeedbacks innerJoin Groups).selectAll().where { val asGroup = (GroupAssignments innerJoin GroupAssignmentCriteria innerJoin GroupFeedbacks innerJoin Groups).selectAll().where {
GroupFeedbacks.groupId inList groupsForEdition.keys.toList() (GroupFeedbacks.groupId inList groupsForEdition.keys.toList()) and
}.map { it[GroupFeedbacks.groupAssignmentId] to it } (GroupAssignmentCriteria.name eq "")
}.map { it[GroupAssignments.id] to it }
val asIndividual = (GroupAssignments innerJoin IndividualFeedbacks innerJoin Groups).selectAll().where { val asIndividual = (GroupAssignments innerJoin GroupAssignmentCriteria innerJoin IndividualFeedbacks innerJoin Groups).selectAll().where {
IndividualFeedbacks.studentId eq student.id (IndividualFeedbacks.studentId eq student.id) and
}.map { it[IndividualFeedbacks.groupAssignmentId] to it } (GroupAssignmentCriteria.name eq "")
}.map { it[GroupAssignments.id] to it }
val res = mutableMapOf<EntityID<UUID>, LocalGroupGrade>() val res = mutableMapOf<EntityID<UUID>, LocalGroupGrade>()
asGroup.forEach { asGroup.forEach {
@ -183,8 +401,9 @@ class StudentState(val student: Student, edition: Edition) {
} }
val soloGrades = RawDbState { val soloGrades = RawDbState {
(SoloAssignments innerJoin SoloFeedbacks).selectAll().where { (SoloAssignments innerJoin SoloAssignmentCriteria innerJoin SoloFeedbacks).selectAll().where {
SoloFeedbacks.studentId eq student.id (SoloFeedbacks.studentId eq student.id) and
(SoloAssignmentCriteria.name eq "")
}.map { LocalSoloGrade(it[SoloAssignments.name], it[SoloFeedbacks.grade]) }.toList() }.map { LocalSoloGrade(it[SoloAssignments.name], it[SoloFeedbacks.grade]) }.toList()
} }
@ -238,25 +457,34 @@ class GroupState(val group: Group) {
} }
class GroupAssignmentState(val assignment: GroupAssignment) { class GroupAssignmentState(val assignment: GroupAssignment) {
data class LocalFeedback(val feedback: String, val grade: String) data class FeedbackEntry(val feedback: String, val grade: String)
data class LocalCriterionFeedback(
val criterion: GroupAssignmentCriterion, val entry: FeedbackEntry?
)
data class LocalFeedback(
val global: FeedbackEntry?, val byCriterion: List<LocalCriterionFeedback>
)
data class LocalGFeedback( data class LocalGFeedback(
val group: Group, val group: Group,
val feedback: LocalFeedback?, val feedback: LocalFeedback,
val individuals: List<Pair<Student, Pair<String?, LocalFeedback?>>> val individuals: List<Pair<Student, Pair<String?, LocalFeedback>>> // Student -> (Role, Feedback)
) )
val editionCourse = transaction { assignment.edition.course to assignment.edition } val editionCourse = transaction { assignment.edition.course to assignment.edition }
private val _name = mutableStateOf(assignment.name); val name = _name.immutable() private val _name = mutableStateOf(assignment.name); val name = _name.immutable()
private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable() private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable()
val feedback = RawDbState { loadFeedback() }
private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable() private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable()
val criteria = RawDbState {
assignment.criteria.orderBy(GroupAssignmentCriteria.name to SortOrder.ASC).toList()
}
val feedback = RawDbState { loadFeedback() }
val autofill = RawDbState { val autofill = RawDbState {
val forGroups = GroupFeedbacks.selectAll().where { GroupFeedbacks.groupAssignmentId eq assignment.id }.flatMap { val forGroups = (GroupFeedbacks innerJoin GroupAssignmentCriteria).selectAll().where { GroupAssignmentCriteria.assignmentId eq assignment.id }.flatMap {
it[GroupFeedbacks.feedback].split('\n') it[GroupFeedbacks.feedback].split('\n')
} }
val forIndividuals = IndividualFeedbacks.selectAll().where { IndividualFeedbacks.groupAssignmentId eq assignment.id }.flatMap { val forIndividuals = (IndividualFeedbacks innerJoin GroupAssignmentCriteria).selectAll().where { GroupAssignmentCriteria.assignmentId eq assignment.id }.flatMap {
it[IndividualFeedbacks.feedback].split('\n') it[IndividualFeedbacks.feedback].split('\n')
} }
@ -264,60 +492,88 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
} }
private fun Transaction.loadFeedback(): List<Pair<Group, LocalGFeedback>> { private fun Transaction.loadFeedback(): List<Pair<Group, LocalGFeedback>> {
val individuals = IndividualFeedbacks.selectAll().where { val allCrit = GroupAssignmentCriterion.find {
IndividualFeedbacks.groupAssignmentId eq assignment.id GroupAssignmentCriteria.assignmentId eq assignment.id
}.map { }
it[IndividualFeedbacks.studentId] to LocalFeedback(it[IndividualFeedbacks.feedback], it[IndividualFeedbacks.grade])
}.associate { it }
val groupFeedbacks = GroupFeedbacks.selectAll().where { return Group.find {
GroupFeedbacks.groupAssignmentId eq assignment.id
}.map {
it[GroupFeedbacks.groupId] to (it[GroupFeedbacks.feedback] to it[GroupFeedbacks.grade])
}.associate { it }
val groups = Group.find {
(Groups.editionId eq assignment.edition.id) (Groups.editionId eq assignment.edition.id)
}.sortAsc(Groups.name).map { group -> }.sortAsc(Groups.name).map { group ->
val students = group.studentRoles.sortedBy { it.student.name }.map { sR -> val forGroup = (GroupFeedbacks innerJoin Groups).selectAll().where {
val student = sR.student (GroupFeedbacks.assignmentId eq assignment.id) and (Groups.id eq group.id)
val role = sR.role }.map { row ->
val feedback = individuals[student.id] val crit = row[GroupFeedbacks.criterionId]?.let { GroupAssignmentCriterion[it] }
val fdbk = row[GroupFeedbacks.feedback]
val grade = row[GroupFeedbacks.grade]
student to (role to feedback) crit to FeedbackEntry(fdbk, grade)
} }
groupFeedbacks[group.id]?.let { (f, g) -> val global = forGroup.firstOrNull { it.first == null }?.second
group to LocalGFeedback(group, LocalFeedback(f, g), students) val byCrit_ = forGroup.map { it.first?.let { k -> LocalCriterionFeedback(k, it.second) } }
} ?: (group to LocalGFeedback(group, null, students)) .filterNotNull().associateBy { it.criterion.id }
val byCrit = allCrit.map { c ->
byCrit_[c.id] ?: LocalCriterionFeedback(c, null)
} }
return groups val byGroup = LocalFeedback(global, byCrit)
val indiv = group.studentRoles.map {
val student = it.student
val role = it.role
val forSt = (IndividualFeedbacks innerJoin Groups innerJoin GroupStudents)
.selectAll().where {
(IndividualFeedbacks.assignmentId eq assignment.id) and
(GroupStudents.studentId eq student.id) and (Groups.id eq group.id)
}.map { row ->
val crit = row[IndividualFeedbacks.criterionId]?.let { id -> GroupAssignmentCriterion[id] }
val fdbk = row[IndividualFeedbacks.feedback]
val grade = row[IndividualFeedbacks.grade]
crit to FeedbackEntry(fdbk, grade)
} }
fun upsertGroupFeedback(group: Group, msg: String, grd: String) { 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 byCrit = allCrit.map { c ->
byCrit_[c.id] ?: LocalCriterionFeedback(c, null)
}
val byStudent = LocalFeedback(global, byCrit)
student to (role to byStudent)
}
group to LocalGFeedback(group, byGroup, indiv)
}
}
fun upsertGroupFeedback(group: Group, msg: String, grd: String, criterion: GroupAssignmentCriterion? = null) {
transaction { transaction {
GroupFeedbacks.upsert { GroupFeedbacks.upsert {
it[groupAssignmentId] = assignment.id it[assignmentId] = assignment.id
it[groupId] = group.id it[groupId] = group.id
it[this.feedback] = msg it[this.feedback] = msg
it[this.grade] = grd it[this.grade] = grd
it[criterionId] = criterion?.id
} }
} }
feedback.refresh() feedback.refresh(); autofill.refresh()
} }
fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String) { fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String, criterion: GroupAssignmentCriterion? = null) {
transaction { transaction {
IndividualFeedbacks.upsert { IndividualFeedbacks.upsert {
it[groupAssignmentId] = assignment.id it[assignmentId] = assignment.id
it[groupId] = group.id it[groupId] = group.id
it[studentId] = student.id it[studentId] = student.id
it[this.feedback] = msg it[this.feedback] = msg
it[this.grade] = grd it[this.grade] = grd
it[criterionId] = criterion?.id
} }
} }
feedback.refresh() feedback.refresh(); autofill.refresh()
} }
fun updateTask(t: String) { fun updateTask(t: String) {
@ -333,6 +589,215 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
} }
_deadline.value = d _deadline.value = d
} }
fun addCriterion(name: String) {
transaction {
GroupAssignmentCriterion.new {
this.name = name;
this.description = "";
this.assignment = this@GroupAssignmentState.assignment
}
criteria.refresh()
}
}
fun updateCriterion(criterion: GroupAssignmentCriterion, name: String, desc: String) {
transaction {
criterion.name = name
criterion.description = desc
}
criteria.refresh()
}
fun deleteCriterion(criterion: GroupAssignmentCriterion) {
transaction {
GroupFeedbacks.deleteWhere { criterionId eq criterion.id }
IndividualFeedbacks.deleteWhere { criterionId eq criterion.id }
criterion.delete()
}
criteria.refresh()
}
}
class SoloAssignmentState(val assignment: SoloAssignment) {
data class LocalFeedback(val feedback: String, val grade: String)
data class FullFeedback(val global: LocalFeedback?, val byCriterion: List<Pair<SoloAssignmentCriterion, LocalFeedback?>>)
val editionCourse = transaction { assignment.edition.course to assignment.edition }
private val _name = mutableStateOf(assignment.name); val name = _name.immutable()
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()
}
val feedback = RawDbState { loadFeedback() }
val autofill = RawDbState {
SoloFeedbacks.selectAll().where { SoloFeedbacks.assignmentId eq assignment.id }.map {
it[SoloFeedbacks.feedback].split('\n')
}.flatten().distinct().sorted()
}
private fun Transaction.loadFeedback(): List<Pair<Student, FullFeedback>> {
val allCrit = SoloAssignmentCriterion.find {
SoloAssignmentCriteria.assignmentId eq assignment.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 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 byCrit = allCrit.map { c ->
byCrit_[c.id] ?: Pair(c, null)
}
student to FullFeedback(global, byCrit)
}
}
fun upsertFeedback(student: Student, msg: String?, grd: String?, criterion: SoloAssignmentCriterion? = null) {
transaction {
SoloFeedbacks.upsert {
it[assignmentId] = assignment.id
it[studentId] = student.id
it[this.feedback] = msg ?: ""
it[this.grade] = grd ?: ""
it[criterionId] = criterion?.id
}
}
feedback.refresh(); autofill.refresh()
}
fun updateTask(t: String) {
transaction {
assignment.assignment = t
}
_task.value = t
}
fun updateDeadline(d: LocalDateTime) {
transaction {
assignment.deadline = d
}
_deadline.value = d
}
fun addCriterion(name: String) {
transaction {
SoloAssignmentCriterion.new {
this.name = name;
this.description = "";
this.assignment = this@SoloAssignmentState.assignment
}
criteria.refresh()
}
}
fun updateCriterion(criterion: SoloAssignmentCriterion, name: String, desc: String) {
transaction {
criterion.name = name
criterion.description = desc
}
criteria.refresh()
}
fun deleteCriterion(criterion: SoloAssignmentCriterion) {
transaction {
SoloFeedbacks.deleteWhere { criterionId eq criterion.id }
criterion.delete()
}
criteria.refresh()
}
}
class PeerEvaluationState(val evaluation: PeerEvaluation) {
data class Student2StudentEntry(val grade: String, val feedback: String)
data class StudentEntry(val student: Student, val global: Student2StudentEntry?, val others: List<Pair<Student, Student2StudentEntry?>>)
data class GroupEntry(val group: Group, val content: String, val students: List<StudentEntry>)
val editionCourse = transaction { evaluation.edition.course to evaluation.edition }
private val _name = mutableStateOf(evaluation.name); val name = _name.immutable()
val contents = RawDbState { loadContents() }
private fun Transaction.loadContents(): List<GroupEntry> {
val found = (Groups leftJoin PeerEvaluationContents).selectAll().where {
Groups.editionId eq evaluation.edition.id
}.associate { gc ->
val group = Group[gc[Groups.id]]
val content = gc[PeerEvaluationContents.content] ?: ""
val students = group.students.map { student1 ->
val others = group.students.map { student2 ->
val eval = StudentToStudentEvaluation.selectAll().where {
StudentToStudentEvaluation.peerEvaluationId eq evaluation.id and
(StudentToStudentEvaluation.studentIdFrom eq student1.id) and
(StudentToStudentEvaluation.studentIdTo eq student2.id)
}.firstOrNull()
student2 to eval?.let {
Student2StudentEntry(
it[StudentToStudentEvaluation.grade], it[StudentToStudentEvaluation.note]
)
}
}.sortedBy { it.first.name }
val global = StudentToGroupEvaluation.selectAll().where {
StudentToGroupEvaluation.peerEvaluationId eq evaluation.id and
(StudentToGroupEvaluation.studentId eq student1.id)
}.firstOrNull()?.let {
Student2StudentEntry(it[StudentToGroupEvaluation.grade], it[StudentToGroupEvaluation.note])
}
StudentEntry(student1, global, others)
}.sortedBy { it.student.name } // enforce synchronized order
group to GroupEntry(group, content, students)
}
return editionCourse.second.groups.map {
found[it] ?: GroupEntry(
it, "",
it.students.map { s1 -> StudentEntry(s1, null, it.students.map { s2 -> s2 to null }) }
)
}
}
fun upsertGroupFeedback(group: Group, feedback: String) {
transaction {
PeerEvaluationContents.upsert {
it[peerEvaluationId] = evaluation.id
it[groupId] = group.id
it[this.content] = feedback
}
}
contents.refresh()
}
fun upsertIndividualFeedback(from: Student, to: Student?, grade: String, feedback: String) {
transaction {
to?.let {
StudentToStudentEvaluation.upsert {
it[peerEvaluationId] = evaluation.id
it[studentIdFrom] = from.id
it[studentIdTo] = to.id
it[this.grade] = grade
it[this.note] = feedback
}
} ?: StudentToGroupEvaluation.upsert {
it[peerEvaluationId] = evaluation.id
it[studentId] = from.id
it[this.grade] = grade
it[this.note] = feedback
}
}
contents.refresh()
}
} }
@ -353,4 +818,3 @@ class GroupAssignmentState(val assignment: GroupAssignment) {