Compare commits
10 Commits
97fe7a8139
...
c88d0d2e58
Author | SHA1 | Date |
---|---|---|
|
c88d0d2e58 | |
|
a7aafccd19 | |
|
034b018e2d | |
|
4da4b0bb85 | |
|
f407a8c43e | |
|
49e3b8126f | |
|
63c4197cfc | |
|
b69b46afee | |
|
d0ddd54710 | |
|
0bfef36559 |
|
@ -17,4 +17,5 @@ captures
|
||||||
!*.xcodeproj/project.xcworkspace/
|
!*.xcodeproj/project.xcworkspace/
|
||||||
!*.xcworkspace/contents.xcworkspacedata
|
!*.xcworkspace/contents.xcworkspacedata
|
||||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
**/grader.db
|
**/grader.db
|
||||||
|
**/*.backup
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,23 @@
|
||||||
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) + "..."
|
||||||
} else {
|
} else {
|
||||||
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 ?: "") }
|
||||||
}
|
}
|
|
@ -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)
|
||||||
}
|
}
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
|
@ -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,22 +55,14 @@ fun GroupAssignmentView(state: GroupAssignmentState) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if(idx == 0) {
|
if(idx == 0) {
|
||||||
val updTask = rememberRichTextState()
|
groupTaskWidget(
|
||||||
|
task, deadline, criteria,
|
||||||
LaunchedEffect(task) { updTask.setMarkdown(task) }
|
onSetTask = { state.updateTask(it) },
|
||||||
|
onSetDeadline = { state.updateDeadline(it) },
|
||||||
Row {
|
onAddCriterion = { state.addCriterion(it) },
|
||||||
DateTimePicker(deadline, { state.updateDeadline(it) })
|
onModCriterion = { c, n, d -> state.updateCriterion(c, n, d) },
|
||||||
}
|
onRmCriterion = { state.deleteCriterion(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 {
|
else {
|
||||||
groupFeedback(state, allFeedback[idx - 1].second)
|
groupFeedback(state, allFeedback[idx - 1].second)
|
||||||
|
@ -70,12 +70,126 @@ fun GroupAssignmentView(state: GroupAssignmentState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
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)
|
||||||
|
OutlinedRichTextEditor(
|
||||||
|
state = updTask,
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
singleLine = false,
|
||||||
|
minLines = 5,
|
||||||
|
label = { Text("Task") }
|
||||||
|
)
|
||||||
|
CancelSaveRow(
|
||||||
|
true,
|
||||||
|
{ updTask.setMarkdown(taskMD) },
|
||||||
|
"Reset",
|
||||||
|
"Update"
|
||||||
|
) { onSetTask(updTask.toMarkdown()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@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 {
|
Row {
|
||||||
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
|
Box { FromTo(textLenMeasured.dp) }
|
||||||
OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f))
|
LazyRow(Modifier.height(textLenMeasured.dp), state = horScroll) {
|
||||||
Spacer(Modifier.weight(0.6f))
|
item { VLine() }
|
||||||
Button({ state.upsertIndividualFeedback(student, group, sMsg.text, sGrade) }, Modifier.weight(0.2f).align(Alignment.CenterVertically),
|
items(current.students) { (s, _) ->
|
||||||
enabled = sGrade.isNotBlank() || sMsg.text.isNotBlank()) {
|
Box(
|
||||||
Text("Save")
|
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 {
|
||||||
|
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
|
||||||
|
OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f))
|
||||||
|
Spacer(Modifier.weight(0.6f))
|
||||||
|
Button(
|
||||||
|
{ state.upsertIndividualFeedback(from, to, sGrade, sMsg); editing = null },
|
||||||
|
Modifier.weight(0.2f).align(Alignment.CenterVertically),
|
||||||
|
enabled = sGrade.isNotBlank() || sMsg.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f),
|
||||||
|
label = { Text("Feedback") },
|
||||||
|
singleLine = false,
|
||||||
|
minLines = 5
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AutocompleteLineField(
|
Column(Modifier.weight(0.5f)) {
|
||||||
sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") }
|
Row {
|
||||||
) { filter ->
|
Text("Group-level notes", Modifier.weight(1f).align(Alignment.CenterVertically), fontWeight = FontWeight.Bold)
|
||||||
suggestions.filter { x -> x.trim().startsWith(filter.trim()) }
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,14 +27,16 @@ 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) }
|
||||||
|
|
||||||
ListOrEmpty(
|
Box(Modifier.padding(15.dp)) {
|
||||||
data,
|
ListOrEmpty(
|
||||||
{ Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) },
|
data,
|
||||||
{ Text("Add a course") },
|
{ Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) },
|
||||||
{ showDialog = true },
|
{ Text("Add a course") },
|
||||||
addAfterLazy = false
|
{ showDialog = true },
|
||||||
) { _, it ->
|
addAfterLazy = false
|
||||||
CourseWidget(state.getEditions(it), { state.delete(it) }, push)
|
) { _, it ->
|
||||||
|
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) }
|
||||||
|
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
when(tab) {
|
||||||
|
OpenPanel.Student -> {
|
||||||
|
if(id == -1) PaneHeader("Nothing selected", "students", course, edition)
|
||||||
|
else PaneHeader(students[id].name, "student", course, edition)
|
||||||
|
}
|
||||||
|
OpenPanel.Group -> {
|
||||||
|
if(id == -1) PaneHeader("Nothing selected", "groups", course, edition)
|
||||||
|
else PaneHeader(groups[id].name, "group", course, edition)
|
||||||
|
}
|
||||||
|
OpenPanel.Assignment -> {
|
||||||
|
if(id == -1) PaneHeader("Nothing selected", "assignments", course, edition)
|
||||||
|
else {
|
||||||
|
when(val a = mergedAssignments[id]) {
|
||||||
|
is Assignment.SAssignment -> PaneHeader(a.name(), "individual assignment", course, edition)
|
||||||
|
is Assignment.GAssignment -> PaneHeader(a.name(), "group assignment", course, edition)
|
||||||
|
is Assignment.PeerEval -> PaneHeader(a.name(), "peer evaluation", course, edition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.weight(0.5f)) {
|
}
|
||||||
GroupAssignmentsWidget(
|
}
|
||||||
state.course, state.edition, groupAs, idx.groupAsIdx(), { toggle(it, Panel.GroupAs) },
|
}
|
||||||
{ state.newGroupAssignment(it) }) { assignment, title ->
|
Box(Modifier.weight(1f)) {
|
||||||
state.setGroupAssignmentTitle(
|
if (id != -1) {
|
||||||
assignment,
|
when (tab) {
|
||||||
title
|
OpenPanel.Student -> StudentView(StudentState(students[id], edition), navs)
|
||||||
)
|
OpenPanel.Group -> GroupView(GroupState(groups[id]), navs)
|
||||||
}
|
OpenPanel.Assignment -> {
|
||||||
}
|
when (val a = mergedAssignments[id]) {
|
||||||
} else {
|
is Assignment.SAssignment -> SoloAssignmentView(SoloAssignmentState(a.assignment))
|
||||||
Box(Modifier.weight(0.5f)) {
|
is Assignment.GAssignment -> GroupAssignmentView(GroupAssignmentState(a.assignment))
|
||||||
StudentsWidget(
|
is Assignment.PeerEval -> PeerEvaluationView(PeerEvaluationState(a.evaluation))
|
||||||
state.course, state.edition, students, idx.studentIdx(), { toggle(it, Panel.Student) },
|
|
||||||
available, { state.addToCourse(it) }
|
|
||||||
) { name, note, contact, addToEdition ->
|
|
||||||
state.newStudent(name, contact, note, addToEdition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(Modifier.weight(0.5f)) {
|
|
||||||
AssignmentsWidget(
|
|
||||||
state.course,
|
|
||||||
state.edition,
|
|
||||||
solo,
|
|
||||||
idx.soloIdx(),
|
|
||||||
{ toggle(it, Panel.Solo) },
|
|
||||||
{ state.newSoloAssignment(it) }) { assignment, title ->
|
|
||||||
state.setSoloAssignmentTitle(assignment, title)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 }
|
||||||
|
) {
|
||||||
|
Text(it.name, Modifier.padding(5.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(showDialog) {
|
||||||
|
StudentDialog(course, edition, { showDialog = false }, available, onImport, onAdd)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
fun GroupPanel(
|
||||||
|
course: Course, edition: Edition, groups: List<Group>,
|
||||||
|
selected: Int, onSelect: (Int) -> Unit,
|
||||||
|
onAdd: (String) -> Unit, onUpdate: (Group, String) -> Unit, onDelete: (Group) -> Unit
|
||||||
|
) = Column(Modifier.padding(10.dp)) {
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
var deleting by remember { mutableStateOf(-1) }
|
||||||
|
var editing by remember { mutableStateOf(-1) }
|
||||||
|
|
||||||
|
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 {
|
Row {
|
||||||
Box(Modifier.weight(1f).align(Alignment.CenterVertically)) { singleWidget(it) }
|
Text(it.name(), Modifier.padding(5.dp).align(Alignment.CenterVertically).weight(1f))
|
||||||
editDialog?.let { _ ->
|
Column(Modifier.padding(2.dp)) {
|
||||||
IconButton({ current = it }, Modifier.align(Alignment.CenterVertically)) {
|
Icon(Icons.Default.ArrowUpward, "Move up", Modifier.clickable {
|
||||||
Icon(Icons.Default.Edit, "Edit")
|
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 { showDialog = false }
|
if(showDialog) {
|
||||||
editDialog?.let { d ->
|
dialog("Assignment name", assignments.map{ it.name() }, { showDialog = false }, "", onAdd)
|
||||||
current?.let { c ->
|
}
|
||||||
d(c) { current = null }
|
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(
|
||||||
@Composable
|
"an assignment",
|
||||||
fun StudentsWidget(
|
{ deleting = -1 },
|
||||||
course: Course, edition: Edition, students: List<Student>, selected: Int?, onSelect: (Int) -> Unit,
|
{ onDelete(assignments[deleting]) }
|
||||||
availableStudents: List<Student>, onImport: (List<Student>) -> Unit,
|
) { Text(assignments[deleting].name()) }
|
||||||
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit
|
}
|
||||||
) = EditionSideWidget(
|
|
||||||
course, edition, "Student list (${students.size})", "students", "a student", students, selected, onSelect,
|
|
||||||
{ Text(it.name, Modifier.padding(5.dp)) }
|
|
||||||
) { onExit ->
|
|
||||||
StudentDialog(course, edition, onExit, availableStudents, onImport, onAdd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -261,40 +416,4 @@ 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) }
|
|
||||||
}
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,189 +1,201 @@
|
||||||
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,
|
||||||
) {
|
) {
|
||||||
LazyRow(
|
val clip = LocalClipboardManager.current
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
val scope = rememberCoroutineScope()
|
||||||
modifier = modifier
|
|
||||||
) {
|
Row(modifier.fillMaxWidth()) {
|
||||||
item {
|
LazyRow(
|
||||||
RichTextStyleButton(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
onClick = {
|
modifier = Modifier.weight(1f)
|
||||||
state.toggleSpanStyle(
|
) {
|
||||||
SpanStyle(
|
item {
|
||||||
fontWeight = FontWeight.Bold
|
RichTextStyleButton(
|
||||||
|
onClick = {
|
||||||
|
state.toggleSpanStyle(
|
||||||
|
SpanStyle(
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
},
|
isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold,
|
||||||
isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold,
|
icon = Icons.Outlined.FormatBold
|
||||||
icon = Icons.Outlined.FormatBold
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
RichTextStyleButton(
|
RichTextStyleButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
state.toggleSpanStyle(
|
state.toggleSpanStyle(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
fontStyle = FontStyle.Italic
|
fontStyle = FontStyle.Italic
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
},
|
isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic,
|
||||||
isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic,
|
icon = Icons.Outlined.FormatItalic
|
||||||
icon = Icons.Outlined.FormatItalic
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
RichTextStyleButton(
|
RichTextStyleButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
state.toggleSpanStyle(
|
state.toggleSpanStyle(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
textDecoration = TextDecoration.Underline
|
textDecoration = TextDecoration.Underline
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
},
|
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true,
|
||||||
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true,
|
icon = Icons.Outlined.FormatUnderlined
|
||||||
icon = Icons.Outlined.FormatUnderlined
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
RichTextStyleButton(
|
RichTextStyleButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
state.toggleSpanStyle(
|
state.toggleSpanStyle(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
textDecoration = TextDecoration.LineThrough
|
textDecoration = TextDecoration.LineThrough
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
},
|
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true,
|
||||||
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true,
|
icon = Icons.Outlined.FormatStrikethrough
|
||||||
icon = Icons.Outlined.FormatStrikethrough
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
RichTextStyleButton(
|
RichTextStyleButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
state.toggleSpanStyle(
|
state.toggleSpanStyle(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
fontSize = 28.sp
|
fontSize = 28.sp
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
},
|
isSelected = state.currentSpanStyle.fontSize == 28.sp,
|
||||||
isSelected = state.currentSpanStyle.fontSize == 28.sp,
|
icon = Icons.Outlined.FormatSize
|
||||||
icon = Icons.Outlined.FormatSize
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
RichTextStyleButton(
|
RichTextStyleButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
state.toggleSpanStyle(
|
state.toggleSpanStyle(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
color = Color.Red
|
color = Color.Red
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
},
|
isSelected = state.currentSpanStyle.color == Color.Red,
|
||||||
isSelected = state.currentSpanStyle.color == Color.Red,
|
icon = Icons.Filled.Circle,
|
||||||
icon = Icons.Filled.Circle,
|
tint = Color.Red
|
||||||
tint = Color.Red
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
RichTextStyleButton(
|
RichTextStyleButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
state.toggleSpanStyle(
|
state.toggleSpanStyle(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
background = Color.Yellow
|
background = Color.Yellow
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
},
|
isSelected = state.currentSpanStyle.background == Color.Yellow,
|
||||||
isSelected = state.currentSpanStyle.background == Color.Yellow,
|
icon = Icons.Outlined.Circle,
|
||||||
icon = Icons.Outlined.Circle,
|
tint = Color.Yellow
|
||||||
tint = Color.Yellow
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.height(24.dp)
|
||||||
|
.width(1.dp)
|
||||||
|
.background(Color(0xFF393B3D))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RichTextStyleButton(
|
||||||
|
onClick = {
|
||||||
|
state.toggleUnorderedList()
|
||||||
|
},
|
||||||
|
isSelected = state.isUnorderedList,
|
||||||
|
icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RichTextStyleButton(
|
||||||
|
onClick = {
|
||||||
|
state.toggleOrderedList()
|
||||||
|
},
|
||||||
|
isSelected = state.isOrderedList,
|
||||||
|
icon = Icons.Outlined.FormatListNumbered,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.height(24.dp)
|
||||||
|
.width(1.dp)
|
||||||
|
.background(Color(0xFF393B3D))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RichTextStyleButton(
|
||||||
|
onClick = {
|
||||||
|
state.toggleCodeSpan()
|
||||||
|
},
|
||||||
|
isSelected = state.isCodeSpan,
|
||||||
|
icon = Icons.Outlined.Code,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
IconButton({ state.toClipboard(clip) }) {
|
||||||
Box(
|
Icon(Icons.Default.ContentCopy, contentDescription = "Copy markdown")
|
||||||
Modifier
|
|
||||||
.height(24.dp)
|
|
||||||
.width(1.dp)
|
|
||||||
.background(Color(0xFF393B3D))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
IconButton({ state.loadClipboard(clip, scope) }) {
|
||||||
item {
|
Icon(Icons.Default.ContentPaste, contentDescription = "Paste markdown")
|
||||||
RichTextStyleButton(
|
|
||||||
onClick = {
|
|
||||||
state.toggleUnorderedList()
|
|
||||||
},
|
|
||||||
isSelected = state.isUnorderedList,
|
|
||||||
icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
RichTextStyleButton(
|
|
||||||
onClick = {
|
|
||||||
state.toggleOrderedList()
|
|
||||||
},
|
|
||||||
isSelected = state.isOrderedList,
|
|
||||||
icon = Icons.Outlined.FormatListNumbered,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.height(24.dp)
|
|
||||||
.width(1.dp)
|
|
||||||
.background(Color(0xFF393B3D))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
RichTextStyleButton(
|
|
||||||
onClick = {
|
|
||||||
state.toggleCodeSpan()
|
|
||||||
},
|
|
||||||
isSelected = state.isCodeSpan,
|
|
||||||
icon = Icons.Outlined.Code,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
@ -348,4 +378,83 @@ fun DateTimePicker(
|
||||||
fun ItalicAndNormal(italic: String, normal: String) = Row{
|
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))
|
||||||
}
|
}
|
|
@ -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 {
|
|
||||||
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)
|
|
||||||
}.sortAsc(Groups.name).map { group ->
|
|
||||||
val students = group.studentRoles.sortedBy { it.student.name }.map { sR ->
|
|
||||||
val student = sR.student
|
|
||||||
val role = sR.role
|
|
||||||
val feedback = individuals[student.id]
|
|
||||||
|
|
||||||
student to (role to feedback)
|
|
||||||
}
|
|
||||||
|
|
||||||
groupFeedbacks[group.id]?.let { (f, g) ->
|
|
||||||
group to LocalGFeedback(group, LocalFeedback(f, g), students)
|
|
||||||
} ?: (group to LocalGFeedback(group, null, students))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups
|
return Group.find {
|
||||||
|
(Groups.editionId eq assignment.edition.id)
|
||||||
|
}.sortAsc(Groups.name).map { group ->
|
||||||
|
val forGroup = (GroupFeedbacks innerJoin Groups).selectAll().where {
|
||||||
|
(GroupFeedbacks.assignmentId eq assignment.id) and (Groups.id eq group.id)
|
||||||
|
}.map { row ->
|
||||||
|
val crit = row[GroupFeedbacks.criterionId]?.let { GroupAssignmentCriterion[it] }
|
||||||
|
val fdbk = row[GroupFeedbacks.feedback]
|
||||||
|
val grade = row[GroupFeedbacks.grade]
|
||||||
|
|
||||||
|
crit to FeedbackEntry(fdbk, grade)
|
||||||
|
}
|
||||||
|
|
||||||
|
val global = forGroup.firstOrNull { it.first == null }?.second
|
||||||
|
val byCrit_ = forGroup.map { it.first?.let { k -> LocalCriterionFeedback(k, it.second) } }
|
||||||
|
.filterNotNull().associateBy { it.criterion.id }
|
||||||
|
val byCrit = allCrit.map { c ->
|
||||||
|
byCrit_[c.id] ?: LocalCriterionFeedback(c, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
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) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue