Finished UI overhaul
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -8,6 +8,8 @@ import com.jaytux.grader.data.Database
|
||||
import com.mohamedrejeb.richeditor.model.RichTextState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
import java.time.Clock
|
||||
import java.time.LocalDateTime
|
||||
import java.util.prefs.Preferences
|
||||
@@ -42,3 +44,13 @@ object Preferences {
|
||||
}
|
||||
|
||||
infix fun <T1, T2, T3> Pair<T1, T2>.app(x: T3) = Triple(first, second, x)
|
||||
|
||||
fun startEmail(recipients: List<String>, onError: (String) -> Unit) {
|
||||
if(Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.MAIL)) {
|
||||
val mailTo = "mailto:${recipients.joinToString(",")}"
|
||||
Desktop.getDesktop().mail(URI(mailTo))
|
||||
}
|
||||
else {
|
||||
onError("Email client is not supported on this platform.")
|
||||
}
|
||||
}
|
||||
@@ -108,20 +108,14 @@ object SoloFeedbacks : CompositeIdTable("soloFdbks") {
|
||||
|
||||
object PeerEvaluations : UUIDTable("peerEvals") {
|
||||
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
|
||||
val studentCriterion = reference("student_crit", Criteria.id)
|
||||
}
|
||||
|
||||
object PeerEvaluationFeedbacks : CompositeIdTable("peerEvalFdbks") {
|
||||
val groupId = reference("group_id", Groups.id)
|
||||
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||
|
||||
override val primaryKey = PrimaryKey(groupId, feedbackId)
|
||||
}
|
||||
|
||||
object PeerEvaluationStudentOverrideFeedbacks : UUIDTable("peerEvalStudOvrFdbks") {
|
||||
val groupId = reference("group_id", Groups.id)
|
||||
val studentId = reference("student_id", Students.id)
|
||||
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||
val overrides = reference("overrides", BaseFeedbacks.id)
|
||||
|
||||
override val primaryKey = PrimaryKey(studentId, feedbackId)
|
||||
}
|
||||
|
||||
object PeerEvaluationS2GEvaluations : UUIDTable("peerEvalS2GEvals") {
|
||||
@@ -176,6 +170,6 @@ enum class AssignmentType(val display: String) {
|
||||
val v2Tables = arrayOf(
|
||||
Courses, Editions, Groups, Students, GroupStudents, EditionStudents, BaseAssignments, Criteria, GroupAssignments,
|
||||
SoloAssignments, BaseFeedbacks, GroupFeedbacks, StudentOverrideFeedbacks, SoloFeedbacks, PeerEvaluations,
|
||||
PeerEvaluationFeedbacks, PeerEvaluationStudentOverrideFeedbacks, PeerEvaluationS2GEvaluations,
|
||||
PeerEvaluationS2SEvaluations, CategoricGrades, CategoricGradeOptions, NumericGrades
|
||||
PeerEvaluationFeedbacks, PeerEvaluationS2GEvaluations, PeerEvaluationS2SEvaluations, CategoricGrades,
|
||||
CategoricGradeOptions, NumericGrades
|
||||
)
|
||||
@@ -94,6 +94,7 @@ class PeerEvaluation(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, PeerEvaluation>(PeerEvaluations)
|
||||
|
||||
var base by BaseAssignment referencedOn PeerEvaluations.baseAssignmentId
|
||||
var studentCriterion by Criterion referencedOn PeerEvaluations.studentCriterion
|
||||
}
|
||||
|
||||
class CategoricGrade(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
@@ -143,14 +144,13 @@ class BaseFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
|
||||
private val _forStudentIfSolo by Student via SoloFeedbacks
|
||||
private val _forGroupIfGroup by Group via GroupFeedbacks
|
||||
private val _forGroupIfPeer by Group via PeerEvaluationFeedbacks
|
||||
private val _forStudentIfPeer by Student via PeerEvaluationFeedbacks
|
||||
|
||||
val asSoloFeedback get() = _forStudentIfSolo.singleOrNull()
|
||||
val asGroupFeedback get() = _forGroupIfGroup.singleOrNull()
|
||||
val asPeerEvaluationFeedback get() = _forGroupIfPeer.singleOrNull()
|
||||
val asPeerEvaluationFeedback get() = _forStudentIfPeer.singleOrNull()
|
||||
|
||||
val forStudentsOverrideIfGroup by StudentOverrideFeedback referrersOn StudentOverrideFeedbacks.overrides
|
||||
val forStudentsOverrideIfPeer by PeerEvaluationStudentOverrideFeedback referrersOn PeerEvaluationStudentOverrideFeedbacks.overrides
|
||||
}
|
||||
|
||||
class StudentOverrideFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
@@ -162,15 +162,6 @@ class StudentOverrideFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
var overrides by BaseFeedback referencedOn StudentOverrideFeedbacks.overrides
|
||||
}
|
||||
|
||||
class PeerEvaluationStudentOverrideFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, PeerEvaluationStudentOverrideFeedback>(PeerEvaluationStudentOverrideFeedbacks)
|
||||
|
||||
var group by Group referencedOn PeerEvaluationStudentOverrideFeedbacks.groupId
|
||||
var student by Student referencedOn PeerEvaluationStudentOverrideFeedbacks.studentId
|
||||
var feedback by BaseFeedback referencedOn PeerEvaluationStudentOverrideFeedbacks.feedbackId
|
||||
var overrides by BaseFeedback referencedOn PeerEvaluationStudentOverrideFeedbacks.overrides
|
||||
}
|
||||
|
||||
class PeerEvaluationS2G(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, PeerEvaluationS2G>(PeerEvaluationS2GEvaluations)
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ fun AssignmentsView(vm: EditionVM, token: Navigator.NavToken) = Row(Modifier.fil
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
val peerEvalData by vm.asPeerEvaluation.entity
|
||||
var updatingPeerEvalGrade by remember { mutableStateOf(false) }
|
||||
|
||||
Text(assignment.assignment.name, style = MaterialTheme.typography.headlineMedium)
|
||||
Text("Deadline: ${assignment.assignment.deadline.format(fmt)}", Modifier.padding(top = 5.dp).clickable { updatingDeadline = true }, fontStyle = FontStyle.Italic)
|
||||
Row {
|
||||
@@ -85,6 +88,30 @@ fun AssignmentsView(vm: EditionVM, token: Navigator.NavToken) = Row(Modifier.fil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peerEvalData?.let { pe ->
|
||||
Row {
|
||||
Text("Students are reviewing each other using ", Modifier.align(Alignment.CenterVertically))
|
||||
Surface(shape = MaterialTheme.shapes.small, tonalElevation = 10.dp) {
|
||||
Box(Modifier.clickable { updatingPeerEvalGrade = true }.padding(3.dp)) {
|
||||
Text(
|
||||
when (val t = pe.second) {
|
||||
is UiGradeType.Categoric -> t.grade.name
|
||||
UiGradeType.FreeText -> "by free-form grades"
|
||||
is UiGradeType.Numeric -> t.grade.name
|
||||
UiGradeType.Percentage -> "by percentages"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(updatingPeerEvalGrade) {
|
||||
SetGradingDialog("${assignment.assignment.name} (peer review grade)", pe.second, vm, { updatingPeerEvalGrade = false }) { type ->
|
||||
vm.setPEGrade(pe.first, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Column(Modifier.weight(0.75f)) {
|
||||
Row {
|
||||
|
||||
@@ -141,25 +141,9 @@ fun QuickAGroup(isFocus: Boolean, onFocus: () -> Unit, group: GroupsGradingVM.Gr
|
||||
}
|
||||
}
|
||||
|
||||
private fun gradeState(crit: GroupsGradingVM.CritData, current: Grade?): Grade = transaction {
|
||||
if(current == null) Grade.default(crit.criterion.gradeType, crit.cat, crit.num)
|
||||
when(crit.criterion.gradeType) {
|
||||
GradeType.CATEGORIC ->
|
||||
if(current is Grade.Categoric && current.grade.id == crit.criterion.categoricGrade?.id) current
|
||||
else Grade.default(GradeType.CATEGORIC, crit.cat, crit.num)
|
||||
GradeType.NUMERIC ->
|
||||
if(current is Grade.Numeric && current.grade.id == crit.criterion.numericGrade?.id) current
|
||||
else Grade.default(GradeType.NUMERIC, crit.cat, crit.num)
|
||||
GradeType.PERCENTAGE ->
|
||||
current as? Grade.Percentage ?: Grade.default(GradeType.PERCENTAGE, crit.cat, crit.num)
|
||||
GradeType.NONE ->
|
||||
current as? Grade.FreeText ?: Grade.default(GradeType.NONE, crit.cat, crit.num)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GFWidget(
|
||||
crit: GroupsGradingVM.CritData, gr: Group, feedback: GroupsGradingVM.FeedbackData, vm: GroupsGradingVM, key: Any,
|
||||
crit: CritData, gr: Group, feedback: GroupsGradingVM.FeedbackData, vm: GroupsGradingVM, key: Any,
|
||||
isOpen: Boolean, showDesc: Boolean = false, overrideName: String? = null, markOverridden: Set<UUID> = setOf(),
|
||||
onToggle: () -> Unit
|
||||
) = Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, shadowElevation = 3.dp) {
|
||||
@@ -199,7 +183,7 @@ fun GFWidget(
|
||||
Spacer(Modifier.height(5.dp))
|
||||
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 5, modifier = Modifier.fillMaxWidth().weight(1f))
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Button({ vm.modGroupFeedback(crit.criterion, gr, grade, text) }) {
|
||||
Button({ vm.modGroupFeedback(crit.criterion, gr, grade, text) }, Modifier.padding(horizontal = 20.dp).fillMaxWidth()) {
|
||||
Text("Save grade and feedback")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.draganddrop.dragAndDropSource
|
||||
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -43,9 +44,12 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.jaytux.grader.data.v2.Group
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.startEmail
|
||||
import com.jaytux.grader.viewmodel.EditionVM
|
||||
import com.jaytux.grader.viewmodel.SnackVM
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
@@ -60,6 +64,7 @@ fun GroupsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
|
||||
|
||||
val group = remember(groups, focus) { if(focus != -1) groups[focus] else null }
|
||||
val grades by vm.groupGrades.entities
|
||||
val snacks = viewModel<SnackVM> { SnackVM() }
|
||||
|
||||
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
|
||||
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
|
||||
@@ -75,7 +80,14 @@ fun GroupsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
|
||||
}
|
||||
else {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(group.group.name, style = MaterialTheme.typography.headlineMedium)
|
||||
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(group.group.name, style = MaterialTheme.typography.headlineMedium)
|
||||
if (group.members.any { it.first.contact.isNotBlank() }) {
|
||||
IconButton({ startEmail(group.members.mapNotNull { it.first.contact.ifBlank { null } }) { snacks.show(it) } }) {
|
||||
Icon(Mail, "Send email", Modifier.fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(Modifier.padding(5.dp)) {
|
||||
var showTargetBorder by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -20,7 +20,7 @@ fun HomeTitle() = Text("Grader")
|
||||
|
||||
@Composable
|
||||
fun HomeView(token: Navigator.NavToken) {
|
||||
val vm = viewModel<HomeVM>()
|
||||
val vm = viewModel<HomeVM> { HomeVM() }
|
||||
val courses by vm.courses.entities
|
||||
var addingCourse by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
@@ -1388,4 +1388,45 @@ val DoubleForward: ImageVector by lazy {
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
val Mail: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "mail",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Transparent),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(22f, 7f)
|
||||
lineToRelative(-8.991f, 5.727f)
|
||||
arcToRelative(2f, 2f, 0f, false, true, -2.009f, 0f)
|
||||
lineTo(2f, 7f)
|
||||
}
|
||||
path(
|
||||
fill = SolidColor(Color.Transparent),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(4f, 4f)
|
||||
horizontalLineTo(20f)
|
||||
arcTo(2f, 2f, 0f, false, true, 22f, 6f)
|
||||
verticalLineTo(18f)
|
||||
arcTo(2f, 2f, 0f, false, true, 20f, 20f)
|
||||
horizontalLineTo(4f)
|
||||
arcTo(2f, 2f, 0f, false, true, 2f, 18f)
|
||||
verticalLineTo(6f)
|
||||
arcTo(2f, 2f, 0f, false, true, 4f, 4f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
@@ -1,13 +1,60 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.ScrollableTabRow
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.jaytux.grader.GroupGrading
|
||||
import com.jaytux.grader.PeerEvalGrading
|
||||
import com.jaytux.grader.app
|
||||
import com.jaytux.grader.data.v2.CategoricGrade
|
||||
import com.jaytux.grader.data.v2.GradeType
|
||||
import com.jaytux.grader.data.v2.Group
|
||||
import com.jaytux.grader.data.v2.NumericGrade
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.viewmodel.Grade
|
||||
import com.jaytux.grader.viewmodel.GroupsGradingVM
|
||||
import com.jaytux.grader.viewmodel.Navigator
|
||||
import com.jaytux.grader.viewmodel.PeerEvalsGradingVM
|
||||
import sun.tools.jconsole.LabeledComponent.layout
|
||||
|
||||
@Composable
|
||||
fun PeerEvalsGradingTitle(data: PeerEvalGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Peer Evaluations / ${data.assignment.name} / Grading")
|
||||
@@ -17,4 +64,224 @@ fun PeerEvalsGradingView(data: PeerEvalGrading, token: Navigator.NavToken) {
|
||||
val vm = viewModel<PeerEvalsGradingVM>(key = data.assignment.id.toString()) {
|
||||
PeerEvalsGradingVM(data.course, data.edition, data.assignment)
|
||||
}
|
||||
val groups by vm.groupList.entities
|
||||
val focus by vm.focus
|
||||
|
||||
val selectedGroup = remember(focus, groups) { groups.getOrNull(focus) }
|
||||
|
||||
val students by vm.students.entities
|
||||
val matrix by vm.evaluationMatrix.entities
|
||||
val studentGrades by vm.studentGrades.entities
|
||||
var selectedStudent by remember(selectedGroup, studentGrades) {
|
||||
mutableStateOf(0)
|
||||
}
|
||||
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text("Grading ${vm.base.name}", style = MaterialTheme.typography.headlineMedium)
|
||||
Text("Group assignment in ${vm.course.name} - ${vm.edition.name}")
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
|
||||
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
|
||||
QuickAGroup(idx == focus, { vm.focusGroup(idx) }, it)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(Modifier.weight(0.75f).fillMaxHeight(), tonalElevation = 1.dp) {
|
||||
if (focus == -1 || selectedGroup == null) {
|
||||
Box(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||
Text("Select a group to start grading.", Modifier.align(Alignment.Center))
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.weight(0.75f).padding(15.dp)) {
|
||||
Row {
|
||||
IconButton({ vm.focusPrev() }, Modifier.align(Alignment.CenterVertically), enabled = focus > 0) {
|
||||
Icon(DoubleBack, "Previous group")
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(selectedGroup.group.name, Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton({ vm.focusNext() }, Modifier.align(Alignment.CenterVertically), enabled = focus < groups.size - 1) {
|
||||
Icon(DoubleForward, "Next group")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
matrix?.let { mat ->
|
||||
students?.let { stu ->
|
||||
Box(Modifier.weight(0.66f)) {
|
||||
GradeTable(mat, stu, selectedGroup.group, vm.studentCriterion, vm::setEvaluation)
|
||||
}
|
||||
}
|
||||
} ?: Box(Modifier.weight(0.66f).fillMaxWidth()) {
|
||||
Text("Error: could not load evaluations for this group.", Modifier.align(Alignment.Center), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Column(Modifier.weight(0.33f)) {
|
||||
studentGrades?.let { sgs ->
|
||||
val currentStudent = sgs[selectedStudent]
|
||||
|
||||
PrimaryScrollableTabRow(selectedStudent, Modifier.fillMaxWidth()) {
|
||||
sgs.forEachIndexed { idx, st ->
|
||||
Tab(idx == selectedStudent, { selectedStudent = idx }) {
|
||||
Row {
|
||||
Icon(UserIcon, "")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(st.first.name, Modifier.align(Alignment.CenterVertically))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SingleStudentGrade(currentStudent.first.name, currentStudent.second, vm.global) { grade, feedback ->
|
||||
vm.setStudentGrade(currentStudent.first, grade, feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GradeTable(
|
||||
matrix: List<PeerEvalsGradingVM.Evaluation>, students: List<Student>, group: Group,
|
||||
egData: CritData, onSet: (evaluator: Student, evaluatee: Student?, group: Group, grade: Grade, feedback: String) -> Unit
|
||||
) {
|
||||
Row {
|
||||
val horScroll = rememberLazyListState()
|
||||
val style = LocalTextStyle.current
|
||||
val measure = rememberTextMeasurer()
|
||||
val textLenMeasured = remember(matrix, students) {
|
||||
students.maxOf { s ->
|
||||
measure.measure(s.name, style).size.width
|
||||
} + 10
|
||||
}
|
||||
val cellSize = 75.dp
|
||||
var idx by remember(matrix, students) { mutableStateOf(0) }
|
||||
var editing by remember(matrix, students) { mutableStateOf<Triple<Student, Student?, FeedbackItem?>?>(null) }
|
||||
|
||||
val isSelected = { from: Student, to: Student? ->
|
||||
editing?.let { (f, t, _) -> f == from && t == to } ?: false
|
||||
}
|
||||
|
||||
Column(Modifier.weight(0.66f).padding(10.dp)) {
|
||||
Row {
|
||||
Box { FromTo(textLenMeasured.dp) }
|
||||
LazyRow(Modifier.height(textLenMeasured.dp), state = horScroll) {
|
||||
item { VLine() }
|
||||
items(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(matrix) { (evaluator, groupLevel, s2s) ->
|
||||
Row(Modifier.height(cellSize)) {
|
||||
Column(Modifier.width(textLenMeasured.dp).align(Alignment.CenterVertically)) {
|
||||
Text(evaluator.name, Modifier.width(textLenMeasured.dp))
|
||||
}
|
||||
LazyRow(state = horScroll) {
|
||||
item { VLine() }
|
||||
items(s2s) { (evaluatee, entry) ->
|
||||
PEGradeWidget(
|
||||
entry,
|
||||
{ editing = evaluator to evaluatee app entry }, { editing = null },
|
||||
isSelected(evaluator, evaluatee), Modifier.size(cellSize, cellSize)
|
||||
)
|
||||
}
|
||||
item { VLine() }
|
||||
item {
|
||||
PEGradeWidget(
|
||||
groupLevel,
|
||||
{ editing = evaluator to null app groupLevel }, { editing = null },
|
||||
isSelected(evaluator, null), Modifier.size(cellSize, cellSize)
|
||||
)
|
||||
}
|
||||
item { VLine() }
|
||||
}
|
||||
}
|
||||
}
|
||||
measuredItem { HLine() }
|
||||
}
|
||||
}
|
||||
|
||||
editing?.let {
|
||||
Surface(Modifier.weight(0.33f), tonalElevation = 10.dp, shape = MaterialTheme.shapes.medium) {
|
||||
val (evaluator, evaluatee, data) = it
|
||||
EditS2SOrS2G(evaluator.name, evaluatee?.name ?: group.name, data, egData) { grade, feedback ->
|
||||
onSet(evaluator, evaluatee, group, grade, feedback)
|
||||
}
|
||||
}
|
||||
} ?: Box(Modifier.weight(0.33f)) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditS2SOrS2G(evaluator: String, evaluatee: String, current: FeedbackItem?, critData: CritData, onUpdate: (Grade, String) -> Unit) =
|
||||
Column(Modifier.padding(10.dp).fillMaxHeight()) {
|
||||
println("Recomposing editor for $evaluator -> $evaluatee with current ${current?.grade}")
|
||||
var grade by remember(evaluator, evaluatee, current) { mutableStateOf(gradeState(critData, current?.grade)) }
|
||||
var text by remember(evaluator, evaluatee, current) { mutableStateOf(current?.feedback ?: "") }
|
||||
|
||||
Text(evaluatee, style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Evaluated by $evaluator", style = MaterialTheme.typography.bodyMedium, fontStyle = FontStyle.Italic)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GradePicker(grade, key = evaluator to evaluatee to current) { grade = it }
|
||||
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 10, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Button({ onUpdate(grade, text) }, Modifier.padding(horizontal = 20.dp).fillMaxWidth()) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SingleStudentGrade(name: String, current: FeedbackItem?, critData: CritData, onUpdate: (Grade, String) -> Unit) = Column {
|
||||
var grade by remember(name, critData) { mutableStateOf(gradeState(critData, current?.grade)) }
|
||||
var text by remember(name) { mutableStateOf(current?.feedback ?: "") }
|
||||
|
||||
GradePicker(grade, key = critData to current app name) { grade = it }
|
||||
Spacer(Modifier.height(5.dp))
|
||||
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 5, modifier = Modifier.fillMaxWidth().weight(1f))
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Button({ onUpdate(grade, text) }, Modifier.padding(horizontal = 20.dp).fillMaxWidth()) {
|
||||
Text("Save grade and feedback")
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -35,14 +36,18 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.jaytux.grader.data.v2.Edition
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.startEmail
|
||||
import com.jaytux.grader.viewmodel.EditionVM
|
||||
import com.jaytux.grader.viewmodel.SnackVM
|
||||
|
||||
@Composable
|
||||
fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
|
||||
val students by vm.studentList.entities
|
||||
val focus by vm.focusIndex
|
||||
val snacks = viewModel<SnackVM> { SnackVM() }
|
||||
|
||||
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
|
||||
ListOrEmpty(students, { Text("No students yet.") }) { idx, it ->
|
||||
@@ -63,19 +68,30 @@ fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.weight(0.75f).padding(15.dp)) {
|
||||
Surface(Modifier.padding(10.dp).fillMaxWidth(), tonalElevation = 10.dp, shadowElevation = 2.dp, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(students[focus].name, style = MaterialTheme.typography.headlineSmall)
|
||||
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(students[focus].name, style = MaterialTheme.typography.headlineSmall)
|
||||
if(students[focus].contact.isNotBlank()) {
|
||||
IconButton({ startEmail(listOf(students[focus].contact)) { snacks.show(it) } }) {
|
||||
Icon(Mail, "Send email", Modifier.fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
|
||||
Text("Contact: ", Modifier.align(Alignment.CenterVertically).padding(start = 15.dp))
|
||||
if(!editing) {
|
||||
if (students[focus].contact.isBlank()) Text(
|
||||
"No contact info.",
|
||||
Modifier.padding(start = 5.dp),
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = LocalTextStyle.current.color.copy(alpha = 0.5f)
|
||||
)
|
||||
else Text(students[focus].contact, Modifier.padding(start = 5.dp))
|
||||
if (students[focus].contact.isBlank()) {
|
||||
Text(
|
||||
"No contact info.",
|
||||
Modifier.padding(start = 5.dp),
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = LocalTextStyle.current.color.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
else {
|
||||
Text(students[focus].contact, Modifier.padding(start = 5.dp))
|
||||
}
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Icon(Edit, "Edit contact info", Modifier.clickable { editing = true })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,72 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import com.jaytux.grader.data.v2.BaseFeedback
|
||||
import com.jaytux.grader.data.v2.CategoricGrade
|
||||
import com.jaytux.grader.data.v2.Criterion
|
||||
import com.jaytux.grader.data.v2.GradeType
|
||||
import com.jaytux.grader.data.v2.NumericGrade
|
||||
import com.jaytux.grader.viewmodel.Grade
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
|
||||
@Composable
|
||||
fun TextUnit.toDp(): Dp = with(LocalDensity.current) { value.toDp() }
|
||||
fun TextUnit.toDp(): Dp = with(LocalDensity.current) { value.toDp() }
|
||||
|
||||
data class CritData(val criterion: Criterion, val cat: CategoricGrade?, val num: NumericGrade?) {
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun fromDb(c: Criterion) = CritData(c, c.categoricGrade, c.numericGrade)
|
||||
}
|
||||
}
|
||||
|
||||
data class FeedbackItem(val base: BaseFeedback, val grade: Grade, val feedback: String) {
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun fromDb(f: BaseFeedback): FeedbackItem = when(f.criterion.gradeType) {
|
||||
GradeType.CATEGORIC -> {
|
||||
val categoric = f.criterion.categoricGrade!!
|
||||
val options = categoric.options.toList()
|
||||
Grade.Categoric(f.gradeCategoric ?: options.first(), options, categoric)
|
||||
}
|
||||
GradeType.NUMERIC -> Grade.Numeric(f.gradeNumeric ?: 0.0, f.criterion.numericGrade!!)
|
||||
GradeType.PERCENTAGE -> Grade.Percentage(f.gradeNumeric ?: 0.0)
|
||||
GradeType.NONE -> Grade.FreeText(f.gradeFreeText ?: "")
|
||||
}.let { FeedbackItem(f, it, f.feedback) }
|
||||
}
|
||||
}
|
||||
|
||||
fun gradeState(type: GradeType, categoric: CategoricGrade?, numeric: NumericGrade?, current: Grade?): Grade = transaction {
|
||||
if(current == null) {
|
||||
println("gradeState: current is null, defaulting")
|
||||
Grade.default(type, categoric, numeric)
|
||||
}
|
||||
else {
|
||||
when(type) {
|
||||
GradeType.CATEGORIC ->
|
||||
if(current is Grade.Categoric && current.grade.id == categoric?.id) {
|
||||
println("gradeState: current categoric grade is valid, keeping")
|
||||
current
|
||||
}
|
||||
else {
|
||||
println("gradeState: current categoric grade is invalid, defaulting [${current is Grade.Categoric} (${current::class.java.simpleName}), ${(current as? Grade.Categoric)?.grade?.name} == ${categoric?.name}]")
|
||||
Grade.default(GradeType.CATEGORIC, categoric, numeric)
|
||||
}
|
||||
GradeType.NUMERIC ->
|
||||
if(current is Grade.Numeric && current.grade.id == numeric?.id) {
|
||||
println("gradeState: current numeric grade is valid, keeping")
|
||||
current
|
||||
}
|
||||
else {
|
||||
println("gradeState: current numeric grade is invalid, defaulting [${current is Grade.Numeric}, ${(current as? Grade.Numeric)?.grade?.id == numeric?.id}]")
|
||||
Grade.default(GradeType.NUMERIC, categoric, numeric)
|
||||
}
|
||||
GradeType.PERCENTAGE ->
|
||||
current as? Grade.Percentage ?: Grade.default(GradeType.PERCENTAGE, categoric, numeric)
|
||||
GradeType.NONE ->
|
||||
current as? Grade.FreeText ?: Grade.default(GradeType.NONE, categoric, numeric)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun gradeState(crit: CritData, current: Grade?): Grade = gradeState(crit.criterion.gradeType, crit.cat, crit.num, current)
|
||||
@@ -126,7 +126,7 @@ fun FromTo(size: Dp) {
|
||||
}
|
||||
|
||||
Box {
|
||||
Text("Evaluated", Modifier.graphicsLayer {
|
||||
Text("Evaluatee", Modifier.graphicsLayer {
|
||||
rotationZ = -90f
|
||||
translationX = w - 15f
|
||||
translationY = h - 15f
|
||||
@@ -136,19 +136,37 @@ fun FromTo(size: Dp) {
|
||||
}
|
||||
}
|
||||
|
||||
//@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) {
|
||||
@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 PEGradeWidget(
|
||||
feedback: FeedbackItem?,
|
||||
onSelect: () -> Unit, onDeselect: () -> Unit,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) = Box(modifier.padding(2.dp)) {
|
||||
Selectable(isSelected, onSelect, onDeselect) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
feedback?.grade?.render() ?: Text("(none)", fontStyle = FontStyle.Italic)
|
||||
// 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))
|
||||
|
||||
@@ -27,12 +27,7 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
|
||||
data class CriterionData(val criterion: Criterion, val gradeType: UiGradeType) {
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun from(c: Criterion) = CriterionData(c, when(c.gradeType) {
|
||||
GradeType.CATEGORIC -> UiGradeType.Categoric(c.categoricGrade!!.options.toList(), c.categoricGrade!!)
|
||||
GradeType.NUMERIC -> UiGradeType.Numeric(c.numericGrade!!)
|
||||
GradeType.PERCENTAGE -> UiGradeType.Percentage
|
||||
GradeType.NONE -> UiGradeType.FreeText
|
||||
})
|
||||
fun from(c: Criterion) = CriterionData(c, UiGradeType.from(c.gradeType, c.categoricGrade, c.numericGrade))
|
||||
}
|
||||
}
|
||||
data class AssignmentData(val assignment: BaseAssignment, val global: CriterionData, val criteria: List<CriterionData>)
|
||||
@@ -82,15 +77,14 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
|
||||
gr to asGroup?.asGroupFeedback app (solo != null)
|
||||
}
|
||||
AssignmentType.SOLO -> {
|
||||
val gr = asg.globalCriterion.feedbacks.find { it.asSoloFeedback == st }
|
||||
val eval = asg.globalCriterion.feedbacks.find { it.asSoloFeedback == st }
|
||||
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||
gr to null app false
|
||||
eval to null app false
|
||||
}
|
||||
AssignmentType.PEER_EVALUATION -> {
|
||||
val asGroup = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id in groupIds }
|
||||
val solo = asg.globalCriterion.feedbacks.find { it.forStudentsOverrideIfPeer.any { over -> over.student == st } }
|
||||
val gr = (solo ?: asGroup)?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||
gr to asGroup?.asPeerEvaluationFeedback app (solo != null)
|
||||
val eval = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id == st.id }
|
||||
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||
eval to null app false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +117,13 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
val asPeerEvaluation = RawDbFocusableSingleState { asg: BaseAssignment ->
|
||||
asg.asPeerEvaluation?.let { peer ->
|
||||
val stuCrit = peer.studentCriterion
|
||||
peer to UiGradeType.from(stuCrit.gradeType, stuCrit.categoricGrade, stuCrit.numericGrade)
|
||||
}
|
||||
}
|
||||
|
||||
private val _selectedTab = mutableStateOf(Tab.STUDENTS)
|
||||
private val _focusIndex = mutableStateOf(-1)
|
||||
val selectedTab = _selectedTab.immutable()
|
||||
@@ -147,7 +148,10 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
|
||||
groupAvailableStudents.focus(grp)
|
||||
groupGrades.focus(grp)
|
||||
}
|
||||
Tab.ASSIGNMENTS -> {}
|
||||
Tab.ASSIGNMENTS -> {
|
||||
val asg = assignmentList.entities.value[idx].assignment
|
||||
asPeerEvaluation.focus(asg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,8 +293,8 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
|
||||
}
|
||||
|
||||
private fun postCreateAsg() {
|
||||
focus(assignmentList.entities.value.size)
|
||||
assignmentList.refresh()
|
||||
focus(assignmentList.entities.value.size - 1)
|
||||
}
|
||||
|
||||
fun mkGroupAssignment(name: String) {
|
||||
@@ -312,7 +316,16 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
|
||||
fun mkPeerEvaluation(name: String) {
|
||||
transaction {
|
||||
val asg = mkBaseAssignment(name, AssignmentType.PEER_EVALUATION)
|
||||
PeerEvaluation.new { this.base = asg }
|
||||
val stCrit = Criterion.new {
|
||||
this.assignment = asg
|
||||
this.name = "@__internal"
|
||||
this.desc = "INTERNAL ONLY: Criterion to store the grade type for peer evaluation assignments"
|
||||
this.gradeType = GradeType.NONE
|
||||
}
|
||||
PeerEvaluation.new {
|
||||
this.base = asg
|
||||
this.studentCriterion = stCrit
|
||||
}
|
||||
}
|
||||
postCreateAsg()
|
||||
}
|
||||
@@ -408,6 +421,24 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
|
||||
numericGrades.refresh()
|
||||
}
|
||||
|
||||
fun setPEGrade(pe: PeerEvaluation, gradeType: UiGradeType) {
|
||||
transaction {
|
||||
pe.studentCriterion.gradeType = when (gradeType) {
|
||||
is UiGradeType.Categoric -> GradeType.CATEGORIC
|
||||
is UiGradeType.Numeric -> GradeType.NUMERIC
|
||||
UiGradeType.Percentage -> GradeType.PERCENTAGE
|
||||
UiGradeType.FreeText -> GradeType.NONE
|
||||
}
|
||||
|
||||
when (gradeType) {
|
||||
is UiGradeType.Categoric -> pe.studentCriterion.categoricGrade = gradeType.grade
|
||||
is UiGradeType.Numeric -> pe.studentCriterion.numericGrade = gradeType.grade
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
asPeerEvaluation.refresh()
|
||||
}
|
||||
|
||||
fun rmAssignment(assignment: BaseAssignment) {
|
||||
transaction {
|
||||
assignment.delete()
|
||||
|
||||
@@ -15,10 +15,18 @@ import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
|
||||
sealed class Grade {
|
||||
data class FreeText(val text: String) : Grade()
|
||||
data class Percentage(val percentage: Double) : Grade()
|
||||
data class Numeric(val value: Double, val grade: NumericGrade) : Grade()
|
||||
data class Categoric(val value: CategoricGradeOption, val options: List<CategoricGradeOption>, val grade: CategoricGrade) : Grade()
|
||||
data class FreeText(val text: String) : Grade() {
|
||||
override fun toString(): String = "FreeText($text)"
|
||||
}
|
||||
data class Percentage(val percentage: Double) : Grade() {
|
||||
override fun toString(): String = "Perc($percentage%)"
|
||||
}
|
||||
data class Numeric(val value: Double, val grade: NumericGrade) : Grade() {
|
||||
override fun toString(): String = "Numeric($value / ${grade.max})"
|
||||
}
|
||||
data class Categoric(val value: CategoricGradeOption, val options: List<CategoricGradeOption>, val grade: CategoricGrade) : Grade() {
|
||||
override fun toString(): String = "Categoric(${value.option})"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun render(modifier: Modifier = Modifier) = when(this) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import com.jaytux.grader.data.v2.NumericGrade
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.data.v2.StudentOverrideFeedback
|
||||
import com.jaytux.grader.data.v2.StudentOverrideFeedbacks
|
||||
import com.jaytux.grader.ui.CritData
|
||||
import com.jaytux.grader.ui.FeedbackItem
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
@@ -28,28 +30,7 @@ import org.jetbrains.exposed.v1.jdbc.upsertReturning
|
||||
|
||||
class GroupsGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
|
||||
data class GroupData(val group: Group, val students: List<Pair<Student, String?>>)
|
||||
data class FeedbackItem(val base: BaseFeedback, val grade: Grade, val feedback: String) {
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun fromDb(f: BaseFeedback): FeedbackItem = when(f.criterion.gradeType) {
|
||||
GradeType.CATEGORIC -> {
|
||||
val categoric = f.criterion.categoricGrade!!
|
||||
val options = categoric.options.toList()
|
||||
Grade.Categoric(f.gradeCategoric ?: options.first(), options, categoric)
|
||||
}
|
||||
GradeType.NUMERIC -> Grade.Numeric(f.gradeNumeric ?: 0.0, f.criterion.numericGrade!!)
|
||||
GradeType.PERCENTAGE -> Grade.Percentage(f.gradeNumeric ?: 0.0)
|
||||
GradeType.NONE -> Grade.FreeText(f.gradeFreeText ?: "")
|
||||
}.let { FeedbackItem(f, it, f.feedback) }
|
||||
}
|
||||
}
|
||||
data class FeedbackData(val groupLevel: FeedbackItem?, val overrides: List<Pair<Student, FeedbackItem?>>)
|
||||
data class CritData(val criterion: Criterion, val cat: CategoricGrade?, val num: NumericGrade?) {
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun fromDb(c: Criterion) = CritData(c, c.categoricGrade, c.numericGrade)
|
||||
}
|
||||
}
|
||||
|
||||
private val _focus = mutableStateOf(-1)
|
||||
val focus = _focus.immutable()
|
||||
|
||||
@@ -67,7 +67,7 @@ class Navigator private constructor(
|
||||
?: throw IllegalStateException("No renderer for destination of type ${top.dest::class.simpleName}")
|
||||
top to render
|
||||
}
|
||||
val snackVM = viewModel<SnackVM>()
|
||||
val snackVM = viewModel<SnackVM> { SnackVM() }
|
||||
snackVM.Launcher(state)
|
||||
|
||||
BackHandler { back() }
|
||||
|
||||
@@ -1,9 +1,217 @@
|
||||
package com.jaytux.grader.viewmodel
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.jaytux.grader.app
|
||||
import com.jaytux.grader.data.v2.BaseAssignment
|
||||
import com.jaytux.grader.data.v2.BaseFeedback
|
||||
import com.jaytux.grader.data.v2.BaseFeedbacks
|
||||
import com.jaytux.grader.data.v2.Course
|
||||
import com.jaytux.grader.data.v2.Edition
|
||||
import com.jaytux.grader.data.v2.Group
|
||||
import com.jaytux.grader.data.v2.GroupStudent
|
||||
import com.jaytux.grader.data.v2.PeerEvaluationFeedbacks
|
||||
import com.jaytux.grader.data.v2.PeerEvaluationS2G
|
||||
import com.jaytux.grader.data.v2.PeerEvaluationS2GEvaluations
|
||||
import com.jaytux.grader.data.v2.PeerEvaluationS2S
|
||||
import com.jaytux.grader.data.v2.PeerEvaluationS2SEvaluations
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.ui.CritData
|
||||
import com.jaytux.grader.ui.FeedbackItem
|
||||
import com.jaytux.grader.viewmodel.GroupsGradingVM.GroupData
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.dao.with
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
|
||||
class PeerEvalsGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
|
||||
data class S2S(val evaluatee: Student, val data: FeedbackItem?)
|
||||
data class Evaluation(val evaluator: Student, val groupLevel: FeedbackItem?, val s2s: List<S2S>)
|
||||
|
||||
private val _focus = mutableStateOf(-1)
|
||||
val focus = _focus.immutable()
|
||||
|
||||
val asPeer = transaction { base.asPeerEvaluation!! }
|
||||
val global = transaction { CritData.fromDb(base.globalCriterion) }
|
||||
val studentCriterion = transaction { CritData.fromDb(asPeer.studentCriterion) }
|
||||
|
||||
val groupList = RawDbState {
|
||||
edition.groups.with(Group::students, GroupStudent::student).map { group ->
|
||||
GroupData(group, group.students.map { Pair(it.student, it.role) })
|
||||
}
|
||||
}
|
||||
|
||||
val evaluationMatrix = RawDbFocusableState { group: Group ->
|
||||
val studentIds = group.students.map { it.student.id.value }
|
||||
val s2gs = PeerEvaluationS2G.find {
|
||||
(PeerEvaluationS2GEvaluations.peerEvalId eq asPeer.id) and
|
||||
(PeerEvaluationS2GEvaluations.studentId inList studentIds)
|
||||
}.also {
|
||||
println("S2G for group ${group.name}:")
|
||||
it.forEach { println(" ${it.student.name} -> ${it.evaluation.gradeCategoric ?: it.evaluation.gradeNumeric ?: it.evaluation.gradeFreeText}") }
|
||||
}
|
||||
val s2ss = PeerEvaluationS2S.find {
|
||||
(PeerEvaluationS2SEvaluations.peerEvalId eq asPeer.id) and
|
||||
(PeerEvaluationS2SEvaluations.studentId inList studentIds)
|
||||
}
|
||||
group.students.map { evaluator ->
|
||||
val s2s = group.students.map { evaluatee ->
|
||||
val item = s2ss.find { it.student.id == evaluator.student.id && it.evaluatedStudent.id == evaluatee.student.id }?.let {
|
||||
FeedbackItem.fromDb(it.evaluation)
|
||||
}
|
||||
S2S(evaluatee.student, item)
|
||||
}
|
||||
val s2g = s2gs.find { it.student.id == evaluator.student.id }?.let { FeedbackItem.fromDb(it.evaluation) }
|
||||
Evaluation(evaluator.student, s2g, s2s)
|
||||
}
|
||||
}
|
||||
|
||||
val studentGrades = RawDbFocusableState { group: Group ->
|
||||
val studentIds = group.students.map { it.student.id.value }.toSet()
|
||||
|
||||
val mapping = global.criterion.feedbacks.mapNotNull {
|
||||
it.asPeerEvaluationFeedback?.let { x ->
|
||||
if(x.id.value in studentIds) x.id to FeedbackItem.fromDb(it) else null
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
group.students.map { student -> student.student to mapping[student.student.id] }
|
||||
}
|
||||
|
||||
val students = RawDbFocusableState { group: Group ->
|
||||
group.students.map { it.student }
|
||||
}
|
||||
|
||||
fun focusGroup(idx: Int) {
|
||||
_focus.value = idx
|
||||
|
||||
val current = groupList.entities.value[idx].group
|
||||
evaluationMatrix.focus(current)
|
||||
students.focus(current)
|
||||
studentGrades.focus(current)
|
||||
}
|
||||
|
||||
fun focusPrev() {
|
||||
if (focus.value > 0) focusGroup(focus.value - 1)
|
||||
}
|
||||
|
||||
fun focusNext() {
|
||||
if (focus.value < groupList.entities.value.size - 1) focusGroup(focus.value + 1)
|
||||
}
|
||||
|
||||
private fun setStudentEvaluation(evaluator: Student, evaluatee: Student, grade: Grade, feedback: String) = transaction {
|
||||
val existing = PeerEvaluationS2S.find {
|
||||
(PeerEvaluationS2SEvaluations.peerEvalId eq asPeer.id) and
|
||||
(PeerEvaluationS2SEvaluations.studentId eq evaluator.id) and
|
||||
(PeerEvaluationS2SEvaluations.evaluatedStudentId eq evaluatee.id)
|
||||
}.firstOrNull()
|
||||
|
||||
if(existing != null) {
|
||||
existing.evaluation.feedback = feedback
|
||||
when(grade) {
|
||||
is Grade.Categoric -> existing.evaluation.gradeCategoric = grade.value
|
||||
is Grade.Numeric -> existing.evaluation.gradeNumeric = grade.value
|
||||
is Grade.Percentage -> existing.evaluation.gradeNumeric = grade.percentage
|
||||
is Grade.FreeText -> existing.evaluation.gradeFreeText = grade.text
|
||||
}
|
||||
}
|
||||
else {
|
||||
val base = BaseFeedback.new {
|
||||
criterion = studentCriterion.criterion
|
||||
this.feedback = feedback
|
||||
when(grade) {
|
||||
is Grade.Categoric -> this.gradeCategoric = grade.value
|
||||
is Grade.Numeric -> this.gradeNumeric = grade.value
|
||||
is Grade.Percentage -> this.gradeNumeric = grade.percentage
|
||||
is Grade.FreeText -> this.gradeFreeText = grade.text
|
||||
}
|
||||
}
|
||||
|
||||
PeerEvaluationS2S.new {
|
||||
evaluation = base
|
||||
peerEvaluation = asPeer
|
||||
student = evaluator
|
||||
evaluatedStudent = evaluatee
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setStudentGroupEvaluation(evaluator: Student, group: Group, grade: Grade, feedback: String) = transaction {
|
||||
val existing = PeerEvaluationS2G.find {
|
||||
(PeerEvaluationS2GEvaluations.peerEvalId eq asPeer.id) and
|
||||
(PeerEvaluationS2GEvaluations.studentId eq evaluator.id) and
|
||||
(PeerEvaluationS2GEvaluations.groupId eq group.id)
|
||||
}.firstOrNull()
|
||||
|
||||
if(existing != null) {
|
||||
existing.evaluation.feedback = feedback
|
||||
when(grade) {
|
||||
is Grade.Categoric -> existing.evaluation.gradeCategoric = grade.value
|
||||
is Grade.Numeric -> existing.evaluation.gradeNumeric = grade.value
|
||||
is Grade.Percentage -> existing.evaluation.gradeNumeric = grade.percentage
|
||||
is Grade.FreeText -> existing.evaluation.gradeFreeText = grade.text
|
||||
}
|
||||
}
|
||||
else {
|
||||
val base = BaseFeedback.new {
|
||||
criterion = studentCriterion.criterion
|
||||
this.feedback = feedback
|
||||
when(grade) {
|
||||
is Grade.Categoric -> this.gradeCategoric = grade.value
|
||||
is Grade.Numeric -> this.gradeNumeric = grade.value
|
||||
is Grade.Percentage -> this.gradeNumeric = grade.percentage
|
||||
is Grade.FreeText -> this.gradeFreeText = grade.text
|
||||
}
|
||||
}
|
||||
|
||||
PeerEvaluationS2G.new {
|
||||
evaluation = base
|
||||
peerEvaluation = asPeer
|
||||
student = evaluator
|
||||
this.group = group
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setEvaluation(evaluator: Student, evaluatee: Student?, group: Group, grade: Grade, feedback: String) {
|
||||
println("Setting: evaluator=${evaluator.name}, evaluatee=${evaluatee?.name}, group=${group.name}, grade=$grade, feedback=$feedback")
|
||||
evaluatee?.let { setStudentEvaluation(evaluator, it, grade, feedback) } ?: setStudentGroupEvaluation(evaluator, group, grade, feedback)
|
||||
|
||||
evaluationMatrix.refresh()
|
||||
}
|
||||
|
||||
fun setStudentGrade(student: Student, grade: Grade, feedback: String) = transaction {
|
||||
val existing = BaseFeedback.find { BaseFeedbacks.criterionId eq global.criterion.id }
|
||||
.find { it.asPeerEvaluationFeedback?.id?.value == student.id.value }
|
||||
|
||||
if(existing != null) {
|
||||
existing.feedback = feedback
|
||||
when(grade) {
|
||||
is Grade.Categoric -> existing.gradeCategoric = grade.value
|
||||
is Grade.Numeric -> existing.gradeNumeric = grade.value
|
||||
is Grade.Percentage -> existing.gradeNumeric = grade.percentage
|
||||
is Grade.FreeText -> existing.gradeFreeText = grade.text
|
||||
}
|
||||
}
|
||||
else {
|
||||
val base = BaseFeedback.new {
|
||||
criterion = global.criterion
|
||||
this.feedback = feedback
|
||||
when(grade) {
|
||||
is Grade.Categoric -> this.gradeCategoric = grade.value
|
||||
is Grade.Numeric -> this.gradeNumeric = grade.value
|
||||
is Grade.Percentage -> this.gradeNumeric = grade.percentage
|
||||
is Grade.FreeText -> this.gradeFreeText = grade.text
|
||||
}
|
||||
}
|
||||
|
||||
PeerEvaluationFeedbacks.insert {
|
||||
it[PeerEvaluationFeedbacks.feedbackId] = base.id
|
||||
it[PeerEvaluationFeedbacks.studentId] = student.id
|
||||
}
|
||||
}
|
||||
studentGrades.refresh()
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,14 @@ sealed class UiGradeType {
|
||||
object Percentage : UiGradeType()
|
||||
data class Numeric(val grade: NumericGrade) : UiGradeType()
|
||||
data class Categoric(val options: List<CategoricGradeOption>, val grade: CategoricGrade) : UiGradeType()
|
||||
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun from(type: GradeType, categoric: CategoricGrade?, numeric: NumericGrade?) = when(type) {
|
||||
GradeType.CATEGORIC -> Categoric(categoric!!.options.toList(), categoric)
|
||||
GradeType.NUMERIC -> Numeric(numeric!!)
|
||||
GradeType.PERCENTAGE -> Percentage
|
||||
GradeType.NONE -> FreeText
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user