Compare commits

..

15 Commits

46 changed files with 5699 additions and 3015 deletions

View File

@@ -7,12 +7,19 @@ plugins {
} }
kotlin { kotlin {
jvm("desktop") compilerOptions {
freeCompilerArgs.add("-Xcontext-parameters")
}
jvm("desktop") {}
sourceSets { sourceSets {
val desktopMain by getting val desktopMain by getting
commonMain.dependencies { desktopMain.dependencies {
implementation(compose.desktop.currentOs) {
exclude(group = "org.jetbrains.compose.material")
}
implementation(compose.runtime) implementation(compose.runtime)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.material) implementation(compose.material)
@@ -22,19 +29,26 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.material3.core) implementation(libs.material3.core)
implementation(libs.material.icons)
implementation(libs.sl4j) implementation(libs.sl4j)
}
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.coroutines.swing)
implementation(libs.exposed.core) implementation(libs.exposed.core)
implementation(libs.exposed.jdbc) implementation(libs.exposed.jdbc)
implementation(libs.exposed.dao) implementation(libs.exposed.dao)
implementation(libs.exposed.migration)
implementation(libs.exposed.migration.jdbc)
implementation(libs.exposed.kotlin.datetime) implementation(libs.exposed.kotlin.datetime)
implementation(libs.sqlite) implementation(libs.sqlite)
implementation(libs.material3.desktop) implementation(libs.material3.desktop)
implementation(libs.rtfield) implementation(libs.rtfield)
implementation(libs.filekit.core)
implementation(libs.filekit.dialogs)
implementation(libs.filekit.dialogs.compose)
implementation(libs.filekit.coil)
implementation(libs.directories)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.compose.backhandler)
implementation(libs.jewel)
implementation(libs.jewel.windows)
} }
} }
} }
@@ -47,8 +61,13 @@ compose.desktop {
nativeDistributions { nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.jaytux.grader" packageName = "com.jaytux.grader"
mainClass = "com.jaytux.grader.MainKt"
packageVersion = "1.0.0" packageVersion = "1.0.0"
includeAllModules = true includeAllModules = true
linux {
modules("jdk.security.auth")
}
} }
} }
} }

View File

@@ -1,42 +1,39 @@
package com.jaytux.grader package com.jaytux.grader
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import com.jaytux.grader.data.v2.BaseAssignment
import androidx.compose.ui.Modifier import com.jaytux.grader.data.v2.Course
import androidx.compose.ui.unit.dp import com.jaytux.grader.data.v2.Edition
import com.jaytux.grader.ui.ChevronLeft import com.jaytux.grader.ui.EditionTitle
import com.jaytux.grader.ui.CoursesView import com.jaytux.grader.ui.EditionView
import com.jaytux.grader.ui.toDp import com.jaytux.grader.ui.GroupsGradingTitle
import com.jaytux.grader.viewmodel.CourseListState import com.jaytux.grader.ui.GroupsGradingView
import org.jetbrains.compose.ui.tooling.preview.Preview import com.jaytux.grader.ui.HomeTitle
import com.jaytux.grader.ui.HomeView
import com.jaytux.grader.ui.PeerEvalsGradingTitle
import com.jaytux.grader.ui.PeerEvalsGradingView
import com.jaytux.grader.ui.SolosGradingTitle
import com.jaytux.grader.ui.SolosGradingView
import com.jaytux.grader.ui.Surface
import com.jaytux.grader.viewmodel.Navigator
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
data class UiRoute(val heading: String, val content: @Composable (push: (UiRoute) -> Unit) -> Unit) object Home : Navigator.IDestination
data class EditionDetail(val ed: Edition, val course: Course) : Navigator.IDestination
data class GroupGrading(val course: Course, val edition: Edition, val assignment: BaseAssignment) : Navigator.IDestination
data class SoloGrading(val course: Course, val edition: Edition, val assignment: BaseAssignment) : Navigator.IDestination
data class PeerEvalGrading(val course: Course, val edition: Edition, val assignment: BaseAssignment) : Navigator.IDestination
@Composable @Composable
@Preview
fun App() { fun App() {
MaterialTheme { IntUiTheme(isDark = true) {
val courseList = CourseListState() Surface {
var stack by remember { Navigator.NavHost(Home) {
val start = UiRoute("Courses Overview") { CoursesView(courseList, it) } composable<Home>({ HomeTitle() }) { _, token -> HomeView(token) }
mutableStateOf(listOf(start)) composable<EditionDetail>({ EditionTitle(it) }) { data, token -> EditionView(data, token) }
} composable<GroupGrading>({ GroupsGradingTitle(it) }) { data, token -> GroupsGradingView(data, token) }
composable<SoloGrading>({ SolosGradingTitle(it) }) { data, token -> SolosGradingView(data, token) }
Column { composable<PeerEvalGrading>({ PeerEvalsGradingTitle(it) }) { data, token -> PeerEvalsGradingView(data, token) }
Surface(Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primary, tonalElevation = 10.dp, shadowElevation = 10.dp) {
Row(Modifier.padding(10.dp)) {
IconButton({ stack = stack.toMutableList().also { it.removeLast() } }, enabled = stack.size >= 2) {
Icon(ChevronLeft, "Back", Modifier.size(MaterialTheme.typography.headlineLarge.fontSize.toDp()))
}
Text(stack.last().heading, Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.headlineLarge)
}
}
Surface(Modifier.fillMaxSize()) {
Box {
stack.last().content { stack += (it) }
}
} }
} }
} }

View File

@@ -0,0 +1,48 @@
package com.jaytux.grader
sealed class Either<out E , out V> {
class Error<E>(val error: E) : Either<E, Nothing>() {
override fun toString(): String = "Error($error)"
}
class Value<V>(val value: V) : Either<Nothing, V>() {
override fun toString(): String = "Value($value)"
}
fun isError() = this is Error<E>
fun isValue() = this is Value<V>
fun <R> map(f: (V) -> R) = when(this) {
is Error -> this
is Value -> Value(f(value))
}
suspend fun <R> mapSuspend(f: suspend (V) -> R) = when(this) {
is Error -> this
is Value -> Value(f(value))
}
fun <R> mapError(f: (E) -> R) = when(this) {
is Error -> Error(f(error))
is Value -> this
}
suspend fun <R> mapErrorSuspend(f: suspend (E) -> R) = when(this) {
is Error -> Error(f(error))
is Value -> this
}
fun <R> fold(fError: (E) -> R, fValue: (V) -> R): R = when(this) {
is Error -> fError(error)
is Value -> fValue(value)
}
suspend fun <R> foldSuspend(fError: suspend (E) -> R, fValue: suspend (V) -> R): R = when(this) {
is Error -> fError(error)
is Value -> fValue(value)
}
companion object {
fun <E> E.error(): Either<E, Nothing> = Error(this)
fun <V> V.value(): Either<Nothing, V> = Value(this)
}
}

View File

@@ -5,6 +5,9 @@ import androidx.compose.ui.text.AnnotatedString
import com.mohamedrejeb.richeditor.model.RichTextState import com.mohamedrejeb.richeditor.model.RichTextState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.awt.Desktop
import java.net.URI
import java.util.prefs.Preferences
fun String.maxN(n: Int): String { fun String.maxN(n: Int): String {
return if (this.length > n) { return if (this.length > n) {
@@ -14,10 +17,35 @@ fun String.maxN(n: Int): String {
} }
} }
fun RichTextState.toClipboard(clip: ClipboardManager) { suspend fun RichTextState.toClipboard(clip: ClipboardManager) {
clip.setText(AnnotatedString(this.toMarkdown())) clip.setText(AnnotatedString(this.toMarkdown()))
} }
fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) { suspend fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) {
scope.launch { setMarkdown(clip.getText()?.text ?: "") } scope.launch { setMarkdown(clip.getText()?.text ?: "") }
} }
object Preferences {
private val _p = Preferences.userNodeForPackage(this::class.java)
operator fun get(key: String): String? = _p.get(key, null)
operator fun set(key: String, value: String) {
_p.put(key, value)
}
var exportPath
get() = get("exportPath") ?: System.getProperty("user.home") + "/grader_export"
set(value) { set("exportPath", value) }
}
infix fun <T1, T2, T3> Pair<T1, T2>.app(x: T3) = Triple(first, second, x)
fun startEmail(recipients: List<String>, onError: (String) -> Unit) {
if(Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.MAIL)) {
val mailTo = "mailto:${recipients.joinToString(",")}"
Desktop.getDesktop().mail(URI(mailTo))
}
else {
onError("Email client is not supported on this platform.")
}
}

View File

@@ -1,143 +0,0 @@
package com.jaytux.grader.data
import org.jetbrains.exposed.dao.id.CompositeIdTable
import org.jetbrains.exposed.dao.id.UUIDTable
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
object Courses : UUIDTable("courses") {
val name = varchar("name", 50).uniqueIndex()
}
object Editions : UUIDTable("editions") {
val courseId = reference("course_id", Courses.id)
val name = varchar("name", 50)
init {
uniqueIndex(courseId, name)
}
}
object Groups : UUIDTable("groups") {
val editionId = reference("edition_id", Editions.id)
val name = varchar("name", 50)
init {
uniqueIndex(editionId, name)
}
}
object Students : UUIDTable("students") {
val name = varchar("name", 50)
val contact = varchar("contact", 50)
val note = text("note")
}
object GroupStudents : UUIDTable("grpStudents") {
val groupId = reference("group_id", Groups.id)
val studentId = reference("student_id", Students.id)
val role = varchar("role", 50).nullable()
init {
uniqueIndex(groupId, studentId) // can't figure out how to make this a composite key
}
}
object EditionStudents : Table("editionStudents") {
val editionId = reference("edition_id", Editions.id)
val studentId = reference("student_id", Students.id)
override val primaryKey = PrimaryKey(studentId)
}
object GroupAssignments : UUIDTable("grpAssgmts") {
val editionId = reference("edition_id", Editions.id)
val number = integer("number").nullable()
val name = varchar("name", 50)
val assignment = text("assignment")
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") {
val editionId = reference("edition_id", Editions.id)
val number = integer("number").nullable()
val name = varchar("name", 50)
val assignment = text("assignment")
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") {
val assignmentId = reference("group_assignment_id", GroupAssignments.id)
val criterionId = reference("criterion_id", GroupAssignmentCriteria.id).nullable()
val groupId = reference("group_id", Groups.id)
val feedback = text("feedback")
val grade = varchar("grade", 32)
override val primaryKey = PrimaryKey(groupId, criterionId)
}
object IndividualFeedbacks : CompositeIdTable("indivFdbks") {
val assignmentId = reference("group_assignment_id", GroupAssignments.id)
val criterionId = reference("criterion_id", GroupAssignmentCriteria.id).nullable()
val groupId = reference("group_id", Groups.id)
val studentId = reference("student_id", Students.id)
val feedback = text("feedback")
val grade = varchar("grade", 32)
override val primaryKey = PrimaryKey(studentId, criterionId)
}
object SoloFeedbacks : CompositeIdTable("soloFdbks") {
val assignmentId = reference("solo_assignment_id", SoloAssignments.id)
val criterionId = reference("criterion_id", SoloAssignmentCriteria.id).nullable()
val studentId = reference("student_id", Students.id)
val feedback = text("feedback")
val grade = varchar("grade", 32)
override val primaryKey = PrimaryKey(studentId, criterionId)
}
object PeerEvaluationContents : CompositeIdTable("peerEvalCnts") {
val peerEvaluationId = reference("peer_evaluation_id", PeerEvaluations.id)
val groupId = reference("group_id", Groups.id)
val content = text("content")
override val primaryKey = PrimaryKey(peerEvaluationId, groupId)
}
object StudentToGroupEvaluation : CompositeIdTable("stToGrEv") {
val peerEvaluationId = reference("peer_evaluation_id", PeerEvaluations.id)
val studentId = reference("student_id", Students.id)
val grade = varchar("grade", 32)
val note = text("note")
override val primaryKey = PrimaryKey(peerEvaluationId, studentId)
}
object StudentToStudentEvaluation : CompositeIdTable("stToStEv") {
val peerEvaluationId = reference("peer_evaluation_id", PeerEvaluations.id)
val studentIdFrom = reference("student_id_from", Students.id)
val studentIdTo = reference("student_id_to", Students.id)
val grade = varchar("grade", 32)
val note = text("note")
override val primaryKey = PrimaryKey(peerEvaluationId, studentIdFrom, studentIdTo)
}

View File

@@ -1,34 +1,64 @@
package com.jaytux.grader.data package com.jaytux.grader.data
import org.jetbrains.exposed.sql.Database import com.jaytux.grader.app
import org.jetbrains.exposed.sql.SchemaUtils import com.jaytux.grader.data.v2.CategoricGrade
import org.jetbrains.exposed.sql.transactions.transaction import com.jaytux.grader.data.v2.CategoricGradeOption
import com.jaytux.grader.data.v2.CategoricGradeOptions
import com.jaytux.grader.data.v2.CategoricGrades
import com.jaytux.grader.data.v2.Courses
import com.jaytux.grader.data.v2.NumericGrade
import com.jaytux.grader.data.v2.v2Tables
import dev.dirs.ProjectDirectories
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.getValue
import kotlin.io.path.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.batchInsert
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
object Database { object Database {
val db by lazy { val dataDir: String = ProjectDirectories.from("com", "jaytux", "grader").dataDir.also {
val actual = Database.connect("jdbc:sqlite:file:./grader.db", "org.sqlite.JDBC") val path = Path(it)
transaction { if(!path.exists()) path.createDirectories()
SchemaUtils.create(
Courses, Editions, Groups,
Students, GroupStudents, EditionStudents,
GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria,
GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks,
PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation,
StudentToGroupEvaluation
)
val addMissing = SchemaUtils.addMissingColumnsStatements(
Courses, Editions, Groups,
Students, GroupStudents, EditionStudents,
GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria,
GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks,
PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation,
StudentToGroupEvaluation
)
addMissing.forEach { exec(it) }
} }
val db by lazy {
val actual = Database.connect("jdbc:sqlite:file:${dataDir}/grader.db", "org.sqlite.JDBC")
transaction(actual) {
SchemaUtils.create(*v2Tables)
}
actual actual
} }
fun init() { db } fun init() {
TransactionManager.defaultDatabase = db
transaction {
if(CategoricGrade.count() == 0L) {
val (pf, af) = CategoricGrades.batchInsert(listOf("Pass/Fail", "Default A-F"), shouldReturnGeneratedValues = true) {
this[CategoricGrades.name] = it
}.map {
it[CategoricGrades.id]
}
CategoricGradeOptions.batchInsert(
listOf("Pass", "Fail").mapIndexed { idx, it -> it to pf app idx } +
listOf("A (Excellent)", "B (Good)", "C (Satisfactory)", "D (Poor)", "F (Fail)").mapIndexed { idx, it -> it to af app idx }
) {
this[CategoricGradeOptions.option] = it.first
this[CategoricGradeOptions.gradeId] = it.second
this[CategoricGradeOptions.index] = it.third
}
}
if(NumericGrade.count() == 0L) {
NumericGrade.new {
name = "Max-20"
max = 20.0
}
}
}
}
} }

View File

@@ -1,137 +0,0 @@
package com.jaytux.grader.data
import com.jaytux.grader.data.GroupAssignment.Companion.referrersOn
import org.jetbrains.exposed.dao.Entity
import org.jetbrains.exposed.dao.EntityClass
import org.jetbrains.exposed.dao.id.EntityID
import java.util.UUID
class Course(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, Course>(Courses)
var name by Courses.name
val editions by Edition referrersOn Editions.courseId
}
class Edition(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, Edition>(Editions)
var course by Course referencedOn Editions.courseId
var name by Editions.name
val groups by Group referrersOn Groups.editionId
val soloStudents by Student via EditionStudents
val soloAssignments by SoloAssignment referrersOn SoloAssignments.editionId
val groupAssignments by GroupAssignment referrersOn GroupAssignments.editionId
val peerEvaluations by PeerEvaluation referrersOn PeerEvaluations.editionId
}
class Group(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, Group>(Groups)
var edition by Edition referencedOn Groups.editionId
var name by Groups.name
val students by Student via GroupStudents
val studentRoles by GroupMember referrersOn GroupStudents.groupId
}
class GroupMember(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, GroupMember>(GroupStudents)
var student by Student referencedOn GroupStudents.studentId
var group by Group referencedOn GroupStudents.groupId
var role by GroupStudents.role
}
class Student(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, Student>(Students)
var name by Students.name
var note by Students.note
var contact by Students.contact
val groups by Group via GroupStudents
val courses by Edition via EditionStudents
}
class GroupAssignment(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, GroupAssignment>(GroupAssignments)
var edition by Edition referencedOn GroupAssignments.editionId
var number by GroupAssignments.number
var name by GroupAssignments.name
var assignment by GroupAssignments.assignment
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) {
companion object : EntityClass<UUID, SoloAssignment>(SoloAssignments)
var edition by Edition referencedOn SoloAssignments.editionId
var number by SoloAssignments.number
var name by SoloAssignments.name
var assignment by SoloAssignments.assignment
var deadline by SoloAssignments.deadline
val criteria by SoloAssignmentCriterion referrersOn SoloAssignmentCriteria.assignmentId
}
class SoloAssignmentCriterion(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, SoloAssignmentCriterion>(SoloAssignmentCriteria)
var assignment by SoloAssignment referencedOn SoloAssignmentCriteria.assignmentId
var name by SoloAssignmentCriteria.name
var description by SoloAssignmentCriteria.desc
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 PeerEvaluation(id: EntityID<UUID>) : Entity<UUID>(id) {
companion object : EntityClass<UUID, PeerEvaluation>(PeerEvaluations)
var edition by Edition referencedOn PeerEvaluations.editionId
var number by PeerEvaluations.number
var name by PeerEvaluations.name
}

View File

@@ -0,0 +1,118 @@
package com.jaytux.grader.data
//class MdBuilder {
// private val content = StringBuilder()
//
// fun appendHeader(text: String, level: Int = 1) {
// require(level in 1..6) { "Header level must be between 1 and 6" }
// content.appendLine()
// content.appendLine("#".repeat(level) + " $text")
// content.appendLine()
// }
// fun appendMd(text: String) { content.appendLine(text) }
// fun appendParagraph(text: String, bold: Boolean = false, italic: Boolean = false) {
// val formattedText = buildString {
// if (bold) append("**")
// if (italic) append("_")
// append(text)
// if (italic) append("_")
// if (bold) append("**")
// }
// content.appendLine(formattedText)
// content.appendLine()
// }
//
// fun build(title: String, scheme: String = "dark"): String = "${prologue(title, scheme)}\n\n${content.toString()}"
// private fun prologue(title: String, scheme: String = "dark") = """
// ---
// title: $title
// date: ${Clock.System.now()}
// header-includes:
// - '<link rel="stylesheet" href="https://classless.de/classless-tiny.css" media="(prefers-color-scheme: $scheme)" />'
// - '<link rel="stylesheet" href="https://classless.de/addons/themes.css" media="(prefers-color-scheme: light)" />'
// ---
// """.trimIndent()
//}
//
//object Exporter {
// private fun MdBuilder.appendGroupFeedback(assignment: GroupAssignment, it: GroupAssignmentState.LocalGFeedback) {
// appendHeader("${assignment.name} (group: ${it.group.name})", 1)
// if (it.feedback.global != null && it.feedback.global.grade.isNotBlank()) {
// val global = it.feedback.global.grade
// appendParagraph("Overall grade: ${it.feedback.global.grade}", true, true)
//
// it.individuals.forEach { (student, it) ->
// val (_, data) = it
// if (data.global != null && data.global.grade.isNotBlank() && data.global.grade != global) {
// appendParagraph("${student.name} grade: ${data.global.grade}", true, true)
// }
// }
// }
//
// fun appendFeedback(
// heading: String,
// group: GroupAssignmentState.FeedbackEntry?,
// byStudent: List<Pair<Student, GroupAssignmentState.FeedbackEntry>>
// ) {
// if (group != null || byStudent.isNotEmpty()) {
// appendHeader(heading, 2)
// if (group != null) {
// if (group.grade.isNotBlank()) {
// appendParagraph("Group grade: ${group.grade}", true, true)
// }
// if (group.feedback.isNotBlank()) {
// appendMd(group.feedback)
// }
// }
//
// byStudent.forEach { (student, it) ->
// if (it.grade.isNotBlank() || it.feedback.isNotBlank()) appendHeader(student.name, 3)
// if (it.grade.isNotBlank()) {
// appendParagraph("Grade: ${it.grade}", true, true)
// }
// if (it.feedback.isNotBlank()) {
// appendMd(it.feedback)
// }
// }
// }
// }
//
// appendFeedback(
// "Overall Feedback", it.feedback.global,
// it.individuals.mapNotNull { ind -> ind.second.second.global?.let { g -> ind.first to g } }
// )
//
// val criteria = (it.feedback.byCriterion.map { (c, _) -> c } +
// it.individuals.flatMap { (_, x) -> x.second.byCriterion.map { (c, _) -> c } }).distinctBy { x -> x.id.value }
//
// criteria.forEach { c ->
// appendFeedback(
// c.name,
// it.feedback.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry,
// it.individuals.mapNotNull { (student, s) ->
// val entry = s.second.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry
// entry?.let { student to it }
// }
// )
// }
// }
//
// private fun MdBuilder.outputTo(path: Path, title: String) {
// val contents = build(title)
// val buffer = Buffer()
// buffer.write(contents.toByteArray())
// SystemFileSystem.sink(path, false).write(buffer, buffer.size)
// }
//
// fun GroupAssignmentState.LocalGFeedback.exportTo(path: Path, assignment: GroupAssignment) {
// val builder = MdBuilder()
// builder.appendGroupFeedback(assignment, this)
// builder.outputTo(path, "${assignment.name} (for group ${group.name})")
// }
//
// fun GroupAssignmentState.batchExport(dirPath: Path) {
// feedback.entities.value.forEach { (_, it) ->
// it.exportTo(dirPath / "${it.group.name} (${assignment.name}).md", assignment)
// }
// }
//}

View File

@@ -0,0 +1,175 @@
package com.jaytux.grader.data.v2
import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable
import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable
import org.jetbrains.exposed.v1.datetime.datetime
object Courses : UUIDTable("courses") {
val name = varchar("name", 50).uniqueIndex()
}
object Editions : UUIDTable("editions") {
val courseId = reference("course_id", Courses.id)
val name = varchar("name", 50)
val archived = bool("archived").default(false)
init {
uniqueIndex(courseId, name)
}
}
object Groups : UUIDTable("groups") {
val editionId = reference("edition_id", Editions.id)
val name = varchar("name", 50)
init {
uniqueIndex(editionId, name)
}
}
object Students : UUIDTable("students") {
val name = varchar("name", 50)
val contact = varchar("contact", 50)
val note = text("note")
}
object GroupStudents : UUIDTable("grpStudents") {
val groupId = reference("group_id", Groups.id)
val studentId = reference("student_id", Students.id)
val role = varchar("role", 50).nullable()
init {
uniqueIndex(groupId, studentId)
}
}
object EditionStudents : CompositeIdTable("editionStudents") {
val editionId = reference("edition_id", Editions.id)
val studentId = reference("student_id", Students.id)
override val primaryKey = PrimaryKey(editionId, studentId)
}
object BaseAssignments : UUIDTable("baseAssgmts") {
val editionId = reference("edition_id", Editions.id)
val name = varchar("name", 50)
val assignment = text("assignment")
val globalCriterion = reference("global_crit", Criteria.id)
val deadline = datetime("deadline")
val number = integer("number").nullable()
val type = enumerationByName("type", 20, AssignmentType::class)
}
object Criteria : UUIDTable("criteria") {
val assignmentId = reference("assignment_id", BaseAssignments.id)
val name = varchar("name", 50)
val desc = text("desc")
val gradeType = enumerationByName("grade_type", 20, GradeType::class)
val categoricGrade = reference("categoric_grade_id", CategoricGrades.id).nullable()
val numericGrade = reference("numeric_grade_id", NumericGrades.id).nullable()
}
object GroupAssignments : UUIDTable("grpAssgmts") {
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
}
object SoloAssignments : UUIDTable("soloAssgmts") {
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
}
object BaseFeedbacks : UUIDTable("baseFeedbacks") {
val criterionId = reference("criterion_id", Criteria.id)
val feedback = text("feedback")
val gradeFreeText = varchar("grade_text", 32).nullable()
val gradeCategoric = reference("grade_categoric", CategoricGradeOptions.id).nullable()
val gradeNumeric = double("grade_numeric").nullable()
}
object GroupFeedbacks : CompositeIdTable("grpFdbks") {
val groupId = reference("group_id", Groups.id)
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
override val primaryKey = PrimaryKey(groupId, feedbackId)
}
object StudentOverrideFeedbacks : UUIDTable("studOvrFdbks") {
val groupId = reference("group_id", Groups.id)
val studentId = reference("student_id", Students.id)
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
val overrides = reference("overrides", BaseFeedbacks.id)
}
object SoloFeedbacks : CompositeIdTable("soloFdbks") {
val studentId = reference("student_id", Students.id)
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
override val primaryKey = PrimaryKey(studentId, feedbackId)
}
object PeerEvaluations : UUIDTable("peerEvals") {
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
val studentCriterion = reference("student_crit", Criteria.id)
}
object PeerEvaluationFeedbacks : CompositeIdTable("peerEvalFdbks") {
val studentId = reference("student_id", Students.id)
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
override val primaryKey = PrimaryKey(studentId, feedbackId)
}
object PeerEvaluationS2GEvaluations : UUIDTable("peerEvalS2GEvals") {
val peerEvalId = reference("peer_eval_id", PeerEvaluations.id)
val studentId = reference("student_id", Students.id)
val groupId = reference("group_id", Groups.id)
val evaluationId = reference("evaluation_id", BaseFeedbacks.id)
init {
uniqueIndex(peerEvalId, groupId, studentId)
}
}
object PeerEvaluationS2SEvaluations : UUIDTable("peerEvalS2SEvals") {
val peerEvalId = reference("peer_eval_id", PeerEvaluations.id)
val studentId = reference("student_id", Students.id)
val evaluatedStudentId = reference("evaluated_student_id", Students.id)
val evaluationId = reference("evaluation_id", BaseFeedbacks.id)
init {
uniqueIndex(peerEvalId, studentId, evaluatedStudentId)
}
}
object CategoricGrades : UUIDTable("categoricGrades") {
val name = varchar("name", 50).uniqueIndex()
}
object CategoricGradeOptions : UUIDTable("categoricGradeOpts") {
val gradeId = reference("grade_id", CategoricGrades.id)
val option = varchar("option", 50)
val index = integer("index")
init {
uniqueIndex(gradeId, option)
}
}
object NumericGrades : UUIDTable("numericGrades") {
val name = varchar("name", 50).uniqueIndex()
val max = double("max")
}
enum class GradeType {
CATEGORIC, NUMERIC, PERCENTAGE, NONE
}
enum class AssignmentType(val display: String) {
GROUP("Group Assignment"), SOLO("Individual Assignment"), PEER_EVALUATION("Peer Evaluation")
}
val v2Tables = arrayOf(
Courses, Editions, Groups, Students, GroupStudents, EditionStudents, BaseAssignments, Criteria, GroupAssignments,
SoloAssignments, BaseFeedbacks, GroupFeedbacks, StudentOverrideFeedbacks, SoloFeedbacks, PeerEvaluations,
PeerEvaluationFeedbacks, PeerEvaluationS2GEvaluations, PeerEvaluationS2SEvaluations, CategoricGrades,
CategoricGradeOptions, NumericGrades
)

View File

@@ -0,0 +1,181 @@
package com.jaytux.grader.data.v2
import org.jetbrains.exposed.v1.dao.Entity
import org.jetbrains.exposed.v1.dao.EntityClass
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.dao.java.UUIDEntity
import org.jetbrains.exposed.v1.dao.java.UUIDEntityClass
import java.util.UUID
class Course(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<Course>(Courses)
var name by Courses.name
val editions by Edition referrersOn Editions.courseId orderBy Editions.name
}
class Edition(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, Edition>(Editions)
var course by Course referencedOn Editions.courseId
var name by Editions.name
var archived by Editions.archived
val students by Student via EditionStudents orderBy Students.name
val groups by Group referrersOn Groups.editionId orderBy Groups.name
val assignments by BaseAssignment referrersOn BaseAssignments.editionId orderBy BaseAssignments.number
}
class Group(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, Group>(Groups)
var edition by Edition referencedOn Groups.editionId
var name by Groups.name
val students by GroupStudent referrersOn GroupStudents.groupId
val feedbacks by BaseFeedback via GroupFeedbacks
}
class Student(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, Student>(Students)
var name by Students.name
var note by Students.note
var contact by Students.contact
val editions by Edition via EditionStudents orderBy Editions.name
val groups by GroupStudent referrersOn GroupStudents.studentId
}
class GroupStudent(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, GroupStudent>(GroupStudents)
var student by Student referencedOn GroupStudents.studentId
var group by Group referencedOn GroupStudents.groupId
var role by GroupStudents.role
}
class BaseAssignment(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, BaseAssignment>(BaseAssignments)
var name by BaseAssignments.name
var assignment by BaseAssignments.assignment
var globalCriterion by Criterion referencedOn BaseAssignments.globalCriterion
var deadline by BaseAssignments.deadline
var number by BaseAssignments.number
var edition by Edition referencedOn BaseAssignments.editionId
var type by BaseAssignments.type
private val _asGroupAssignment by GroupAssignment referrersOn GroupAssignments.baseAssignmentId
private val _asSoloAssignment by SoloAssignment referrersOn SoloAssignments.baseAssignmentId
private val _asPeerEvaluation by PeerEvaluation referrersOn PeerEvaluations.baseAssignmentId
val asGroupAssignment get() = _asGroupAssignment.singleOrNull()
val asSoloAssignment get() = _asSoloAssignment.singleOrNull()
val asPeerEvaluation get() = _asPeerEvaluation.singleOrNull()
val criteria by Criterion referrersOn Criteria.assignmentId orderBy Criteria.name
val nonBaseCriteria get() = criteria.filterNot { it.id.value == globalCriterion.id.value }
}
class GroupAssignment(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, GroupAssignment>(GroupAssignments)
var base by BaseAssignment referencedOn GroupAssignments.baseAssignmentId
}
class SoloAssignment(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, SoloAssignment>(SoloAssignments)
var base by BaseAssignment referencedOn SoloAssignments.baseAssignmentId
}
class PeerEvaluation(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, PeerEvaluation>(PeerEvaluations)
var base by BaseAssignment referencedOn PeerEvaluations.baseAssignmentId
var studentCriterion by Criterion referencedOn PeerEvaluations.studentCriterion
}
class CategoricGrade(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, CategoricGrade>(CategoricGrades)
var name by CategoricGrades.name
val options by CategoricGradeOption referrersOn CategoricGradeOptions.gradeId orderBy CategoricGradeOptions.index
}
class CategoricGradeOption(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, CategoricGradeOption>(CategoricGradeOptions)
var grade by CategoricGrade referencedOn CategoricGradeOptions.gradeId
var option by CategoricGradeOptions.option
var index by CategoricGradeOptions.index
}
class NumericGrade(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, NumericGrade>(NumericGrades)
var name by NumericGrades.name
var max by NumericGrades.max
}
class Criterion(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, Criterion>(Criteria)
var assignment by BaseAssignment referencedOn Criteria.assignmentId
var name by Criteria.name
var desc by Criteria.desc
var gradeType by Criteria.gradeType
var categoricGrade by CategoricGrade optionalReferencedOn Criteria.categoricGrade
var numericGrade by NumericGrade optionalReferencedOn Criteria.numericGrade
val feedbacks by BaseFeedback referrersOn BaseFeedbacks.criterionId
}
class BaseFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, BaseFeedback>(BaseFeedbacks)
var criterion by Criterion referencedOn BaseFeedbacks.criterionId
var feedback by BaseFeedbacks.feedback
var gradeFreeText by BaseFeedbacks.gradeFreeText
var gradeCategoric by CategoricGradeOption optionalReferencedOn BaseFeedbacks.gradeCategoric
var gradeNumeric by BaseFeedbacks.gradeNumeric
private val _forStudentIfSolo by Student via SoloFeedbacks
private val _forGroupIfGroup by Group via GroupFeedbacks
private val _forStudentIfPeer by Student via PeerEvaluationFeedbacks
val asSoloFeedback get() = _forStudentIfSolo.singleOrNull()
val asGroupFeedback get() = _forGroupIfGroup.singleOrNull()
val asPeerEvaluationFeedback get() = _forStudentIfPeer.singleOrNull()
val forStudentsOverrideIfGroup by StudentOverrideFeedback referrersOn StudentOverrideFeedbacks.overrides
}
class StudentOverrideFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, StudentOverrideFeedback>(StudentOverrideFeedbacks)
var group by Group referencedOn StudentOverrideFeedbacks.groupId
var student by Student referencedOn StudentOverrideFeedbacks.studentId
var feedback by BaseFeedback referencedOn StudentOverrideFeedbacks.feedbackId
var overrides by BaseFeedback referencedOn StudentOverrideFeedbacks.overrides
}
class PeerEvaluationS2G(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, PeerEvaluationS2G>(PeerEvaluationS2GEvaluations)
var peerEvaluation by PeerEvaluation referencedOn PeerEvaluationS2GEvaluations.peerEvalId
var student by Student referencedOn PeerEvaluationS2GEvaluations.studentId
var group by Group referencedOn PeerEvaluationS2GEvaluations.groupId
var evaluation by BaseFeedback referencedOn PeerEvaluationS2GEvaluations.evaluationId
}
class PeerEvaluationS2S(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, PeerEvaluationS2S>(PeerEvaluationS2SEvaluations)
var peerEvaluation by PeerEvaluation referencedOn PeerEvaluationS2SEvaluations.peerEvalId
var student by Student referencedOn PeerEvaluationS2SEvaluations.studentId
var evaluatedStudent by Student referencedOn PeerEvaluationS2SEvaluations.evaluatedStudentId
var evaluation by BaseFeedback referencedOn PeerEvaluationS2SEvaluations.evaluationId
}

View File

@@ -0,0 +1,157 @@
```mermaid
erDiagram
COURSES {
uuid id PK
string name
}
EDITIONS {
uuid id PK
uuid courseId FK
string name
}
COURSES ||--o{ EDITIONS : has
GROUPS {
uuid id PK
uuid editionId FK
string name
}
EDITIONS ||--o{ GROUPS : has
STUDENTS {
uuid id PK
string name
string contact
string note
}
EDITIONS }o--o{ STUDENTS : "has (through edition_students)"
GROUP_STUDENTS {
uuid id PK
uuid groupId FK
uuid studentId FK
maybe(string) role
}
STUDENTS ||--o{ GROUP_STUDENTS : belongs_to
GROUPS ||--o{ GROUP_STUDENTS : has
BASE_ASSIGNMENTS {
uuid id PK
uuid editionId FK
string name
string assignment
uuid globalCriterion FK
datetime deadline
maybe(int) number
}
EDITIONS ||--o{ BASE_ASSIGNMENTS : has
BASE_ASSIGNMENTS ||--|| CRITERIA : "global/main criterion"
CRITERIA {
uuid id PK
uuid assignmentId FK
string name
string desc
GradeType gradeType
maybe(uuid) categoricGrade FK
maybe(uuid) numericGrade FK
}
CRITERIA ||--o{ BASE_ASSIGNMENTS : belongs_to
CRITERIA o|--o{ CATEGORIC_GRADES : "if categoric"
CRITERIA o|--o{ NUMERIC_GRADES : "if numeric"
GROUP_ASSIGNMENTS {
uuid id PK
uuid baseAssignmentId FK
}
BASE_ASSIGNMENTS ||--o{ GROUP_ASSIGNMENTS : is
SOLO_ASSIGNMENTS {
uuid id PK
uuid baseAssignmentId FK
}
BASE_ASSIGNMENTS ||--o{ SOLO_ASSIGNMENTS : is
BASE_FEEDBACKS {
uuid id PK
uuid criterionId FK
string feedback
maybe(string) gradeFreeText
maybe(uuid) gradeCategoric FK
maybe(double) gradeNumeric
}
CRITERIA ||--o{ BASE_FEEDBACKS : has
GROUPS }o--o{ BASE_FEEDBACKS : "has (through group_feedbacks)"
STUDENTS }o--o{ BASE_FEEDBACKS : "has (through solo_feedbacks)"
CATEGORIC_GRADES |o--o{ BASE_FEEDBACKS : "if categoric"
STUDENT_OVERRIDE_FEEDBACKS {
uuid id PK
uuid groupId FK
uuid studentId FK
uuid feedbackId FK
}
GROUPS }o--|| STUDENT_OVERRIDE_FEEDBACKS : "original feedback"
STUDENTS }o--|| STUDENT_OVERRIDE_FEEDBACKS : "overridden for"
BASE_FEEDBACKS ||--o{ STUDENT_OVERRIDE_FEEDBACKS : has
PEER_EVALUATIONS {
uuid id PK
uuid baseAssignmentId FK
}
BASE_ASSIGNMENTS ||--o{ PEER_EVALUATIONS : has
GROUPS }o--o{ PEER_EVALUATIONS : "has (through peer_evaluation_groups)"
PEER_EVALUATION_STUDENT_OVERRIDE_FEEDBACKS {
uuid id PK
uuid groupId FK
uuid studentId FK
uuid feedbackId FK
}
GROUPS }o--|| PEER_EVALUATION_STUDENT_OVERRIDE_FEEDBACKS : "original feedback"
STUDENTS }o--|| PEER_EVALUATION_STUDENT_OVERRIDE_FEEDBACKS : "overridden for"
BASE_FEEDBACKS ||--o{ PEER_EVALUATION_STUDENT_OVERRIDE_FEEDBACKS : has
PEER_EVALUATION_S2G_EVALUATIONS {
uuid id PK
uuid peerEvalId FK
uuid studentId FK
uuid groupId FK
uuid evaluationId FK
}
PEER_EVALUATIONS }o--|| PEER_EVALUATION_S2G_EVALUATIONS : has
STUDENTS }o--|| PEER_EVALUATION_S2G_EVALUATIONS : "evaluates"
GROUPS }o--|| PEER_EVALUATION_S2G_EVALUATIONS : "is evaluated"
BASE_FEEDBACKS ||--o{ PEER_EVALUATION_S2G_EVALUATIONS : "evaluation"
PEER_EVALUATION_S2S_EVALUATIONS {
uuid id PK
uuid peerEvalId FK
uuid studentId FK
uuid evaluatedStudentId FK
uuid evaluationId FK
}
PEER_EVALUATIONS }o--|| PEER_EVALUATION_S2S_EVALUATIONS : has
STUDENTS }o--|| PEER_EVALUATION_S2S_EVALUATIONS : "evaluates"
STUDENTS }o--|| PEER_EVALUATION_S2S_EVALUATIONS : "is evaluated"
BASE_FEEDBACKS ||--o{ PEER_EVALUATION_S2S_EVALUATIONS : "evaluation"
CATEGORIC_GRADES {
uuid id PK
string name
}
CATEGORIC_GRADE_OPTIONS {
uuid id PK
uuid gradeId FK
string option
}
CATEGORIC_GRADES ||--o{ CATEGORIC_GRADE_OPTIONS : has
NUMERIC_GRADES {
uuid id PK
string name
double max
}
```

View File

@@ -2,12 +2,14 @@ package com.jaytux.grader
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import com.jaytux.grader.App
import com.jaytux.grader.data.Database import com.jaytux.grader.data.Database
import io.github.vinceglb.filekit.FileKit
fun main(){ fun main(){
Database.init() Database.init()
application { application {
FileKit.init(appId = "com.jaytux.grader")
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "Grader", title = "Grader",

View File

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

View File

@@ -0,0 +1,537 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.DatePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.TimeInput
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import com.jaytux.grader.GroupGrading
import com.jaytux.grader.PeerEvalGrading
import com.jaytux.grader.SoloGrading
import com.jaytux.grader.data.v2.AssignmentType
import com.jaytux.grader.viewmodel.EditionVM
import com.jaytux.grader.viewmodel.Navigator
import com.jaytux.grader.viewmodel.UiGradeType
import com.mohamedrejeb.richeditor.model.rememberRichTextState
import kotlinx.coroutines.launch
import kotlinx.datetime.*
import kotlinx.datetime.format.MonthNames
import kotlinx.datetime.format.char
import kotlin.time.Instant
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.typography
@Composable
fun AssignmentsView(vm: EditionVM, token: Navigator.NavToken) = Row(Modifier.fillMaxSize()) {
val assignments by vm.assignmentList.entities
val focus by vm.focusIndex
val scope = rememberCoroutineScope()
val descRtf = rememberRichTextState()
val assignment = remember(assignments, focus) {
assignments.getOrNull(focus)?.also {
scope.launch { descRtf.setMarkdown(it.global.criterion.desc) }
}
}
var updatingDeadline by remember { mutableStateOf(false) }
var addingRubric by remember { mutableStateOf(false) }
var editingRubric by remember { mutableStateOf(-1) }
var updatingGrade by remember { mutableStateOf(false) }
val navToGrading = lambda@{
if(assignment == null) return@lambda
when(assignment.assignment.type) {
AssignmentType.GROUP -> token.navTo(GroupGrading(vm.course, vm.edition, assignment.assignment))
AssignmentType.SOLO -> token.navTo(SoloGrading(vm.course, vm.edition, assignment.assignment))
AssignmentType.PEER_EVALUATION -> token.navTo(PeerEvalGrading(vm.course, vm.edition, assignment.assignment))
}
}
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
ListOrEmpty(assignments, { Text("No groups yet.") }) { idx, it ->
QuickAssignment(idx, it, vm)
}
}
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
if (assignment == null) {
Box(Modifier.fillMaxSize()) {
Text("Select an assignment to see details.", Modifier.padding(10.dp).align(Alignment.Center), fontStyle = FontStyle.Italic)
}
} else {
Column(Modifier.padding(10.dp)) {
val peerEvalData by vm.asPeerEvaluation.entity
var updatingPeerEvalGrade by remember { mutableStateOf(false) }
Text(assignment.assignment.name, style = JewelTheme.typography.h2TextStyle)
Text("Deadline: ${assignment.assignment.deadline.format(fmt)}", Modifier.padding(top = 5.dp).clickable { updatingDeadline = true }, fontStyle = FontStyle.Italic)
Row {
Text("${assignment.assignment.type.display} using grading ", Modifier.align(Alignment.CenterVertically))
Surface(shape = JewelTheme.shapes.small) {
Box(Modifier.clickable { updatingGrade = true }.padding(3.dp)) {
Text(when(val t = assignment.global.gradeType){
is UiGradeType.Categoric -> t.grade.name
UiGradeType.FreeText -> "by free-form grades"
is UiGradeType.Numeric -> t.grade.name
UiGradeType.Percentage -> "by percentages"
})
}
}
}
peerEvalData?.let { pe ->
Row {
Text("Students are reviewing each other using ", Modifier.align(Alignment.CenterVertically))
Surface(shape = JewelTheme.shapes.small) {
Box(Modifier.clickable { updatingPeerEvalGrade = true }.padding(3.dp)) {
Text(
when (val t = pe.second) {
is UiGradeType.Categoric -> t.grade.name
UiGradeType.FreeText -> "by free-form grades"
is UiGradeType.Numeric -> t.grade.name
UiGradeType.Percentage -> "by percentages"
}
)
}
}
}
if(updatingPeerEvalGrade) {
SetGradingDialog("${assignment.assignment.name} (peer review grade)", pe.second, vm, { updatingPeerEvalGrade = false }) { type ->
vm.setPEGrade(pe.first, type)
}
}
}
Row {
Column(Modifier.weight(0.75f)) {
Row {
Text("Description:", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(top = 10.dp).weight(1f))
Button({ vm.setDesc(assignment, descRtf.toMarkdown()) }) {
Text("Update")
}
}
Spacer(Modifier.height(10.dp))
RichTextField(descRtf)
}
Spacer(Modifier.width(10.dp))
Surface(Modifier.weight(0.25f), color = Color.White) {
Column(Modifier.padding(15.dp)) {
Row {
Text("Grading Rubrics", Modifier.weight(1f), style = JewelTheme.typography.h2TextStyle)
IconButton({ addingRubric = true }) {
Icon(Icons.CirclePlus, "Add grading rubric")
}
}
Spacer(Modifier.height(10.dp))
LazyColumn(Modifier.weight(1f)) {
itemsIndexed(assignment.criteria) { idx, it ->
Row(Modifier.padding(5.dp)) {
Column(Modifier.weight(1f)) {
Text(it.criterion.name)
Text(it.criterion.desc, Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
}
IconButton({ editingRubric = idx }, Modifier.align(Alignment.Top)) {
Icon(Icons.Edit, "Edit grading rubric")
}
}
}
}
Spacer(Modifier.height(10.dp))
Button({ navToGrading() }, Modifier.fillMaxWidth()) {
Text("Go to grading")
}
}
}
}
}
}
}
if(updatingDeadline) {
if(assignment == null) updatingDeadline = false
else {
DeadlinePicker(assignment.assignment.deadline, { updatingDeadline = false }) {
vm.modAssignment(assignment.assignment, null, it)
}
}
}
if(addingRubric) {
if(assignment == null) addingRubric = false
else {
AddCriterionDialog(null, vm, assignment.criteria.map { it.criterion.name }, { addingRubric = false }) { name, desc, type ->
vm.mkCriterion(assignment.assignment, name, desc, type)
}
}
}
if(editingRubric != -1) {
if(assignment == null) editingRubric = -1
else {
AddCriterionDialog(assignment.criteria[editingRubric], vm, assignment.criteria.map { it.criterion.name }, { editingRubric = -1 }) { name, desc, type ->
vm.modCriterion(assignment.criteria[editingRubric].criterion, name, desc, type)
}
}
}
if(updatingGrade) {
if(assignment == null) updatingGrade = false
else {
SetGradingDialog(assignment.assignment.name, assignment.global.gradeType, vm, { updatingGrade = false }) { type ->
vm.modCriterion(assignment.global.criterion, null, null, type)
}
}
}
}
val fmt = LocalDateTime.Format {
date(LocalDate.Format {
day(); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED); char(' '); year()
})
char(' ')
time(LocalTime.Format {
amPmHour(); char(':'); minute(); char(' '); amPmMarker("AM", "PM")
})
}
@Composable
fun QuickAssignment(idx: Int, assignment: EditionVM.AssignmentData, vm: EditionVM) {
val focus by vm.focusIndex
Surface(markFocused = focus == idx, shape = JewelTheme.shapes.small) {
Column(Modifier.fillMaxWidth().clickable { vm.focus(idx) }.padding(10.dp)) {
Text(assignment.assignment.name, fontWeight = FontWeight.Bold)
Text("Deadline: ${assignment.assignment.deadline.format(fmt)}", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
}
}
}
@Composable
fun AddAssignmentDialog(label: String, taken: List<String>, onClose: () -> Unit, current: String = "", onSave: (String, AssignmentType) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(750.dp, 300.dp), position = WindowPosition(Alignment.Center))
) {
val focus = remember { FocusRequester() }
Surface(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
var type by remember { mutableStateOf(AssignmentType.SOLO) }
var name by remember(current) { mutableStateOf(current) }
Column(Modifier.align(Alignment.Center)) {
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
AssignmentType.entries.forEachIndexed { idx, it ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(idx, AssignmentType.entries.size),
selected = type == it,
onClick = { type = it }
) { Text(it.display) }
}
}
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text(label) }, isError = name in taken)
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
onSave(name, type)
onClose()
}
}
}
}
LaunchedEffect(Unit) { focus.requestFocus() }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeadlinePicker(deadline: LocalDateTime, onDismiss: () -> Unit, onSave: (LocalDateTime) -> Unit) {
val state = rememberDatePickerState(deadline.date.toJavaLocalDate())
val (h, m) = deadline.time.let { it.hour to it.minute }
val time = rememberTimePickerState(h, m)
val reconstruct = {
val inst = Instant.fromEpochMilliseconds(state.selectedDateMillis!!)
val date = inst.toLocalDateTime(TimeZone.currentSystemDefault())
LocalDateTime(date.date, LocalTime(time.hour, time.minute))
}
Dialog(onDismiss, DialogProperties()) {
Surface(shape = JewelTheme.shapes.large) {
Column(Modifier.padding(15.dp)) {
DatePicker(state, Modifier.fillMaxWidth())
TimeInput(time, Modifier.fillMaxWidth())
Row {
Spacer(Modifier.weight(1f))
Button(onDismiss) {
Text("Cancel")
}
Spacer(Modifier.width(10.dp))
Button({ onSave(reconstruct()); onDismiss() }) {
Text("Save")
}
}
}
}
}
}
@Composable
fun AddCriterionDialog(current: EditionVM.CriterionData?, vm: EditionVM, taken: List<String>, onClose: () -> Unit, onSave: (name: String, desc: String, type: UiGradeType) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
) {
val focus = remember { FocusRequester() }
var type by remember(current) { mutableStateOf(current?.gradeType ?: UiGradeType.FreeText) }
var name by remember(current) { mutableStateOf(current?.criterion?.name ?: "") }
var desc by remember(current) { mutableStateOf(current?.criterion?.desc ?: "") }
val categories by vm.categoricGrades.entities
val numeric by vm.numericGrades.entities
Surface(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
Column(Modifier.align(Alignment.Center)) {
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text("Criterion Name") }, isError = name in taken, singleLine = true)
OutlinedTextField(desc, { desc = it }, Modifier.fillMaxWidth(), label = { Text("Short Description") }, singleLine = true)
Surface(shape = JewelTheme.shapes.small, color = Color.White, modifier = Modifier.fillMaxWidth().padding(5.dp)) {
Column {
GradeTypePicker(type, categories, numeric, { n, o -> vm.mkScale(n, o) }, { n, m -> vm.mkNumericScale(n, m) }, Modifier.weight(1f)) { type = it }
CancelSaveRow(name.isNotBlank() && (name !in taken || name == current?.criterion?.name), onClose) {
onSave(name, desc, type)
onClose()
}
}
}
}
}
}
LaunchedEffect(current) { focus.requestFocus() }
}
@Composable
fun SetGradingDialog(name: String, current: UiGradeType, vm: EditionVM, onClose: () -> Unit, onSave: (type: UiGradeType) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
) {
val focus = remember { FocusRequester() }
val categories by vm.categoricGrades.entities
val numeric by vm.numericGrades.entities
var type by remember(current) { mutableStateOf(current) }
Surface(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
Column(Modifier.align(Alignment.Center)) {
Text("Select a grading scale for $name", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(bottom = 10.dp))
Surface(shape = JewelTheme.shapes.small, color = Color.White, modifier = Modifier.fillMaxWidth().padding(5.dp)) {
Column {
GradeTypePicker(type, categories, numeric, { n, o -> vm.mkScale(n, o) }, { n, m -> vm.mkNumericScale(n, m) }, Modifier.weight(1f)) { type = it }
CancelSaveRow(true, onClose) {
onSave(type)
onClose()
}
}
}
}
}
}
LaunchedEffect(current) { focus.requestFocus() }
}
@Composable
fun GradeTypePicker(
type: UiGradeType, categories: List<UiGradeType.Categoric>, numeric: List<UiGradeType.Numeric>,
mkCat: (String, List<String>) -> Unit, mkNum: (String, Double) -> Unit,
modifier: Modifier = Modifier,
onUpdate: (UiGradeType) -> Unit
) = Column(modifier) {
var selectedCategory by remember(categories) {
mutableStateOf(
if(type is UiGradeType.Categoric) categories.indexOfFirst { it.grade.id == type.grade.id }
else -1
)
}
var selectedNumeric by remember(numeric) {
mutableStateOf(
if(type is UiGradeType.Numeric) numeric.indexOfFirst { it.grade.id == type.grade.id }
else -1
)
}
var adding by remember { mutableStateOf(false) }
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(
type is UiGradeType.FreeText, onClick = { onUpdate(UiGradeType.FreeText) },
shape = SegmentedButtonDefaults.itemShape(0, 4)
) {
Text("Free-form grade")
}
SegmentedButton(
type is UiGradeType.Percentage, onClick = { onUpdate(UiGradeType.Percentage) },
shape = SegmentedButtonDefaults.itemShape(1, 4)
) {
Text("Percentage")
}
SegmentedButton(
type is UiGradeType.Categoric, onClick = { onUpdate(categories[maxOf(selectedCategory, 0)]) },
shape = SegmentedButtonDefaults.itemShape(2, 4)
) {
Text("Grading System")
}
SegmentedButton(
type is UiGradeType.Numeric, onClick = { onUpdate(numeric[maxOf(selectedNumeric, 0)]) },
shape = SegmentedButtonDefaults.itemShape(3, 4)
) {
Text("Numeric Grade")
}
}
(type as? UiGradeType.Categoric)?.let {
LazyColumn(Modifier.weight(1f)) {
itemsIndexed(categories) { idx, it ->
Surface(
markFocused = selectedCategory == idx,
shape = JewelTheme.shapes.small
) {
Column(Modifier.fillMaxWidth().clickable { selectedCategory = idx; onUpdate(it) }.padding(10.dp)) {
Text(it.grade.name, fontWeight = FontWeight.Bold)
Text(
"(${it.options.size} options)",
Modifier.padding(start = 10.dp),
fontStyle = FontStyle.Italic
)
}
}
}
item {
Button({ adding = true }, Modifier.fillMaxWidth()) {
Text("Add grading system")
}
}
}
} ?: (type as? UiGradeType.Numeric)?.let {
LazyColumn(Modifier.weight(1f)) {
itemsIndexed(numeric) { idx, it ->
Surface(
markFocused = selectedNumeric == idx,
shape = JewelTheme.shapes.small
) {
Column(Modifier.fillMaxWidth().clickable { selectedNumeric = idx; onUpdate(it) }.padding(10.dp)) {
Text(it.grade.name, fontWeight = FontWeight.Bold)
Text(
"(graded as X/${it.grade.max})",
Modifier.padding(start = 10.dp),
fontStyle = FontStyle.Italic
)
}
}
}
item {
Button({ adding = true }, Modifier.fillMaxWidth()) {
Text("Add numeric system")
}
}
}
} ?: Spacer(Modifier.weight(1f))
if(adding) {
when(type) {
is UiGradeType.Categoric -> AddCatScaleDialog(categories.map { it.grade.name }, { adding = false }) { name, options ->
mkCat(name, options)
}
is UiGradeType.Numeric -> AddNumScaleDialog(numeric.map { it.grade.name }, { adding = false }) { name, max ->
mkNum(name, max)
}
else -> adding = false
}
}
}
@Composable
fun AddCatScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String, List<String>) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
) {
val focus = remember { FocusRequester() }
var name by remember { mutableStateOf("") }
var options by remember { mutableStateOf(listOf<String>()) }
var adding by remember { mutableStateOf("") }
Surface(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
Column(Modifier.align(Alignment.Center)) {
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text("Grading system name") }, isError = name in taken, singleLine = true)
Text("Grade options:", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(top = 10.dp))
LazyColumn(Modifier.weight(1f)) {
itemsIndexed(options) { idx, it ->
Row(Modifier.fillMaxWidth().padding(5.dp)) {
Text(it, Modifier.weight(1f))
IconButton({ options = options.filterNot { o -> o == it } }) {
Icon(Icons.Delete, "Delete grading option")
}
}
}
item {
Row {
OutlinedTextField(adding, { adding = it }, Modifier.weight(1f).align(Alignment.CenterVertically).padding(5.dp), label = { Text("New option") }, isError = adding in options, singleLine = true)
Button({ options = options + adding; adding = "" }, Modifier.align(Alignment.CenterVertically).padding(5.dp), enabled = adding.isNotBlank() && adding !in options) {
Text("Add")
}
}
}
}
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
onSave(name, options)
onClose()
}
}
}
}
LaunchedEffect(Unit) { focus.requestFocus() }
}
@Composable
fun AddNumScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String, Double) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(750.dp, 300.dp), position = WindowPosition(Alignment.Center))
) {
val focus = remember { FocusRequester() }
var name by remember { mutableStateOf("") }
var maxStr by remember { mutableStateOf("0") }
Surface(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
Column(Modifier.align(Alignment.Center)) {
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text("Grading system name") }, isError = name in taken, singleLine = true)
OutlinedTextField(maxStr, { maxStr = it.toDoubleOrNull()?.let { _ -> it } ?: "0" }, Modifier.fillMaxWidth(), label = { Text("Maximum grade") }, singleLine = true)
CancelSaveRow(name.isNotBlank() && name !in taken && (maxStr.toDoubleOrNull() ?: 0.0) > 0.0, onClose) {
onSave(name, maxStr.toDoubleOrNull()!!)
onClose()
}
}
}
}
LaunchedEffect(Unit) { focus.requestFocus() }
}

View File

@@ -1,109 +0,0 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import com.jaytux.grader.UiRoute
import com.jaytux.grader.data.Edition
import com.jaytux.grader.viewmodel.CourseListState
import com.jaytux.grader.viewmodel.EditionListState
import com.jaytux.grader.viewmodel.EditionState
@Composable
fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) {
val data by state.courses.entities
var showDialog by remember { mutableStateOf(false) }
Box(Modifier.padding(15.dp)) {
ListOrEmpty(
data,
{ Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) },
{ Text("Add a course") },
{ showDialog = true },
addAfterLazy = false
) { _, it ->
CourseWidget(state.getEditions(it), { state.delete(it) }, push)
}
}
if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) }
}
@Composable
fun CourseWidget(state: EditionListState, onDelete: () -> Unit, push: (UiRoute) -> Unit) {
val editions by state.editions.entities
var isOpened by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
val callback = { it: Edition ->
val s = EditionState(it)
val route = UiRoute("${state.course.name}: ${it.name}") {
EditionView(s)
}
push(route)
}
Surface(Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 10.dp).clickable { isOpened = !isOpened }, shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 2.dp) {
Row {
Column(Modifier.weight(1f).padding(5.dp)) {
Row {
Icon(
if (isOpened) ChevronDown else ChevronRight, "Toggle editions",
Modifier.size(MaterialTheme.typography.headlineMedium.fontSize.toDp())
.align(Alignment.CenterVertically)
)
Column {
Text(state.course.name, style = MaterialTheme.typography.headlineMedium)
}
}
Row {
Spacer(Modifier.width(25.dp))
Text(
"${editions.size} edition(s)",
fontStyle = FontStyle.Italic,
style = MaterialTheme.typography.bodySmall
)
}
if(isOpened) {
Row {
Spacer(Modifier.width(25.dp))
Column {
editions.forEach { EditionWidget(it, { callback(it) }) { state.delete(it) } }
Button({ showDialog = true }, Modifier.fillMaxWidth()) { Text("Add edition") }
}
}
}
}
Column {
IconButton({ onDelete() }) { Icon(Icons.Default.Delete, "Remove") }
IconButton({ TODO() }, enabled = false) { Icon(Icons.Default.Edit, "Edit") }
}
}
}
if(showDialog) AddStringDialog("Edition name", editions.map { it.name }, { showDialog = false }) { state.new(it) }
}
@Composable
fun EditionWidget(edition: Edition, onOpen: () -> Unit, onDelete: () -> Unit) {
Surface(Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 10.dp).clickable { onOpen() }, shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 2.dp) {
Row(Modifier.padding(5.dp)) {
Text(edition.name, Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall)
IconButton({ onDelete() }) { Icon(Icons.Default.Delete, "Remove") }
}
}
}

View File

@@ -0,0 +1,85 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.EditionDetail
import com.jaytux.grader.viewmodel.EditionVM
import com.jaytux.grader.viewmodel.Navigator
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.typography
@Composable
fun EditionTitle(data: EditionDetail) = Text("Courses / ${data.course.name} / ${data.ed.name}")
@Composable
fun EditionView(data: EditionDetail, token: Navigator.NavToken) {
val vm = viewModel<EditionVM>(key = data.ed.id.toString()) { EditionVM(data.ed, data.course) }
val tab by vm.selectedTab
var adding by remember { mutableStateOf(false) }
val groups by vm.groupList.entities
val assignments by vm.assignmentList.entities
Column(Modifier.padding(10.dp)) {
Row {
Text("${vm.course.name} - ${vm.edition.name}", Modifier.weight(1f), style = JewelTheme.typography.h2TextStyle)
IconButton({ adding = true }) {
Icon(Icons.CirclePlus, "Add ${tab.addText}")
Spacer(Modifier.width(5.dp))
Text("Add ${tab.addText}")
}
}
Spacer(Modifier.height(5.dp))
PrimaryScrollableTabRow(tab.ordinal, edgePadding = 10.dp) {
EditionVM.Tab.entries.forEach {
Tab(tab == it, onClick = { vm.switchTo(it) }, modifier = Modifier.padding(horizontal = 5.dp)) { it.renderTab() }
}
}
Box(Modifier.weight(1f)) {
when (tab) {
EditionVM.Tab.STUDENTS -> StudentsView(vm)
EditionVM.Tab.GROUPS -> GroupsView(vm)
EditionVM.Tab.ASSIGNMENTS -> AssignmentsView(vm, token)
}
}
}
if(adding) {
when(tab) {
EditionVM.Tab.STUDENTS ->
AddStringDialog("Student Name", listOf(), { adding = false }, "") { vm.mkStudent(it, "", "") }
EditionVM.Tab.GROUPS ->
AddStringDialog("Group Name", groups.map { it.group.name }, { adding = false }, "") { vm.mkGroup(it) }
EditionVM.Tab.ASSIGNMENTS ->
AddAssignmentDialog("Assignment Name", assignments.map { it.assignment.name }, { adding = false }, "") { name, type -> vm.mkAssignment(name, type) }
}
}
}
@Composable
fun StudentsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
Icon(Icons.UserIcon, "Students")
Spacer(Modifier.width(5.dp))
Text("Students")
}
@Composable
fun GroupsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
Icon(Icons.UserGroupIcon, "Groups")
Spacer(Modifier.width(5.dp))
Text("Groups")
}
@Composable
fun AssignmentsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
Icon(Icons.AssignmentIcon, "Assignments")
Spacer(Modifier.width(5.dp))
Text("Assignments")
}

View File

@@ -1,419 +0,0 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberDialogState
import com.jaytux.grader.data.Course
import com.jaytux.grader.data.Edition
import com.jaytux.grader.data.Group
import com.jaytux.grader.data.Student
import com.jaytux.grader.viewmodel.*
data class Navigators(
val student: (Student) -> Unit,
val group: (Group) -> Unit,
val assignment: (Assignment) -> Unit
)
@Composable
fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) {
val course = state.course; val edition = state.edition
val students by state.students.entities
val availableStudents by state.availableStudents.entities
val groups by state.groups.entities
val solo by state.solo.entities
val groupAs by state.groupAs.entities
val peers by state.peer.entities
val mergedAssignments by remember(solo, groupAs, peers) { mutableStateOf(Assignment.merge(groupAs, solo, peers)) }
val hist by state.history
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) {
TabLayout(
OpenPanel.entries,
tab.ordinal,
{ state.navTo(OpenPanel.entries[it]) },
{ Text(it.tabName) }
) {
when(tab) {
OpenPanel.Student -> StudentPanel(
course, edition, students, availableStudents, id,
{ state.navTo(it) },
{ name, note, contact, add -> state.newStudent(name, contact, note, add) },
{ students -> state.addToCourse(students) },
{ s, name -> state.setStudentName(s, name) }
) { s -> state.delete(s) }
OpenPanel.Group -> GroupPanel(
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(1f)) {
if (id != -1) {
when (tab) {
OpenPanel.Student -> StudentView(StudentState(students[id], edition), navs)
OpenPanel.Group -> GroupView(GroupState(groups[id]), navs)
OpenPanel.Assignment -> {
when (val a = mergedAssignments[id]) {
is Assignment.SAssignment -> SoloAssignmentView(SoloAssignmentState(a.assignment))
is Assignment.GAssignment -> GroupAssignmentView(GroupAssignmentState(a.assignment))
is Assignment.PeerEval -> PeerEvaluationView(PeerEvaluationState(a.evaluation))
}
}
}
}
}
}
}
@Composable
fun StudentPanel(
course: Course, edition: Edition, students: List<Student>, available: List<Student>,
selected: Int, onSelect: (Int) -> Unit,
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit,
onImport: (List<Student>) -> Unit, onUpdate: (Student, String) -> Unit, onDelete: (Student) -> Unit
) = Column(Modifier.padding(10.dp)) {
var showDialog by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(-1) }
var editing by remember { mutableStateOf(-1) }
Text("Student list (${students.size})", style = MaterialTheme.typography.headlineMedium)
ListOrEmpty(
students,
{ Text(
"Course ${course.name} (edition ${edition.name})\nhas no students yet.",
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
) },
{ Text("Add a student") },
{ showDialog = true }
) { idx, it ->
SelectEditDeleteRow(
selected == idx,
{ onSelect(idx) }, { onSelect(-1) },
{ 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 {
Text(it.name(), Modifier.padding(5.dp).align(Alignment.CenterVertically).weight(1f))
Column(Modifier.padding(2.dp)) {
Icon(Icons.Default.ArrowUpward, "Move up", Modifier.clickable {
if(idx > 0) onSwapOrder(assignments[idx], assignments[idx - 1])
})
Icon(Icons.Default.ArrowDownward, "Move down", Modifier.clickable {
if(idx < assignments.size - 1) onSwapOrder(assignments[idx], assignments[idx + 1])
})
}
Column(Modifier.padding(2.dp)) {
Icon(Icons.Default.Edit, "Edit", Modifier.clickable { editing = idx })
Icon(Icons.Default.Delete, "Delete", Modifier.clickable { deleting = idx })
}
}
}
}
if(showDialog) {
dialog("Assignment name", assignments.map{ it.name() }, { showDialog = false }, "", onAdd)
}
else if(editing != -1) {
AddStringDialog("Assignment name", assignments.map { it.name() }, { editing = -1 }, assignments[editing].name()) {
onUpdate(assignments[editing], it)
}
}
else if(deleting != -1) {
ConfirmDeleteDialog(
"an assignment",
{ deleting = -1 },
{ onDelete(assignments[deleting]) }
) { Text(assignments[deleting].name()) }
}
}
@Composable
fun StudentDialog(
course: Course,
edition: Edition,
onClose: () -> Unit,
availableStudents: List<Student>,
onImport: (List<Student>) -> Unit,
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit
) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(600.dp, 400.dp), position = WindowPosition(Alignment.Center))
) {
Surface(Modifier.fillMaxSize()) {
Column(Modifier.padding(10.dp)) {
var isImport by remember { mutableStateOf(false) }
TabRow(if(isImport) 1 else 0) {
Tab(!isImport, { isImport = false }) { Text("Add new student") }
Tab(isImport, { isImport = true }) { Text("Add existing student") }
}
if(isImport) {
if(availableStudents.isEmpty()) {
Box(Modifier.fillMaxSize()) {
Text("No students available to add to this course.", Modifier.align(Alignment.Center))
}
}
else {
var selected by remember { mutableStateOf(setOf<Int>()) }
val onClick = { idx: Int ->
selected = if(idx in selected) selected - idx else selected + idx
}
Text("Select students to add to ${course.name} ${edition.name}")
LazyColumn {
itemsIndexed(availableStudents) { idx, student ->
Surface(
Modifier.fillMaxWidth().clickable { onClick(idx) },
tonalElevation = if (selected.contains(idx)) 5.dp else 0.dp
) {
Row {
Checkbox(selected.contains(idx), { onClick(idx) })
Text(student.name, Modifier.padding(5.dp))
}
}
}
}
CancelSaveRow(selected.isNotEmpty(), onClose) {
onImport(selected.map { idx -> availableStudents[idx] })
onClose()
}
}
}
else {
Box(Modifier.fillMaxSize()) {
var name by remember { mutableStateOf("") }
var contact by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") }
var add by remember { mutableStateOf(true) }
Column(Modifier.align(Alignment.Center)) {
OutlinedTextField(
name,
{ name = it },
Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Student name") })
OutlinedTextField(
contact,
{ contact = it },
Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Student contact") })
OutlinedTextField(
note,
{ note = it },
Modifier.fillMaxWidth(),
singleLine = false,
minLines = 3,
label = { Text("Note") })
Row {
Checkbox(add, { add = it })
Text(
"Add student to ${course.name} ${edition.name}?",
Modifier.align(Alignment.CenterVertically)
)
}
CancelSaveRow(name.isNotBlank() && contact.isNotBlank(), onClose) {
onAdd(name, note, contact, add)
onClose()
}
}
}
}
}
}
}

View File

@@ -0,0 +1,228 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.GroupGrading
import com.jaytux.grader.app
import com.jaytux.grader.data.v2.Group
import com.jaytux.grader.viewmodel.GroupsGradingVM
import com.jaytux.grader.viewmodel.Navigator
import java.util.*
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.typography
@Composable
fun GroupsGradingTitle(data: GroupGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Group Assignments / ${data.assignment.name} / Grading")
@Composable
fun GroupsGradingView(data: GroupGrading, token: Navigator.NavToken) {
val vm = viewModel<GroupsGradingVM>(key = data.assignment.id.toString()) {
GroupsGradingVM(data.course, data.edition, data.assignment)
}
val groups by vm.groupList.entities
val focus by vm.focus
val selectedGroup = remember(focus, groups) { groups.getOrNull(focus) }
Column(Modifier.padding(10.dp)) {
Text("Grading ${vm.base.name}", style = JewelTheme.typography.h2TextStyle)
Text("Group assignment in ${vm.course.name} - ${vm.edition.name}")
Spacer(Modifier.height(5.dp))
Row(Modifier.fillMaxSize()) {
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
QuickAGroup(idx == focus, { vm.focusGroup(idx) }, it)
}
}
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
if (focus == -1 || selectedGroup == null) {
Box(Modifier.weight(0.75f).fillMaxHeight()) {
Text("Select a group to start grading.", Modifier.align(Alignment.Center))
}
} else {
Column(Modifier.weight(0.75f).padding(15.dp)) {
Row {
IconButton({ vm.focusPrev() }, Modifier.align(Alignment.CenterVertically), enabled = focus > 0) {
Icon(Icons.DoubleBack, "Previous group")
}
Spacer(Modifier.width(10.dp))
Text(selectedGroup.group.name, Modifier.align(Alignment.CenterVertically), style = JewelTheme.typography.h2TextStyle)
Spacer(Modifier.weight(1f))
IconButton({ vm.focusNext() }, Modifier.align(Alignment.CenterVertically), enabled = focus < groups.size - 1) {
Icon(Icons.DoubleForward, "Next group")
}
}
Spacer(Modifier.height(10.dp))
val global by vm.globalGrade.entity
val byCriteria by vm.gradeList.entities
Surface(Modifier.fillMaxSize(), color = Color.White, shape = JewelTheme.shapes.medium) {
LazyColumn {
items(byCriteria ?: listOf()) { (crit, fdbk) ->
var isOpen by remember(selectedGroup) { mutableStateOf(false) }
Column(Modifier.padding(5.dp)) {
GFWidget(crit, selectedGroup.group, fdbk, vm, global to byCriteria, isOpen, showDesc = true) { isOpen = !isOpen }
Spacer(Modifier.height(5.dp))
}
}
global?.let { fdbk ->
item {
Box(Modifier.padding(5.dp)) {
GFWidget(
vm.global, selectedGroup.group, fdbk, vm, global to byCriteria, true,
markOverridden = (byCriteria ?: listOf()).flatMap { (_, it) ->
it.overrides.mapNotNull { o ->
o.second?.let { _ -> o.first.id.value }
}
}.toSet(), overrideName = "Global grade"
) {}
}
}
}
}
}
}
}
}
}
}
}
@Composable
fun QuickAGroup(isFocus: Boolean, onFocus: () -> Unit, group: GroupsGradingVM.GroupData) {
Surface(markFocused = isFocus, shape = JewelTheme.shapes.small) {
Column(Modifier.fillMaxWidth().clickable { onFocus() }.padding(10.dp)) {
Text(group.group.name, fontWeight = FontWeight.Bold)
Text("${group.students.size} student(s)", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
}
}
}
@Composable
fun GFWidget(
crit: CritData, gr: Group, feedback: GroupsGradingVM.FeedbackData, vm: GroupsGradingVM, key: Any,
isOpen: Boolean, showDesc: Boolean = false, overrideName: String? = null, markOverridden: Set<UUID> = setOf(),
onToggle: () -> Unit
) = Surface(Modifier.fillMaxWidth(), shape = JewelTheme.shapes.medium) {
Column {
Surface {
Row(Modifier.fillMaxWidth().clickable { onToggle() }.padding(10.dp)) {
Icon(if(isOpen) Icons.ChevronDown else Icons.ChevronRight, "Toggle criterion detail grading", Modifier.align(Alignment.CenterVertically))
Spacer(Modifier.width(5.dp))
Column(Modifier.align(Alignment.CenterVertically)) {
Row {
Text(overrideName ?: crit.criterion.name, style = JewelTheme.typography.h4TextStyle)
Spacer(Modifier.width(5.dp))
feedback.groupLevel?.grade?.let {
Row(Modifier.align(Alignment.Bottom)) {
// ProvideTextStyle(JewelTheme.typography.small) {
Text("(Grade: ")
it.render()
Text(")")
// }
}
}
}
if(showDesc) {
Text(crit.criterion.desc, fontStyle = FontStyle.Italic, modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp).fillMaxWidth())
}
}
}
}
if(isOpen) {
Row(Modifier.padding(10.dp)) {
var grade by remember(key, feedback) { mutableStateOf(gradeState(crit, feedback.groupLevel?.grade)) }
var text by remember(key, feedback) { mutableStateOf(feedback.groupLevel?.feedback ?: "") }
Column(Modifier.weight(0.5f).height(IntrinsicSize.Min)) {
GradePicker(grade, key = crit to gr) { grade = it }
Spacer(Modifier.height(5.dp))
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 5, modifier = Modifier.fillMaxWidth().weight(1f))
Spacer(Modifier.height(5.dp))
DefaultButton({ vm.modGroupFeedback(crit.criterion, gr, grade, text) }, Modifier.padding(horizontal = 20.dp).fillMaxWidth()) {
Text("Save grade and feedback")
}
}
feedback.groupLevel?.let { groupLevel ->
Spacer(Modifier.width(10.dp))
Surface(Modifier.weight(0.5f).height(IntrinsicSize.Min), shape = JewelTheme.shapes.small) {
Column(Modifier.padding(10.dp)) {
Text("Individual overrides", style = JewelTheme.typography.h4TextStyle)
feedback.overrides.forEach { (student, it) ->
var enable by remember(key, it) { mutableStateOf(it != null) }
var maybeRemoving by remember(key, it) { mutableStateOf(false) }
var sGrade by remember(key, it) { mutableStateOf(gradeState(crit, it?.grade ?: grade)) }
var sText by remember(key, it) { mutableStateOf(it?.feedback ?: "") }
Column {
Row {
Checkbox(enable, { if(it) { enable = true } else { maybeRemoving = true } })
Spacer(Modifier.width(5.dp))
Text(student.name, Modifier.align(Alignment.CenterVertically))
if(student.id.value in markOverridden) {
Spacer(Modifier.width(5.dp))
Text("(Overridden)", Modifier.align(Alignment.CenterVertically), style = JewelTheme.typography.small, fontStyle = FontStyle.Italic, color = Color.Red)
}
}
if(enable) Row {
Spacer(Modifier.width(15.dp))
Surface(color = Color.White, shape = JewelTheme.shapes.small) {
Column(Modifier.padding(10.dp)) {
Spacer(Modifier.height(5.dp))
GradePicker(sGrade, key = crit to gr app student) { sGrade = it }
Spacer(Modifier.height(5.dp))
OutlinedTextField(sText, { sText = it }, label = { Text("Feedback") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(5.dp))
DefaultButton({ vm.modOverrideFeedback(crit.criterion, gr, student, groupLevel, sGrade, sText) }) {
Text("Save override")
}
}
}
}
}
if(maybeRemoving) {
ConfirmDeleteDialog("the individual grade for ${student.name}", { maybeRemoving = false }, {
maybeRemoving = false
enable = false
vm.rmOverrideFeedback(crit.criterion, gr, student)
}) {
Column {
Row {
Text("Grade:")
sGrade.render()
}
Row {
Text("Feedback:")
if(sText.isBlank()) Text("No feedback", fontStyle = FontStyle.Italic)
else Text(sText)
}
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,300 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.draganddrop.dragAndDropSource
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draganddrop.DragAndDropTransferAction
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.draganddrop.DragAndDropTransferable
import androidx.compose.ui.draganddrop.awtTransferable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.data.v2.Group
import com.jaytux.grader.data.v2.Student
import com.jaytux.grader.startEmail
import com.jaytux.grader.viewmodel.EditionVM
import com.jaytux.grader.viewmodel.SnackVM
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
import java.util.UUID
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.foundation.theme.LocalTextStyle
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.typography
@Composable
fun GroupsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
val groups by vm.groupList.entities
val focus by vm.focusIndex
var swappingRole by remember { mutableStateOf(-1) }
val group = remember(groups, focus) { if(focus != -1) groups[focus] else null }
val grades by vm.groupGrades.entities
val snacks = viewModel<SnackVM> { SnackVM() }
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
QuickGroup(idx, it, vm)
}
}
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
if(group == null) {
Box(Modifier.weight(0.75f).fillMaxHeight()) {
Text("Select a group to view details.", Modifier.align(Alignment.Center))
}
}
else {
Column(Modifier.padding(10.dp)) {
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
Text(group.group.name, style = JewelTheme.typography.h2TextStyle)
if (group.members.any { it.first.contact.isNotBlank() }) {
IconButton({ startEmail(group.members.mapNotNull { it.first.contact.ifBlank { null } }) { snacks.show(it) } }) {
Icon(Icons.Mail, "Send email", Modifier.fillMaxHeight())
}
}
}
Spacer(Modifier.height(5.dp))
Row(Modifier.padding(5.dp)) {
var showTargetBorder by remember { mutableStateOf(false) }
val ddTarget = remember {
DDTarget({ showTargetBorder = true }, { showTargetBorder = false }, { DDTarget.extractStudent(it) }) {
vm.addStudentToGroup(it, group.group, null)
}
}
Column(Modifier.weight(0.75f)) {
Surface(
Modifier.weight(0.5f).then(if(showTargetBorder) Modifier.border(BorderStroke(3.dp, Color.Black)) else Modifier)
.dragAndDropTarget({ true }, target = ddTarget),
shape = JewelTheme.shapes.medium, color = Color.White) {
LazyColumn {
item {
Surface {
Row(Modifier.fillMaxWidth().padding(10.dp)) {
Text("Members", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(10.dp))
}
}
}
itemsIndexed(group.members) { idx, (student, role) ->
Row(Modifier.clickable { vm.focus(student) }.padding(10.dp)) {
Column(Modifier.weight(1f)) {
Text(student.name, fontWeight = FontWeight.Bold)
if(student.contact.isEmpty())
Text("No contact info.", fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
else Text(student.contact)
}
if(role != null) {
Surface(Modifier.align(Alignment.CenterVertically), shape = JewelTheme.shapes.small) {
Box(Modifier.clickable { swappingRole = -1 }.clickable { swappingRole = idx }) {
Text(role, Modifier.padding(horizontal = 5.dp, vertical = 2.dp), style = JewelTheme.typography.regular)
}
}
}
else {
Text("No role", Modifier.align(Alignment.CenterVertically).clickable { swappingRole = idx }, fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
}
IconButton({ vm.rmStudentFromGroup(student, group.group) }, Modifier.align(Alignment.CenterVertically)) {
Icon(Icons.PersonMinus, "Remove ${student.name} from group")
}
}
}
if(group.members.isEmpty()) {
item {
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
Text("No members yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
}
}
}
}
}
Spacer(Modifier.height(10.dp))
Column(Modifier.weight(0.5f)) {
Text("Grade Summary: ", style = JewelTheme.typography.h2TextStyle)
Surface(shape = JewelTheme.shapes.medium, color = Color.White) {
LazyColumn(Modifier.fillMaxHeight()) {
item {
Surface {
Row(Modifier.padding(10.dp)) {
Text("Assignment", Modifier.weight(0.66f))
Text("Grade", Modifier.weight(0.33f))
}
}
}
items(grades ?: listOf()) {
Column(Modifier.padding(10.dp)) {
Row {
Text(it.first.name, Modifier.weight(0.66f))
it.second?.render(Modifier.weight(0.33f))
?: Text("---", Modifier.weight(0.33f), color = LocalTextStyle.current.color.copy(alpha = 0.5f))
}
}
}
if((grades ?: listOf()).isEmpty()) {
item {
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
Text("No grades yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
}
}
}
}
}
}
}
Spacer(Modifier.width(10.dp))
val available by vm.groupAvailableStudents.entities
Surface(Modifier.weight(0.25f), shape = JewelTheme.shapes.medium, color = Color.White) {
LazyColumn {
item {
Surface {
Row(Modifier.fillMaxWidth().padding(10.dp)) {
Text("Available Students", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(10.dp))
}
}
}
items(available ?: listOf()) { student ->
AvailableStudent(student, group.group, vm)
}
if((available ?: listOf()).isEmpty()) {
item {
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
Text("No available students.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
}
}
}
}
}
}
}
}
}
if(swappingRole != -1) {
if(group != null) {
val roles by vm.usedRoles.entities
RolePicker(roles, group.members[swappingRole].second, { swappingRole = -1 }) { upd ->
vm.setStudentRole(group.members[swappingRole].first, group.group, upd)
}
}
else {
swappingRole = -1
}
}
}
private class DDTarget<T>(val onStart: () -> Unit, val onEnd: () -> Unit, val validator: (Transferable) -> T?, val handle: (T) -> Unit) : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
onStart()
super.onStarted(event)
}
override fun onEnded(event: DragAndDropEvent) {
onEnd()
super.onEnded(event)
}
@OptIn(ExperimentalComposeUiApi::class)
override fun onDrop(event: DragAndDropEvent): Boolean {
println("Action at the target: ${event.action}")
return validator(event.awtTransferable)?.let {
handle(it)
true
} ?: false
}
companion object {
@OptIn(ExperimentalComposeUiApi::class)
fun mkStudentTransferable(student: Student) = DragAndDropTransferable(StringSelection("com.jaytux.grader:student:${student.id.value}"))
fun extractStudent(transf: Transferable): Student? {
if(transf.isDataFlavorSupported(DataFlavor.stringFlavor)) {
val raw = transf.getTransferData(DataFlavor.stringFlavor) as String
val prefix = "com.jaytux.grader:student:"
if(raw.startsWith(prefix)) {
val id = UUID.fromString(raw.removePrefix(prefix))
return transaction { Student.findById(id) }
}
}
return null
}
}
}
@Composable
fun QuickGroup(idx: Int, group: EditionVM.GroupData, vm: EditionVM) {
val focus by vm.focusIndex
Surface(markFocused = focus == idx, shape = JewelTheme.shapes.small) {
Column(Modifier.fillMaxWidth().clickable { vm.focus(idx) }.padding(10.dp)) {
Text(group.group.name, fontWeight = FontWeight.Bold)
Text("${group.members.size} member(s)", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AvailableStudent(student: Student, group: Group, vm: EditionVM) {
Row(Modifier.padding(10.dp).dragAndDropSource(
drawDragDecoration = {},
) {
DragAndDropTransferData(
transferable = DDTarget.mkStudentTransferable(student),
supportedActions = listOf(DragAndDropTransferAction.Move),
dragDecorationOffset = it,
onTransferCompleted = { act -> println("Source action: $act") }
)
}) {
Text(student.name, Modifier.align(Alignment.CenterVertically).weight(1f), fontWeight = FontWeight.Bold)
IconButton({ vm.addStudentToGroup(student, group, null) }) {
Icon(Icons.CirclePlus, "Add ${student.name} to group")
}
}
}
@Composable
fun Button(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) = DefaultButton(onClick, modifier, enabled) {
Row { content() }
}

View File

@@ -0,0 +1,148 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.EditionDetail
import com.jaytux.grader.data.v2.Edition
import com.jaytux.grader.viewmodel.HomeVM
import com.jaytux.grader.viewmodel.Navigator
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.typography
@Composable
fun HomeTitle() = Text("Grader")
@Composable
fun HomeView(token: Navigator.NavToken) {
val vm = viewModel<HomeVM> { HomeVM() }
val courses by vm.courses.entities
var addingCourse by remember { mutableStateOf(false) }
LazyColumn(Modifier.padding(15.dp)) {
item {
Row {
Text("Courses Overview", Modifier.weight(0.8f), style = JewelTheme.typography.h2TextStyle)
DefaultButton({ addingCourse = true }) {
Icon(Icons.CirclePlus, "Add course")
Spacer(Modifier.width(5.dp))
Text("Add course")
}
}
}
items(courses) {
CourseCard(it, vm) { e -> token.navTo(EditionDetail(e, it.course)) }
}
}
if(addingCourse) {
AddStringDialog("Course Name", courses.map { it.course.name }, { addingCourse = false }, "") {
vm.mkCourse(it)
}
}
}
@Composable
fun CourseCard(course: HomeVM.CourseData, vm: HomeVM, onOpenEdition: (Edition) -> Unit) {
var addingEdition by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) }
Surface(shape = JewelTheme.shapes.medium, modifier = Modifier.fillMaxWidth().padding(10.dp)) {
Column(Modifier.padding(8.dp)) {
Row {
Text(course.course.name, style = JewelTheme.typography.h2TextStyle, modifier = Modifier.weight(1f))
IconButton({ deleting = true }) { Icon(Icons.Delete, "Delete course") }
}
Row {
Text("Editions", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.weight(1f))
DefaultButton({ addingEdition = true }) {
Icon(Icons.CirclePlus, "Add edition")
Spacer(Modifier.width(5.dp))
Text("Add edition")
}
}
FlowRow(horizontalArrangement = Arrangement.SpaceEvenly) {
course.editions.forEach { EditionCard(course.course.name, it, vm, onOpenEdition) }
}
if(course.archived.isNotEmpty()) {
Text("Archived editions", style = JewelTheme.typography.h2TextStyle)
FlowRow(horizontalArrangement = Arrangement.SpaceEvenly) {
course.archived.forEach { EditionCard(course.course.name, it, vm, onOpenEdition) }
}
}
}
}
if(addingEdition) {
AddStringDialog("Edition Name (in ${course.course.name})", course.editions.map { it.edition.name }, { addingEdition = false }, "") {
vm.mkEdition(course.course, it)
}
}
if(deleting) {
ConfirmDeleteDialog("a course", { deleting = false }, { vm.rmCourse(course.course) }) {
Text(course.course.name)
}
}
}
@Composable
fun EditionCard(courseName: String, edition: HomeVM.EditionData, vm: HomeVM, onOpen: (Edition) -> Unit) {
val type = if(edition.edition.archived) "Archived" else "Active"
var deleting by remember { mutableStateOf(false) }
Surface(shape = JewelTheme.shapes.medium, modifier = Modifier.padding(10.dp).clickable { onOpen(edition.edition) }) {
Column(Modifier.padding(10.dp).width(IntrinsicSize.Min)) {
Column(Modifier.width(IntrinsicSize.Max)) {
Text(edition.edition.name, style = JewelTheme.typography.h2TextStyle)
Text(
"$type\n${edition.students.size} student(s) • ${edition.groups.size} group(s) • ${edition.assignments.size} assignment(s)",
style = JewelTheme.typography.regular
)
}
Spacer(Modifier.height(5.dp))
Row {
if(edition.edition.archived) {
DefaultButton({ vm.unarchiveEdition(edition.edition) }, Modifier.weight(0.5f)) {
Icon(Icons.Unarchive, "Unarchive edition")
Spacer(Modifier.width(5.dp))
Text("Unarchive edition")
}
}
else {
DefaultButton({ vm.archiveEdition(edition.edition) }, Modifier.weight(0.5f)) {
Icon(Icons.Archive, "Archive edition")
Spacer(Modifier.width(5.dp))
Text("Archive edition")
}
}
Spacer(Modifier.width(10.dp))
DefaultButton({ deleting = true }, Modifier.weight(0.5f)) {
Icon(Icons.Delete, "Archive edition")
Spacer(Modifier.width(5.dp))
Text("Delete edition")
}
}
}
}
if(deleting) {
ConfirmDeleteDialog("an edition", { deleting = false }, { vm.rmEdition(edition.edition) }) {
Column {
Text(edition.edition.name, Modifier.align(Alignment.CenterHorizontally))
Text("Edition in course $courseName", Modifier.align(Alignment.CenterHorizontally))
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
package com.jaytux.grader.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.jewel.foundation.theme.LocalContentColor
interface IconData {
val ChevronRight: ImageVector
val ChevronDown: ImageVector
val ChevronLeft: ImageVector
val Delete: ImageVector
val CirclePlus: ImageVector
val LibraryPlus: ImageVector
val Archive: ImageVector
val Unarchive: ImageVector
val FormatSize: ImageVector
val CircleFilled: ImageVector
val CircleOutline: ImageVector
val FormatListBullet: ImageVector
val FormatListNumber: ImageVector
val FormatCode: ImageVector
val ContentCopy: ImageVector
val ContentPaste: ImageVector
val FormatItalic: ImageVector
val FormatBold: ImageVector
val FormatUnderline: ImageVector
val FormatStrikethrough: ImageVector
val UserIcon: ImageVector
val UserGroupIcon: ImageVector
val AssignmentIcon: ImageVector
val Edit: ImageVector
val Check: ImageVector
val Close: ImageVector
val PersonMinus: ImageVector
val DoubleBack: ImageVector
val DoubleForward: ImageVector
val Mail: ImageVector
private class Impl(val color: Color) : IconData {
override val ChevronRight: ImageVector by lazy { ChevronRight(color) }
override val ChevronDown: ImageVector by lazy { ChevronDown(color) }
override val ChevronLeft: ImageVector by lazy { ChevronLeft(color) }
override val Delete: ImageVector by lazy { Delete(color) }
override val CirclePlus: ImageVector by lazy { CirclePlus(color) }
override val LibraryPlus: ImageVector by lazy { LibraryPlus(color) }
override val Archive: ImageVector by lazy { Archive(color) }
override val Unarchive: ImageVector by lazy { Unarchive(color) }
override val FormatSize: ImageVector by lazy { FormatSize(color) }
override val CircleFilled: ImageVector by lazy { CircleFilled(color) }
override val CircleOutline: ImageVector by lazy { CircleOutline(color) }
override val FormatListBullet: ImageVector by lazy { FormatListBullet(color) }
override val FormatListNumber: ImageVector by lazy { FormatListNumber(color) }
override val FormatCode: ImageVector by lazy { FormatCode(color) }
override val ContentCopy: ImageVector by lazy { ContentCopy(color) }
override val ContentPaste: ImageVector by lazy { ContentPaste(color) }
override val FormatItalic: ImageVector by lazy { FormatItalic(color) }
override val FormatBold: ImageVector by lazy { FormatBold(color) }
override val FormatUnderline: ImageVector by lazy { FormatUnderline(color) }
override val FormatStrikethrough: ImageVector by lazy { FormatStrikethrough(color) }
override val UserIcon: ImageVector by lazy { UserIcon(color) }
override val UserGroupIcon: ImageVector by lazy { UserGroupIcon(color) }
override val AssignmentIcon: ImageVector by lazy { AssignmentIcon(color) }
override val Edit: ImageVector by lazy { Edit(color) }
override val Check: ImageVector by lazy { Check(color) }
override val Close: ImageVector by lazy { Close(color) }
override val PersonMinus: ImageVector by lazy { PersonMinus(color) }
override val DoubleBack: ImageVector by lazy { DoubleBack(color) }
override val DoubleForward: ImageVector by lazy { DoubleForward(color) }
override val Mail: ImageVector by lazy { Mail(color) }
}
companion object {
private val _cache = mutableMapOf<Color, Impl>()
operator fun get(color: Color): IconData = _cache.getOrPut(color) { Impl(color) }
}
}
@get:Composable
val Icons: IconData
get() = IconData[LocalContentColor.current]

View File

@@ -1,88 +0,0 @@
package com.jaytux.grader.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val ChevronRight: ImageVector by lazy {
ImageVector.Builder(
name = "ChevronRight",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = null,
fillAlpha = 1.0f,
stroke = SolidColor(Color(0xFF000000)),
strokeAlpha = 1.0f,
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
strokeLineMiter = 1.0f,
pathFillType = PathFillType.NonZero
) {
moveTo(9f, 18f)
lineToRelative(6f, -6f)
lineToRelative(-6f, -6f)
}
}.build()
}
val ChevronDown: ImageVector by lazy {
ImageVector.Builder(
name = "ChevronDown",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = null,
fillAlpha = 1.0f,
stroke = SolidColor(Color(0xFF000000)),
strokeAlpha = 1.0f,
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
strokeLineMiter = 1.0f,
pathFillType = PathFillType.NonZero
) {
moveTo(6f, 9f)
lineToRelative(6f, 6f)
lineToRelative(6f, -6f)
}
}.build()
}
public val ChevronLeft: ImageVector by lazy {
ImageVector.Builder(
name = "ChevronLeft",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = null,
fillAlpha = 1.0f,
stroke = SolidColor(Color(0xFF000000)),
strokeAlpha = 1.0f,
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
strokeLineMiter = 1.0f,
pathFillType = PathFillType.NonZero
) {
moveTo(15f, 18f)
lineToRelative(-6f, -6f)
lineToRelative(6f, -6f)
}
}.build()
}

View File

@@ -0,0 +1,262 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.PeerEvalGrading
import com.jaytux.grader.app
import com.jaytux.grader.data.v2.Group
import com.jaytux.grader.data.v2.Student
import com.jaytux.grader.viewmodel.Grade
import com.jaytux.grader.viewmodel.Navigator
import com.jaytux.grader.viewmodel.PeerEvalsGradingVM
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.foundation.theme.LocalTextStyle
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.theme.colorPalette
import org.jetbrains.jewel.ui.typography
@Composable
fun PeerEvalsGradingTitle(data: PeerEvalGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Peer Evaluations / ${data.assignment.name} / Grading")
@Composable
fun PeerEvalsGradingView(data: PeerEvalGrading, token: Navigator.NavToken) {
val vm = viewModel<PeerEvalsGradingVM>(key = data.assignment.id.toString()) {
PeerEvalsGradingVM(data.course, data.edition, data.assignment)
}
val groups by vm.groupList.entities
val focus by vm.focus
val selectedGroup = remember(focus, groups) { groups.getOrNull(focus) }
val students by vm.students.entities
val matrix by vm.evaluationMatrix.entities
val studentGrades by vm.studentGrades.entities
var selectedStudent by remember(selectedGroup, studentGrades) {
mutableStateOf(0)
}
Column(Modifier.padding(10.dp)) {
Text("Grading ${vm.base.name}", style = JewelTheme.typography.h2TextStyle)
Text("Group assignment in ${vm.course.name} - ${vm.edition.name}")
Spacer(Modifier.height(5.dp))
Row(Modifier.fillMaxSize()) {
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
QuickAGroup(idx == focus, { vm.focusGroup(idx) }, it)
}
}
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
if (focus == -1 || selectedGroup == null) {
Box(Modifier.weight(0.75f).fillMaxHeight()) {
Text("Select a group to start grading.", Modifier.align(Alignment.Center))
}
} else {
Column(Modifier.weight(0.75f).padding(15.dp)) {
Row {
IconButton({ vm.focusPrev() }, Modifier.align(Alignment.CenterVertically), enabled = focus > 0) {
Icon(Icons.DoubleBack, "Previous group")
}
Spacer(Modifier.width(10.dp))
Text(selectedGroup.group.name, Modifier.align(Alignment.CenterVertically), style = JewelTheme.typography.h2TextStyle)
Spacer(Modifier.weight(1f))
IconButton({ vm.focusNext() }, Modifier.align(Alignment.CenterVertically), enabled = focus < groups.size - 1) {
Icon(Icons.DoubleForward, "Next group")
}
}
Spacer(Modifier.height(10.dp))
matrix?.let { mat ->
students?.let { stu ->
Box(Modifier.weight(0.66f)) {
GradeTable(mat, stu, selectedGroup.group, vm.studentCriterion, vm::setEvaluation)
}
}
} ?: Box(Modifier.weight(0.66f).fillMaxWidth()) {
Text("Error: could not load evaluations for this group.", Modifier.align(Alignment.Center), color = JewelTheme.globalColors.text.error)
}
Column(Modifier.weight(0.33f)) {
studentGrades?.let { sgs ->
val currentStudent = sgs[selectedStudent]
PrimaryScrollableTabRow(selectedStudent, Modifier.fillMaxWidth()) {
sgs.forEachIndexed { idx, st ->
Tab(idx == selectedStudent, { selectedStudent = idx }) {
Row {
Icon(Icons.UserIcon, "")
Spacer(Modifier.width(5.dp))
Text(st.first.name, Modifier.align(Alignment.CenterVertically))
}
}
}
}
SingleStudentGrade(currentStudent.first.name, currentStudent.second, vm.global) { grade, feedback ->
vm.setStudentGrade(currentStudent.first, grade, feedback)
}
}
}
}
}
}
}
}
}
@Composable
fun GradeTable(
matrix: List<PeerEvalsGradingVM.Evaluation>, students: List<Student>, group: Group,
egData: CritData, onSet: (evaluator: Student, evaluatee: Student?, group: Group, grade: Grade, feedback: String) -> Unit
) {
Row {
val horScroll = rememberLazyListState()
val style = LocalTextStyle.current
val measure = rememberTextMeasurer()
val textLenMeasured = remember(matrix, students) {
students.maxOf { s ->
measure.measure(s.name, style).size.width
} + 10
}
val cellSize = 75.dp
var idx by remember(matrix, students) { mutableStateOf(0) }
var editing by remember(matrix, students) { mutableStateOf<Triple<Student, Student?, FeedbackItem?>?>(null) }
val isSelected = { from: Student, to: Student? ->
editing?.let { (f, t, _) -> f == from && t == to } ?: false
}
Column(Modifier.weight(0.66f).padding(10.dp)) {
Row {
Box { FromTo(textLenMeasured.dp) }
LazyRow(Modifier.height(textLenMeasured.dp), state = horScroll) {
item { VLine() }
items(students) { s ->
Box(
Modifier.width(cellSize).height(textLenMeasured.dp),
contentAlignment = Alignment.TopCenter
) {
var _h: Int = 0
Text(s.name, Modifier.layout { m, c ->
val p = m.measure(c.copy(minWidth = c.maxWidth, maxWidth = Constraints.Infinity))
_h = p.height
layout(p.height, p.width) { p.place(0, 0) }
}.graphicsLayer {
rotationZ = -90f
transformOrigin = TransformOrigin(0f, 0.5f)
translationX = _h.toFloat() / 2f
translationY = textLenMeasured.dp.value - 15f
})
}
}
item { VLine() }
item {
Box(
Modifier.width(cellSize).height(textLenMeasured.dp),
contentAlignment = Alignment.TopCenter
) {
var _h: Int = 0
Text("Group Rating", Modifier.layout { m, c ->
val p = m.measure(c.copy(minWidth = c.maxWidth, maxWidth = Constraints.Infinity))
_h = p.height
layout(p.height, p.width) { p.place(0, 0) }
}.graphicsLayer {
rotationZ = -90f
transformOrigin = TransformOrigin(0f, 0.5f)
translationX = _h.toFloat() / 2f
translationY = textLenMeasured.dp.value - 15f
}, fontWeight = FontWeight.Bold)
}
}
item { VLine() }
}
}
MeasuredLazyColumn(key = idx) {
measuredItem { HLine() }
items(matrix) { (evaluator, groupLevel, s2s) ->
Row(Modifier.height(cellSize)) {
Column(Modifier.width(textLenMeasured.dp).align(Alignment.CenterVertically)) {
Text(evaluator.name, Modifier.width(textLenMeasured.dp))
}
LazyRow(state = horScroll) {
item { VLine() }
items(s2s) { (evaluatee, entry) ->
PEGradeWidget(
entry,
{ editing = evaluator to evaluatee app entry }, { editing = null },
isSelected(evaluator, evaluatee), Modifier.size(cellSize, cellSize)
)
}
item { VLine() }
item {
PEGradeWidget(
groupLevel,
{ editing = evaluator to null app groupLevel }, { editing = null },
isSelected(evaluator, null), Modifier.size(cellSize, cellSize)
)
}
item { VLine() }
}
}
}
measuredItem { HLine() }
}
}
editing?.let {
Surface(Modifier.weight(0.33f), shape = JewelTheme.shapes.medium) {
val (evaluator, evaluatee, data) = it
EditS2SOrS2G(evaluator.name, evaluatee?.name ?: group.name, data, egData) { grade, feedback ->
onSet(evaluator, evaluatee, group, grade, feedback)
}
}
} ?: Box(Modifier.weight(0.33f)) {}
}
}
@Composable
fun EditS2SOrS2G(evaluator: String, evaluatee: String, current: FeedbackItem?, critData: CritData, onUpdate: (Grade, String) -> Unit) =
Column(Modifier.padding(10.dp).fillMaxHeight()) {
println("Recomposing editor for $evaluator -> $evaluatee with current ${current?.grade}")
var grade by remember(evaluator, evaluatee, current) { mutableStateOf(gradeState(critData, current?.grade)) }
var text by remember(evaluator, evaluatee, current) { mutableStateOf(current?.feedback ?: "") }
Text(evaluatee, style = JewelTheme.typography.h2TextStyle)
Text("Evaluated by $evaluator", style = JewelTheme.typography.regular, fontStyle = FontStyle.Italic)
Spacer(Modifier.height(10.dp))
GradePicker(grade, key = evaluator to evaluatee to current) { grade = it }
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 10, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(10.dp))
DefaultButton({ onUpdate(grade, text) }, Modifier.padding(horizontal = 20.dp).fillMaxWidth()) {
Text("Save")
}
}
@Composable
fun SingleStudentGrade(name: String, current: FeedbackItem?, critData: CritData, onUpdate: (Grade, String) -> Unit) = Column {
var grade by remember(name, critData) { mutableStateOf(gradeState(critData, current?.grade)) }
var text by remember(name) { mutableStateOf(current?.feedback ?: "") }
GradePicker(grade, key = critData to current app name) { grade = it }
Spacer(Modifier.height(5.dp))
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 5, modifier = Modifier.fillMaxWidth().weight(1f))
Spacer(Modifier.height(5.dp))
DefaultButton({ onUpdate(grade, text) }, Modifier.padding(horizontal = 20.dp).fillMaxWidth()) {
Text("Save grade and feedback")
}
}

View File

@@ -4,13 +4,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
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.automirrored.outlined.FormatListBulleted
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.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -28,13 +21,20 @@ import androidx.compose.ui.unit.sp
import com.jaytux.grader.loadClipboard import com.jaytux.grader.loadClipboard
import com.jaytux.grader.toClipboard import com.jaytux.grader.toClipboard
import com.mohamedrejeb.richeditor.model.RichTextState import com.mohamedrejeb.richeditor.model.RichTextState
import com.mohamedrejeb.richeditor.ui.material.OutlinedRichTextEditor
import kotlinx.coroutines.launch
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.foundation.theme.LocalContentColor
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.component.styling.IconButtonStyle
import org.jetbrains.jewel.ui.theme.iconButtonStyle
@Composable @Composable
fun RichTextStyleRow( fun RichTextStyleRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: RichTextState, state: RichTextState,
) { ) {
val clip = LocalClipboardManager.current val clip = LocalClipboardManager.current // I know this is deprecated, but I won't figure out the Clipboard API now
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Row(modifier.fillMaxWidth()) { Row(modifier.fillMaxWidth()) {
@@ -52,7 +52,7 @@ fun RichTextStyleRow(
) )
}, },
isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold, isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold,
icon = Icons.Outlined.FormatBold icon = Icons.FormatBold
) )
} }
@@ -66,7 +66,7 @@ fun RichTextStyleRow(
) )
}, },
isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic, isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic,
icon = Icons.Outlined.FormatItalic icon = Icons.FormatItalic
) )
} }
@@ -80,7 +80,7 @@ fun RichTextStyleRow(
) )
}, },
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true, isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true,
icon = Icons.Outlined.FormatUnderlined icon = Icons.FormatUnderline
) )
} }
@@ -94,7 +94,7 @@ fun RichTextStyleRow(
) )
}, },
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true, isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true,
icon = Icons.Outlined.FormatStrikethrough icon = Icons.FormatStrikethrough
) )
} }
@@ -108,7 +108,7 @@ fun RichTextStyleRow(
) )
}, },
isSelected = state.currentSpanStyle.fontSize == 28.sp, isSelected = state.currentSpanStyle.fontSize == 28.sp,
icon = Icons.Outlined.FormatSize icon = Icons.FormatSize
) )
} }
@@ -122,7 +122,7 @@ fun RichTextStyleRow(
) )
}, },
isSelected = state.currentSpanStyle.color == Color.Red, isSelected = state.currentSpanStyle.color == Color.Red,
icon = Icons.Filled.Circle, icon = Icons.CircleFilled,
tint = Color.Red tint = Color.Red
) )
} }
@@ -137,7 +137,7 @@ fun RichTextStyleRow(
) )
}, },
isSelected = state.currentSpanStyle.background == Color.Yellow, isSelected = state.currentSpanStyle.background == Color.Yellow,
icon = Icons.Outlined.Circle, icon = Icons.CircleOutline,
tint = Color.Yellow tint = Color.Yellow
) )
} }
@@ -157,7 +157,7 @@ fun RichTextStyleRow(
state.toggleUnorderedList() state.toggleUnorderedList()
}, },
isSelected = state.isUnorderedList, isSelected = state.isUnorderedList,
icon = Icons.AutoMirrored.Outlined.FormatListBulleted, icon = Icons.FormatListBullet,
) )
} }
@@ -167,7 +167,7 @@ fun RichTextStyleRow(
state.toggleOrderedList() state.toggleOrderedList()
}, },
isSelected = state.isOrderedList, isSelected = state.isOrderedList,
icon = Icons.Outlined.FormatListNumbered, icon = Icons.FormatListNumber,
) )
} }
@@ -186,16 +186,16 @@ fun RichTextStyleRow(
state.toggleCodeSpan() state.toggleCodeSpan()
}, },
isSelected = state.isCodeSpan, isSelected = state.isCodeSpan,
icon = Icons.Outlined.Code, icon = Icons.FormatCode,
) )
} }
} }
IconButton({ state.toClipboard(clip) }) { IconButton({ scope.launch { state.toClipboard(clip) } }) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy markdown") Icon(Icons.ContentCopy, contentDescription = "Copy markdown")
} }
IconButton({ state.loadClipboard(clip, scope) }) { IconButton({ scope.launch { state.loadClipboard(clip, scope) } }) {
Icon(Icons.Default.ContentPaste, contentDescription = "Paste markdown") Icon(Icons.ContentPaste, contentDescription = "Paste markdown")
} }
} }
} }
@@ -214,13 +214,7 @@ fun RichTextStyleButton(
// (Happens only on Desktop) // (Happens only on Desktop)
.focusProperties { canFocus = false }, .focusProperties { canFocus = false },
onClick = onClick, onClick = onClick,
colors = IconButtonDefaults.iconButtonColors( style = IconButtonStyle(JewelTheme.iconButtonStyle.colors, JewelTheme.iconButtonStyle.metrics) // TODO: color swapping depending on isSelected
contentColor = if (isSelected) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onBackground
},
),
) { ) {
Icon( Icon(
icon, icon,
@@ -229,7 +223,7 @@ fun RichTextStyleButton(
modifier = Modifier modifier = Modifier
.background( .background(
color = if (isSelected) { color = if (isSelected) {
MaterialTheme.colorScheme.primary JewelTheme.globalColors.text.disabledSelected
} else { } else {
Color.Transparent Color.Transparent
}, },
@@ -238,3 +232,17 @@ fun RichTextStyleButton(
) )
} }
} }
@Composable
fun RichTextField(
state: RichTextState,
modifier: Modifier = Modifier.fillMaxSize(),
buttonsModifier: Modifier = Modifier.fillMaxWidth(),
outerModifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null
) = Column(outerModifier) {
RichTextStyleRow(buttonsModifier, state)
OutlinedRichTextEditor(
state = state, modifier = modifier, singleLine = false, minLines = 5, label = label
)
}

View File

@@ -0,0 +1,20 @@
package com.jaytux.grader.ui
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.GroupGrading
import com.jaytux.grader.SoloGrading
import com.jaytux.grader.viewmodel.GroupsGradingVM
import com.jaytux.grader.viewmodel.Navigator
import com.jaytux.grader.viewmodel.SolosGradingVM
import org.jetbrains.jewel.ui.component.Text
@Composable
fun SolosGradingTitle(data: SoloGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Individual Assignments / ${data.assignment.name} / Grading")
@Composable
fun SolosGradingView(data: SoloGrading, token: Navigator.NavToken) {
val vm = viewModel<SolosGradingVM>(key = data.assignment.id.toString()) {
SolosGradingVM(data.course, data.edition, data.assignment)
}
}

View File

@@ -0,0 +1,203 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.data.v2.Student
import com.jaytux.grader.startEmail
import com.jaytux.grader.viewmodel.EditionVM
import com.jaytux.grader.viewmodel.SnackVM
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.foundation.theme.LocalTextStyle
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.typography
@Composable
fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
val students by vm.studentList.entities
val focus by vm.focusIndex
val snacks = viewModel<SnackVM> { SnackVM() }
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
ListOrEmpty(students, { Text("No students yet.") }) { idx, it ->
QuickStudent(idx, it, vm)
}
}
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
if(focus == -1) {
Box(Modifier.weight(0.75f).fillMaxHeight()) {
Text("Select a student to view details.", Modifier.align(Alignment.Center))
}
}
else {
val groups by vm.studentGroups.entities
val grades by vm.studentGrades.entities
Column(Modifier.weight(0.75f).padding(15.dp)) {
Surface(Modifier.padding(10.dp).fillMaxWidth(), shape = JewelTheme.shapes.medium) {
Column(Modifier.padding(10.dp)) {
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
Text(students[focus].name, style = JewelTheme.typography.h2TextStyle)
if(students[focus].contact.isNotBlank()) {
IconButton({ startEmail(listOf(students[focus].contact)) { snacks.show(it) } }) {
Icon(Icons.Mail, "Send email", Modifier.fillMaxHeight())
}
}
}
Row {
var editing by remember { mutableStateOf(false) }
Text("Contact: ", Modifier.align(Alignment.CenterVertically).padding(start = 15.dp))
if(!editing) {
if (students[focus].contact.isBlank()) {
Text(
"No contact info.",
Modifier.padding(start = 5.dp),
fontStyle = FontStyle.Italic,
color = LocalTextStyle.current.color.copy(alpha = 0.5f)
)
}
else {
Text(students[focus].contact, Modifier.padding(start = 5.dp))
}
Spacer(Modifier.width(5.dp))
Icon(Icons.Edit, "Edit contact info", Modifier.clickable { editing = true })
}
else {
var mod by remember(focus, students[focus].contact, students[focus].id.value) { mutableStateOf(students[focus].contact) }
OutlinedTextField(mod, { mod = it })
Spacer(Modifier.width(5.dp))
Icon(Icons.Check, "Confirm edit", Modifier.align(Alignment.CenterVertically).clickable {
vm.modStudent(students[focus], null, mod, null)
editing = false
})
Spacer(Modifier.width(5.dp))
Icon(Icons.Close, "Cancel edit", Modifier.align(Alignment.CenterVertically).clickable { editing = false })
}
}
Column {
Text("Groups:", style = JewelTheme.typography.h2TextStyle)
groups?.let { gList ->
if(gList.isEmpty()) null
else {
FlowRow(Modifier.padding(start = 10.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
gList.forEach { group ->
Surface(shape = JewelTheme.shapes.small) {
Box(Modifier.padding(5.dp).clickable { vm.focus(group.first) }) {
Text("${group.first.name} (${group.second ?: "no role"})", Modifier.padding(5.dp))
}
}
}
}
}
} ?: Text("Not a member of any group.", Modifier.padding(start = 15.dp), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
}
}
}
Row {
Column(Modifier.weight(0.33f)) {
var mod by remember(focus, students[focus].note, students[focus].id.value) { mutableStateOf(students[focus].note) }
Text("Internal Note:")
OutlinedTextField(
mod,
{ mod = it },
singleLine = false,
minLines = 5,
modifier = Modifier.fillMaxWidth()
)
if(mod != students[focus].note) {
Row {
Spacer(Modifier.weight(1f))
DefaultButton({ vm.modStudent(students[focus], null, null, mod) }) {
Text("Update note")
}
}
}
}
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(0.66f)) {
Text("Grade Summary: ", style = JewelTheme.typography.h2TextStyle)
Surface(shape = JewelTheme.shapes.medium, color = Color.White) {
LazyColumn {
item {
Surface {
Row(Modifier.padding(10.dp)) {
Text("Assignment", Modifier.weight(0.66f))
Text("Grade", Modifier.weight(0.33f))
}
}
}
items(grades ?: listOf()) {
Column(Modifier.padding(10.dp)) {
Row {
Text(it.assignment.name, Modifier.weight(0.66f))
it.grade?.render(Modifier.weight(0.33f))
?: Text("---", Modifier.weight(0.33f), color = LocalTextStyle.current.color.copy(alpha = 0.5f))
}
it.asMember?.let { g ->
Row(Modifier.padding(start = 10.dp)) {
Text("As member of ${g.name}", fontStyle = FontStyle.Italic)
if (it.overridden) Text(" (overridden)", fontStyle = FontStyle.Italic)
}
}
}
}
if((grades ?: listOf()).isEmpty()) {
item {
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
Text("No grades yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
}
}
}
}
}
}
}
}
}
}
}
@Composable
fun QuickStudent(idx: Int, student: Student, vm: EditionVM) {
val focus by vm.focusIndex
Surface(markFocused = focus == idx, shape = JewelTheme.shapes.small) {
Column(Modifier.fillMaxWidth().clickable { vm.focus(idx) }.padding(10.dp)) {
Text(student.name, fontWeight = FontWeight.Bold)
if(student.contact.isBlank())
Text("No contact info.", fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
else Text(student.contact)
}
}
}

View File

@@ -4,6 +4,72 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import com.jaytux.grader.data.v2.BaseFeedback
import com.jaytux.grader.data.v2.CategoricGrade
import com.jaytux.grader.data.v2.Criterion
import com.jaytux.grader.data.v2.GradeType
import com.jaytux.grader.data.v2.NumericGrade
import com.jaytux.grader.viewmodel.Grade
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
@Composable @Composable
fun TextUnit.toDp(): Dp = with(LocalDensity.current) { value.toDp() } fun TextUnit.toDp(): Dp = with(LocalDensity.current) { value.toDp() }
data class CritData(val criterion: Criterion, val cat: CategoricGrade?, val num: NumericGrade?) {
companion object {
context(trns: Transaction)
fun fromDb(c: Criterion) = CritData(c, c.categoricGrade, c.numericGrade)
}
}
data class FeedbackItem(val base: BaseFeedback, val grade: Grade, val feedback: String) {
companion object {
context(trns: Transaction)
fun fromDb(f: BaseFeedback): FeedbackItem = when(f.criterion.gradeType) {
GradeType.CATEGORIC -> {
val categoric = f.criterion.categoricGrade!!
val options = categoric.options.toList()
Grade.Categoric(f.gradeCategoric ?: options.first(), options, categoric)
}
GradeType.NUMERIC -> Grade.Numeric(f.gradeNumeric ?: 0.0, f.criterion.numericGrade!!)
GradeType.PERCENTAGE -> Grade.Percentage(f.gradeNumeric ?: 0.0)
GradeType.NONE -> Grade.FreeText(f.gradeFreeText ?: "")
}.let { FeedbackItem(f, it, f.feedback) }
}
}
fun gradeState(type: GradeType, categoric: CategoricGrade?, numeric: NumericGrade?, current: Grade?): Grade = transaction {
if(current == null) {
println("gradeState: current is null, defaulting")
Grade.default(type, categoric, numeric)
}
else {
when(type) {
GradeType.CATEGORIC ->
if(current is Grade.Categoric && current.grade.id == categoric?.id) {
println("gradeState: current categoric grade is valid, keeping")
current
}
else {
println("gradeState: current categoric grade is invalid, defaulting [${current is Grade.Categoric} (${current::class.java.simpleName}), ${(current as? Grade.Categoric)?.grade?.name} == ${categoric?.name}]")
Grade.default(GradeType.CATEGORIC, categoric, numeric)
}
GradeType.NUMERIC ->
if(current is Grade.Numeric && current.grade.id == numeric?.id) {
println("gradeState: current numeric grade is valid, keeping")
current
}
else {
println("gradeState: current numeric grade is invalid, defaulting [${current is Grade.Numeric}, ${(current as? Grade.Numeric)?.grade?.id == numeric?.id}]")
Grade.default(GradeType.NUMERIC, categoric, numeric)
}
GradeType.PERCENTAGE ->
current as? Grade.Percentage ?: Grade.default(GradeType.PERCENTAGE, categoric, numeric)
GradeType.NONE ->
current as? Grade.FreeText ?: Grade.default(GradeType.NONE, categoric, numeric)
}
}
}
fun gradeState(crit: CritData, current: Grade?): Grade = gradeState(crit.criterion.gradeType, crit.cat, crit.num, current)

View File

@@ -1,220 +0,0 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberDialogState
import com.jaytux.grader.maxN
import com.jaytux.grader.viewmodel.GroupState
import com.jaytux.grader.viewmodel.StudentState
@Composable
fun StudentView(state: StudentState, nav: Navigators) {
val groups by state.groups.entities
val courses by state.courseEditions.entities
val groupGrades by state.groupGrades.entities
val soloGrades by state.soloGrades.entities
Column(Modifier.padding(10.dp)) {
Row {
Column(Modifier.weight(0.45f)) {
Column(Modifier.padding(10.dp).weight(0.35f)) {
Spacer(Modifier.height(10.dp))
InteractToEdit(state.student.name, { state.update { this.name = it } }, "Name")
InteractToEdit(state.student.contact, { state.update { this.contact = it } }, "Contact")
InteractToEdit(state.student.note, { state.update { this.note = it } }, "Note", singleLine = false)
}
Column(Modifier.weight(0.20f)) {
Text("Courses", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(courses, { Text("Not a member of any course") }) { _, it ->
val (ed, course) = it
Text("${course.name} (${ed.name})", style = MaterialTheme.typography.bodyMedium)
}
}
Column(Modifier.weight(0.45f)) {
Text("Groups", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(groups, { Text("Not a member of any group") }) { _, it ->
val (group, c) = it
val (course, ed) = c
Row(Modifier.clickable { nav.group(group) }) {
Text(group.name, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.width(5.dp))
Text(
"(in course $course ($ed))",
Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
Column(Modifier.weight(0.55f)) {
Text("Courses", style = MaterialTheme.typography.headlineSmall)
LazyColumn {
item {
Text("As group member", fontWeight = FontWeight.Bold)
}
items(groupGrades) {
groupGradeWidget(it)
}
item {
Text("Solo assignments", fontWeight = FontWeight.Bold)
}
items(soloGrades) {
soloGradeWidget(it)
}
}
}
}
}
}
@Composable
fun groupGradeWidget(gg: StudentState.LocalGroupGrade) {
val (group, assignment, gGrade, iGrade) = gg
var expanded by remember { mutableStateOf(false) }
Row(Modifier.padding(5.dp)) {
Spacer(Modifier.width(10.dp))
Surface(
Modifier.clickable { expanded = !expanded }.fillMaxWidth(),
tonalElevation = 5.dp,
shape = MaterialTheme.shapes.medium
) {
Column(Modifier.padding(5.dp)) {
Text("${assignment.maxN(25)} (${iGrade ?: gGrade ?: "no grade yet"})")
if (expanded) {
Row {
Spacer(Modifier.width(10.dp))
Column {
ItalicAndNormal("Assignment: ", assignment)
ItalicAndNormal("Group name: ", group)
ItalicAndNormal("Group grade: ", gGrade ?: "no grade yet")
ItalicAndNormal("Individual grade: ", iGrade ?: "no individual grade")
}
}
}
}
}
}
}
@Composable
fun soloGradeWidget(sg: StudentState.LocalSoloGrade) {
val (assignment, grade) = sg
var expanded by remember { mutableStateOf(false) }
Row(Modifier.padding(5.dp)) {
Spacer(Modifier.width(10.dp))
Surface(
Modifier.clickable { expanded = !expanded }.fillMaxWidth(),
tonalElevation = 5.dp,
shape = MaterialTheme.shapes.medium
) {
Column(Modifier.padding(5.dp)) {
Text("${assignment.maxN(25)} (${grade ?: "no grade yet"})")
if (expanded) {
Row {
Spacer(Modifier.width(10.dp))
Column {
ItalicAndNormal("Assignment: ", assignment)
ItalicAndNormal("Individual grade: ", grade ?: "no grade yet")
}
}
}
}
}
}
}
@Composable
fun GroupView(state: GroupState, nav: Navigators) {
val members by state.members.entities
val available by state.availableStudents.entities
val allRoles by state.roles.entities
var pickRole: Pair<String?, (String?) -> Unit>? by remember { mutableStateOf(null) }
Column(Modifier.padding(10.dp)) {
Row {
Column(Modifier.weight(0.5f)) {
Text("Students", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(members, { Text("No students in this group") }) { _, it ->
val (student, role) = it
Row(Modifier.clickable { nav.student(student) }) {
Text(
"${student.name} (${role ?: "no role"})",
Modifier.weight(0.75f).align(Alignment.CenterVertically),
style = MaterialTheme.typography.bodyMedium
)
IconButton({ pickRole = role to { r -> state.updateRole(student, r) } }, Modifier.weight(0.12f)) {
Icon(Icons.Default.Edit, "Change role")
}
IconButton({ state.removeStudent(student) }, Modifier.weight(0.12f)) {
Icon(Icons.Default.Delete, "Remove student")
}
}
}
}
Column(Modifier.weight(0.5f)) {
Text("Available students", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(available, { Text("No students available") }) { _, it ->
Row(Modifier.padding(5.dp).clickable { nav.student(it) }) {
IconButton({ state.addStudent(it) }) {
Icon(ChevronLeft, "Add student")
}
Text(it.name, Modifier.weight(0.75f).align(Alignment.CenterVertically), style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
pickRole?.let {
val (curr, onPick) = it
RolePicker(allRoles, curr, { pickRole = null }, { role -> onPick(role); pickRole = null })
}
}
@Composable
fun RolePicker(used: List<String>, curr: String?, onClose: () -> Unit, onSave: (String?) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(400.dp, 500.dp), position = WindowPosition(Alignment.Center))
) {
Surface(Modifier.fillMaxSize().padding(10.dp)) {
Box(Modifier.fillMaxSize()) {
var role by remember { mutableStateOf(curr ?: "") }
Column {
Text("Used roles:")
LazyColumn(Modifier.weight(1.0f).padding(5.dp)) {
items(used) {
Surface(Modifier.fillMaxWidth().clickable { role = it }, tonalElevation = 5.dp) {
Text(it, Modifier.padding(5.dp))
}
Spacer(Modifier.height(5.dp))
}
}
OutlinedTextField(role, { role = it }, Modifier.fillMaxWidth())
CancelSaveRow(true, onClose) {
onSave(role.ifBlank { null })
onClose()
}
}
}
}
}

View File

@@ -1,85 +1,76 @@
package com.jaytux.grader.ui package com.jaytux.grader.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.* import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material.icons.filled.Check import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Delete import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.filled.Edit import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.* import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarHostState
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.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.key
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.layout.onGloballyPositioned
import androidx.compose.ui.text.TextRange
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.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.Dp
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.maxN
import com.jaytux.grader.data.Edition import com.jaytux.grader.viewmodel.Grade
import com.jaytux.grader.viewmodel.PeerEvaluationState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.* import kotlinx.datetime.*
import kotlinx.datetime.TimeZone import org.jetbrains.jewel.foundation.Stroke
import org.jetbrains.jewel.foundation.modifier.border
import java.util.* import java.util.*
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.foundation.theme.LocalTextStyle
import org.jetbrains.jewel.ui.Outline
import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.theme.colorPalette
import org.jetbrains.jewel.ui.theme.iconData
import org.jetbrains.jewel.ui.typography
@Composable @Composable
fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "Cancel", saveText: String = "Save", onSave: () -> Unit) { fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "Cancel", saveText: String = "Save", onSave: () -> Unit) {
Row { Row {
Button({ onCancel() }, Modifier.weight(0.45f)) { Text(cancelText) } DefaultButton({ onCancel() }, Modifier.weight(0.45f)) { Text(cancelText) }
Spacer(Modifier.weight(0.1f)) Spacer(Modifier.weight(0.1f))
Button({ onSave() }, Modifier.weight(0.45f), enabled = canSave) { Text(saveText) } DefaultButton({ onSave() }, Modifier.weight(0.45f), enabled = canSave) { Text(saveText) }
} }
} }
@Composable
fun <T> TabLayout(
options: List<T>,
currentIndex: Int,
onSelect: (Int) -> Unit,
optionContent: @Composable (T) -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) = Column(modifier) {
TabRow(currentIndex) {
options.forEachIndexed { idx, it ->
Tab(
selected = idx == currentIndex,
onClick = { onSelect(idx) },
text = { optionContent(it) }
)
}
}
content()
}
@Composable @Composable
fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, current: String = "", onSave: (String) -> Unit) = DialogWindow( fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, current: String = "", onSave: (String) -> Unit) = DialogWindow(
onCloseRequest = onClose, onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center)) state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
) { ) {
val focus = remember { FocusRequester() }
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
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)) {
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text(label) }, isError = name in taken) OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), 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()
@@ -87,6 +78,8 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
} }
} }
} }
LaunchedEffect(Unit) { focus.requestFocus() }
} }
@Composable @Composable
@@ -99,7 +92,7 @@ fun ConfirmDeleteDialog(
onCloseRequest = onExit, onCloseRequest = onExit,
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center)) state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
) { ) {
Surface(Modifier.width(400.dp).height(300.dp), tonalElevation = 5.dp) { Surface(Modifier.width(400.dp).height(300.dp)) {
Box(Modifier.fillMaxSize().padding(10.dp)) { Box(Modifier.fillMaxSize().padding(10.dp)) {
Column(Modifier.align(Alignment.Center)) { Column(Modifier.align(Alignment.Center)) {
Text("You are about to delete $deleteAWhat.", Modifier.padding(10.dp)) Text("You are about to delete $deleteAWhat.", Modifier.padding(10.dp))
@@ -117,298 +110,25 @@ fun ConfirmDeleteDialog(
fun <T> ListOrEmpty( fun <T> ListOrEmpty(
data: List<T>, data: List<T>,
onEmpty: @Composable ColumnScope.() -> Unit, onEmpty: @Composable ColumnScope.() -> Unit,
addOptions: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier,
addAfterLazy: Boolean = true,
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
) { ) {
if(data.isEmpty()) { if(data.isEmpty()) {
Box(modifier) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center)) { Column(Modifier.align(Alignment.Center)) {
onEmpty() onEmpty()
addOptions() }
} }
} }
} }
else { else {
Column { Column(modifier) {
LazyColumn(Modifier.weight(1f)) { LazyColumn(Modifier.weight(1f)) {
itemsIndexed(data) { idx, it -> itemsIndexed(data) { idx, it ->
item(idx, it) item(idx, it)
} }
if(!addAfterLazy) item { addOptions() }
} }
if(addAfterLazy) addOptions()
}
}
}
@Composable
fun <T> ListOrEmpty(
data: List<T>,
emptyText: @Composable ColumnScope.() -> Unit,
addText: @Composable RowScope.() -> Unit,
onAdd: () -> Unit,
addAfterLazy: Boolean = true,
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
) = ListOrEmpty(
data, emptyText,
{ Button(onAdd, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) { addText() } },
addAfterLazy,
item
)
@Composable
fun <T> ListOrEmpty(
data: List<T>,
emptyText: @Composable ColumnScope.() -> Unit,
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
) {
if(data.isEmpty()) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center)) {
emptyText()
}
}
}
else {
Column {
LazyColumn(Modifier.padding(5.dp).weight(1f)) {
itemsIndexed(data) { idx, it ->
item(idx, it)
}
}
}
}
}
@Composable
fun InteractToEdit(
content: String, onSave: (String) -> Unit, pre: String, modifier: Modifier = Modifier,
w1: Float = 0.75f, w2: Float = 0.25f,
singleLine: Boolean = true
) {
var text by remember(content) { mutableStateOf(content) }
Row(modifier.padding(5.dp)) {
val base = if(singleLine) Modifier.align(Alignment.CenterVertically) else Modifier
OutlinedTextField(
text, { text = it }, base.weight(w1), label = { Text(pre) },
singleLine = singleLine, minLines = if(singleLine) 1 else 5
)
IconButton({ onSave(text) }, base.weight(w2)) { Icon(Icons.Default.Check, "Save") }
}
}
@Composable
fun PaneHeader(name: String, type: String, course: Course, edition: Edition) = Column {
Text(name, style = MaterialTheme.typography.headlineMedium)
Text("${type.capitalize(Locale.current)} in ${course.name} (${edition.name})", fontStyle = FontStyle.Italic)
}
@Composable
fun PaneHeader(name: String, type: String, courseEdition: Pair<Course, Edition>) = PaneHeader(name, type, courseEdition.first, courseEdition.second)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AutocompleteLineField(
value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier, label: @Composable (() -> Unit)? = null,
onFilter: (String) -> List<String>
) = Column(modifier) {
var suggestions by remember { mutableStateOf(listOf<String>()) }
var selected by remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
val scrollState = rememberLazyListState()
val posToLine = { pos: Int ->
(value.text.take(pos).count { it == '\n' }) to (value.text.take(pos).lastIndexOf('\n'))
}
val autoComplete = { str: String ->
val pos = value.selection.start
val lines = value.text.split("\n").toMutableList()
val (lineno, lineStart) = posToLine(pos)
lines[lineno] = str
onValueChange(value.copy(text = lines.joinToString("\n"), selection = TextRange(lineStart + str.length + 1)))
}
val currentLine = {
value.text.split('\n')[posToLine(value.selection.start).first]
}
val gotoOption = { idx: Int ->
selected = if(suggestions.isEmpty()) 0 else ((idx + suggestions.size) % suggestions.size)
scope.launch {
scrollState.animateScrollToItem(if(suggestions.isNotEmpty()) (selected + 1) else 0)
}
Unit
}
val onKey = { kev: KeyEvent ->
var res = true
if(suggestions.isNotEmpty()) {
when (kev.key) {
Key.Tab -> autoComplete(suggestions[selected])
Key.DirectionUp -> gotoOption(selected - 1)
Key.DirectionDown -> gotoOption(selected + 1)
Key.Escape -> suggestions = listOf()
else -> res = false
}
}
else res = false
res
}
LaunchedEffect(value.text) {
delay(300)
suggestions = onFilter(currentLine())
gotoOption(if(suggestions.isEmpty()) 0 else selected % suggestions.size)
}
OutlinedTextField(
value, onValueChange,
Modifier.fillMaxWidth().weight(0.75f).onKeyEvent(onKey), label = label, singleLine = false, minLines = 5
)
if(suggestions.isNotEmpty()) {
LazyColumn(Modifier.weight(0.25f), state = scrollState) {
stickyHeader {
Surface(tonalElevation = 5.dp) {
Text("Suggestions", Modifier.padding(5.dp).fillMaxWidth(), fontStyle = FontStyle.Italic)
}
}
itemsIndexed(suggestions) { idx, it ->
Surface(Modifier.padding(5.dp).fillMaxWidth(), tonalElevation = if(selected == idx) 50.dp else 0.dp) {
Text(it, Modifier.clickable { autoComplete(it) })
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimePicker(
value: LocalDateTime,
onPick: (LocalDateTime) -> Unit,
formatter: (LocalDateTime) -> String = { java.text.DateFormat.getDateTimeInstance().format(Date.from(it.toInstant(TimeZone.currentSystemDefault()).toJavaInstant())) },
modifier: Modifier = Modifier,
) {
var showPicker by remember { mutableStateOf(false) }
Row(modifier) {
Text(
formatter(value),
Modifier.align(Alignment.CenterVertically)
)
Spacer(Modifier.width(10.dp))
Button({ showPicker = true }) { Text("Change") }
if (showPicker) {
val dateState = rememberDatePickerState(value.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds())
val timeState = rememberTimePickerState(value.hour, value.minute)
Dialog(
{ showPicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = MaterialTheme.shapes.extraLarge, tonalElevation = 6.dp,
modifier = Modifier.width(800.dp).height(600.dp)
) {
val colors = TimePickerDefaults.colors(
selectorColor = MaterialTheme.colorScheme.primary,
timeSelectorSelectedContainerColor = MaterialTheme.colorScheme.primary,
timeSelectorSelectedContentColor = MaterialTheme.colorScheme.onPrimary,
clockDialSelectedContentColor = MaterialTheme.colorScheme.onPrimary,
) // the colors are fucked, and I don't get why :(
Column(Modifier.padding(10.dp)) {
Row {
DatePicker(
dateState,
Modifier.padding(10.dp).weight(0.5f),
)
TimePicker(
timeState,
Modifier.weight(0.5f).align(Alignment.CenterVertically),
layoutType = TimePickerLayoutType.Vertical,
colors = colors
)
}
CancelSaveRow(true, { showPicker = false }) {
val date = (dateState.selectedDateMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.currentSystemDefault()) } ?: value).date
val time = LocalTime(timeState.hour, timeState.minute)
onPick(LocalDateTime(date, time))
showPicker = false
}
}
}
}
}
// DatePickerDialog(
// { showPicker = false },
// {
// Button({
// showPicker = false; dateState.selectedDateMillis?.let { state.updateDeadline(it) }
// }) { Text("Set deadline") }
// },
// Modifier,
// { Button({ showPicker = false }) { Text("Cancel") } },
// shape = MaterialTheme.shapes.medium,
// tonalElevation = 10.dp,
// colors = DatePickerDefaults.colors(),
// properties = DialogProperties()
// ) {
// DatePicker(
// dateState,
// Modifier.fillMaxWidth().padding(10.dp),
// )
// }
}
}
@Composable
fun ItalicAndNormal(italic: String, normal: String) = Row{
Text(italic, fontStyle = FontStyle.Italic)
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")
} }
} }
} }
@@ -426,7 +146,7 @@ fun FromTo(size: Dp) {
} }
Box { Box {
Text("Evaluated", Modifier.graphicsLayer { Text("Evaluatee", Modifier.graphicsLayer {
rotationZ = -90f rotationZ = -90f
translationX = w - 15f translationX = w - 15f
translationY = h - 15f translationY = h - 15f
@@ -436,16 +156,34 @@ fun FromTo(size: Dp) {
} }
} }
@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() },
markFocused = isSelected,
shape = JewelTheme.shapes.medium
) {
content()
}
}
@Composable @Composable
fun PEGradeWidget( fun PEGradeWidget(
grade: PeerEvaluationState.Student2StudentEntry?, feedback: FeedbackItem?,
onSelect: () -> Unit, onDeselect: () -> Unit, onSelect: () -> Unit, onDeselect: () -> Unit,
isSelected: Boolean, isSelected: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) = Box(modifier.padding(2.dp)) { ) = Box(modifier.padding(2.dp)) {
Selectable(isSelected, onSelect, onDeselect) { Selectable(isSelected, onSelect, onDeselect) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(grade?.let { if(it.grade.isNotBlank()) it.grade else if(it.feedback.isNotBlank()) "(other)" else null } ?: "none") feedback?.grade?.render() ?: Text("(none)", fontStyle = FontStyle.Italic)
// Text(grade?.let { if(it.grade.isNotBlank()) it.grade else if(it.feedback.isNotBlank()) "(other)" else null } ?: "none")
} }
} }
} }
@@ -458,3 +196,231 @@ fun MeasuredLazyItemScope.HLine(height: Dp = 1.dp, color: Color = Color.Black) {
val width by measuredWidth() val width by measuredWidth()
Spacer(Modifier.width(width).height(height).background(color)) Spacer(Modifier.width(width).height(height).background(color))
} }
@Composable
fun RolePicker(used: List<String>, curr: String?, onClose: () -> Unit, onSave: (String?) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(400.dp, 500.dp), position = WindowPosition(Alignment.Center))
) {
Surface(Modifier.fillMaxSize().padding(10.dp)) {
Box(Modifier.fillMaxSize()) {
var role by remember { mutableStateOf(curr ?: "") }
Column {
Text("Used roles:")
LazyColumn(Modifier.weight(1.0f).padding(5.dp)) {
items(used) {
Surface(Modifier.fillMaxWidth().clickable { role = it }) {
Text(it, Modifier.padding(5.dp))
}
Spacer(Modifier.height(5.dp))
}
}
OutlinedTextField(role, { role = it }, Modifier.fillMaxWidth())
CancelSaveRow(true, onClose) {
onSave(role.ifBlank { null })
onClose()
}
}
}
}
}
@Composable
fun GradePicker(grade: Grade, modifier: Modifier = Modifier, key: Any = Unit, onUpdate: (Grade) -> Unit) = Row(modifier) { // TODO: fix UI to remove save-buttons (instead wait fo end of editing)
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
when(grade) {
is Grade.Categoric -> {
if(grade.options.size <= 5) {
Column {
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
grade.options.forEachIndexed { idx, opt ->
println("Rendering opt ${opt.option} (index $idx) ~ current value: ${grade.value.option})")
SegmentedButton(
grade.value.option == opt.option, { onUpdate(Grade.Categoric(opt, grade.options, grade.grade)) },
shape = SegmentedButtonDefaults.itemShape(idx, grade.options.size)
) { Text(opt.option.maxN(15)) }
}
}
Row {
Spacer(Modifier.weight(1f))
Text(grade.value.option, fontStyle = FontStyle.Italic, style = JewelTheme.typography.small)
}
}
}
else {
var slider by remember(grade, key) { mutableStateOf(maxOf(0, grade.options.indexOfFirst { it.option == grade.value.option })) }
Row {
Column(Modifier.weight(1f)) {
Slider(
slider.toFloat(),
onValueChange = { onUpdate(grade.copy(value = grade.options[slider])) },
steps = grade.options.size,
valueRange = 0f..(grade.options.size - 1).toFloat()
)
Row {
Spacer(Modifier.weight(1f))
Text(grade.options[slider].option, fontStyle = FontStyle.Italic, style = JewelTheme.typography.small)
}
}
}
}
}
is Grade.FreeText -> {
var text by remember(grade, key) { mutableStateOf(grade.text) }
OutlinedTextField(grade.text, { onUpdate(grade.copy(text = it)) }, Modifier.weight(1f), singleLine = true)
DefaultButton({ onUpdate(Grade.FreeText(text)) }, enabled = text != grade.text) {
Text("Save")
}
}
is Grade.Numeric -> {
var num by remember(grade, key) { mutableStateOf(grade.value.toString()) }
OutlinedTextField(
num, { num = it.filter { c -> c.isDigit() || c == '.' || c == ',' }.ifEmpty { "0" } },
Modifier.weight(1f), singleLine = true, isError = (num.toDoubleOrNull() ?: 0.0) > grade.grade.max
)
DefaultButton({ onUpdate(Grade.Numeric(num.toDoubleOrNull() ?: 0.0, grade.grade)) }, enabled = (num.toDoubleOrNull() ?: 0.0) <= grade.grade.max) {
Text("Save")
}
}
is Grade.Percentage -> {
var perc by remember(grade, key) { mutableStateOf(grade.percentage.toString()) }
OutlinedTextField("$perc%", { perc = it.filter { c -> c.isDigit() || c == '.' || c == ',' }.ifEmpty { "0" } }, Modifier.weight(1f), singleLine = true)
DefaultButton({ onUpdate(Grade.Percentage(perc.toDoubleOrNull() ?: 0.0)) }) {
Text("Save")
}
}
}
}
@Composable
fun OutlinedTextField(value: String, onChange: (String) -> Unit, modifier: Modifier = Modifier, label: @Composable () -> Unit = {}, isError: Boolean = false, singleLine: Boolean = false, minLines: Int = 1) {
val state = remember { TextFieldState(value) }
LaunchedEffect(value) {
if (state.text.toString() != value) {
state.edit {
replace(0, length, value)
}
}
}
LaunchedEffect(state) {
snapshotFlow { state.text }.collect { newText ->
val newString = newText.toString()
if (newString != value) {
onChange(newString)
}
}
}
if(singleLine) {
TextField(state, modifier, outline = if(isError) Outline.Error else Outline.None, placeholder = label)
}
else {
TextArea(state, modifier, outline = if(isError) Outline.Error else Outline.None, placeholder = label /*, minLines = minLines*/)
}
}
private val LocalSurfaceLayer = compositionLocalOf { 0 }
interface ShapeCollection {
val xLarge: Shape
val large: Shape
val medium: Shape
val small: Shape
val xSmall: Shape
val none: Shape
}
val JewelTheme.Companion.shapes
get() = object : ShapeCollection {
override val xLarge = RoundedCornerShape(28.0.dp)
override val large = RoundedCornerShape(16.0.dp)
override val xSmall = RoundedCornerShape(4.0.dp)
override val medium = RoundedCornerShape(12.0.dp)
override val none = RectangleShape
override val small = RoundedCornerShape(8.0.dp)
}
@Composable
fun Surface(
modifier: Modifier = Modifier,
shape: Shape = RectangleShape,
color: Color = JewelTheme.globalColors.panelBackground,
markFocused: Boolean = false,
content: @Composable () -> Unit
) {
val currentLayer = LocalSurfaceLayer.current
// TODO: markFocused?
Box(modifier = modifier.background(color, shape).let {
if (currentLayer > 0) it.border(Stroke(1.dp, JewelTheme.globalColors.outlines.focused, Stroke.Alignment.Center), shape)
else it
}) {
CompositionLocalProvider(LocalSurfaceLayer provides currentLayer + 1) { content() }
}
}
@Composable
fun Scaffold(topBar: @Composable () -> Unit, snackState: SnackbarHostState, content: @Composable () -> Unit) {
Column(Modifier.fillMaxSize()) {
Box(Modifier.heightIn(max = 150.dp)) {
CompositionLocalProvider(LocalTextStyle provides JewelTheme.typography.h1TextStyle) {
topBar()
}
}
Box(Modifier.weight(1f)) {
content()
}
}
NotificationHost(snackState)
}
@Composable
fun NotificationHost(host: SnackbarHostState) {
val currentData = host.currentSnackbarData
Box(Modifier.fillMaxSize()) {
if (currentData != null) {
Notification(
message = currentData.visuals.message,
onDismiss = { currentData.dismiss() },
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
)
}
}
}
@Composable
fun Notification(message: String, onDismiss: () -> Unit, modifier: Modifier = Modifier) {
Surface(
modifier = modifier.widthIn(max = 300.dp).shadow(8.dp, RoundedCornerShape(8.dp)),
shape = RoundedCornerShape(8.dp)
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = message, modifier = Modifier.weight(1f), style = JewelTheme.defaultTextStyle)
IconButton(onClick = onDismiss) {
Icon(Icons.Close, contentDescription = "Close")
}
}
}
}
@Composable
fun TitleBar(modifier: Modifier = Modifier, title: @Composable () -> Unit, navigationIcon: (@Composable () -> Unit)? = null) {
Surface(modifier) {
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 15.dp), verticalAlignment = Alignment.CenterVertically) {
if (navigationIcon != null) {
Box(Modifier.padding(end = 8.dp)) {
navigationIcon()
}
}
title()
}
}
}

View File

@@ -3,799 +3,64 @@ package com.jaytux.grader.viewmodel
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import com.jaytux.grader.data.* import org.jetbrains.exposed.v1.core.Transaction
import com.jaytux.grader.data.EditionStudents.editionId import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import com.jaytux.grader.data.EditionStudents.studentId
import com.jaytux.grader.viewmodel.GroupAssignmentState.*
import kotlinx.datetime.*
import kotlinx.datetime.TimeZone
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
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()))
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 {
mutableStateOf(transaction { loader() }) mutableStateOf(transaction { loader() })
} }
val entities = rawEntities.immutable()
val entities = rawEntities.immutable()
fun refresh() { fun refresh() {
rawEntities.value = transaction { loader() } rawEntities.value = transaction { loader() }
} }
} }
class CourseListState { class RawDbFocusableSingleState<TIn, TOut: Any>(private val loader: (Transaction.(TIn) -> TOut?)) {
val courses = RawDbState { Course.all().sortAsc(Courses.name).toList() } private var _input: TIn? = null
private val rawEntity by lazy {
fun new(name: String) { mutableStateOf<TOut?>(null)
transaction { Course.new { this.name = name } }
courses.refresh()
} }
fun delete(course: Course) { val entity: State<TOut?> = rawEntity.immutable()
transaction { course.delete() }
courses.refresh() fun focus(input: TIn) {
_input = input
rawEntity.value = transaction { loader(input) }
} }
fun getEditions(course: Course) = EditionListState(course) fun unfocus() {
} _input = null
rawEntity.value = null
class EditionListState(val course: Course) {
val editions = RawDbState { Edition.find { Editions.courseId eq course.id }.sortAsc(Editions.name).toList() }
fun new(name: String) {
transaction { Edition.new { this.name = name; this.course = this@EditionListState.course } }
editions.refresh()
} }
fun delete(edition: Edition) { fun refresh() {
transaction { edition.delete() } rawEntity.value = transaction { _input?.let { loader(it) } }
editions.refresh()
} }
} }
enum class OpenPanel(val tabName: String) { class RawDbFocusableState<TIn, TOut: Any>(private val loader: (Transaction.(TIn) -> List<TOut>)) {
Student("Students"), Group("Groups"), Assignment("Assignments") private var _input: TIn? = null
} private val rawEntities by lazy {
mutableStateOf<List<TOut>?>(null)
class EditionState(val edition: Edition) {
val course = transaction { edition.course }
val students = RawDbState { edition.soloStudents.sortAsc(Students.name).toList() }
val groups = RawDbState { edition.groups.sortAsc(Groups.name).toList() }
val solo = RawDbState { edition.soloAssignments.sortAsc(SoloAssignments.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 {
Student.find {
(Students.id notInList edition.soloStudents.map { it.id })
}.toList()
} }
fun newStudent(name: String, contact: String, note: String, addToEdition: Boolean) { val entities: State<List<TOut>?> = rawEntities.immutable()
transaction {
val student = Student.new { this.name = name; this.contact = contact; this.note = note } fun focus(input: TIn) {
if(addToEdition) EditionStudents.insert { _input = input
it[editionId] = edition.id rawEntities.value = transaction { loader(input) }
it[studentId] = student.id
}
} }
if(addToEdition) students.refresh() fun unfocus() {
else availableStudents.refresh() _input = null
} rawEntities.value = null
fun setStudentName(student: Student, name: String) {
transaction {
student.name = name
}
students.refresh()
}
fun addToCourse(students: List<Student>) {
transaction {
EditionStudents.batchInsert(students) {
this[editionId] = edition.id
this[studentId] = it.id
}
}
availableStudents.refresh()
this.students.refresh()
} }
fun newGroup(name: String) { fun refresh() {
transaction { rawEntities.value = transaction { _input?.let { loader(it) } }
Group.new { this.name = name; this.edition = this@EditionState.edition }
groups.refresh()
}
}
fun setGroupName(group: Group, name: String) {
transaction {
group.name = name
}
groups.refresh()
}
private fun now(): LocalDateTime {
val instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
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) {
transaction {
SoloAssignment.new {
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
this.number = nextIdx()
}
solo.refresh()
}
}
fun setSoloAssignmentTitle(assignment: SoloAssignment, title: String) {
transaction {
assignment.name = title
}
solo.refresh()
}
fun newGroupAssignment(name: String) {
transaction {
GroupAssignment.new {
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
this.number = nextIdx()
}
groupAs.refresh()
}
}
fun setGroupAssignmentTitle(assignment: GroupAssignment, title: String) {
transaction {
assignment.name = title
}
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) {
data class LocalGroupGrade(val groupName: String, val assignmentName: String, val groupGrade: String?, val indivGrade: String?)
data class LocalSoloGrade(val assignmentName: String, val grade: String)
val editionCourse = transaction { edition.course to edition }
val groups = RawDbState { student.groups.sortAsc(Groups.name).map { it to (it.edition.course.name to it.edition.name) }.toList() }
val courseEditions = RawDbState { student.courses.map{ it to it.course }.sortedWith {
(e1, c1), (e2, c2) -> c1.name.compareTo(c2.name).let { if(it == 0) e1.name.compareTo(e2.name) else it }
}.toList() }
val groupGrades = RawDbState {
val groupsForEdition = Group.find {
(Groups.editionId eq edition.id) and (Groups.id inList student.groups.map { it.id })
}.associate { it.id to it.name }
val asGroup = (GroupAssignments innerJoin GroupAssignmentCriteria innerJoin GroupFeedbacks innerJoin Groups).selectAll().where {
(GroupFeedbacks.groupId inList groupsForEdition.keys.toList()) and
(GroupAssignmentCriteria.name eq "")
}.map { it[GroupAssignments.id] to it }
val asIndividual = (GroupAssignments innerJoin GroupAssignmentCriteria innerJoin IndividualFeedbacks innerJoin Groups).selectAll().where {
(IndividualFeedbacks.studentId eq student.id) and
(GroupAssignmentCriteria.name eq "")
}.map { it[GroupAssignments.id] to it }
val res = mutableMapOf<EntityID<UUID>, LocalGroupGrade>()
asGroup.forEach {
val (gAId, gRow) = it
res[gAId] = LocalGroupGrade(
gRow[Groups.name], gRow[GroupAssignments.name], gRow[GroupFeedbacks.grade], null
)
}
asIndividual.forEach {
val (gAId, iRow) = it
val og = res[gAId] ?: LocalGroupGrade(iRow[Groups.name], iRow[GroupAssignments.name], null, null)
res[gAId] = og.copy(indivGrade = iRow[IndividualFeedbacks.grade])
}
res.values.toList()
}
val soloGrades = RawDbState {
(SoloAssignments innerJoin SoloAssignmentCriteria innerJoin SoloFeedbacks).selectAll().where {
(SoloFeedbacks.studentId eq student.id) and
(SoloAssignmentCriteria.name eq "")
}.map { LocalSoloGrade(it[SoloAssignments.name], it[SoloFeedbacks.grade]) }.toList()
}
fun update(f: Student.() -> Unit) {
transaction {
student.f()
}
}
}
class GroupState(val group: Group) {
val members = RawDbState { group.studentRoles.map{ it.student to it.role }.sortedBy { it.first.name }.toList() }
val availableStudents = RawDbState { Student.find {
// not yet in the group
(Students.id notInList group.students.map { it.id }) and
// but in the same course (edition)
(Students.id inList group.edition.soloStudents.map { it.id })
}.sortAsc(Students.name).toList() }
val course = transaction { group.edition.course to group.edition }
val roles = RawDbState {
GroupStudents.select(GroupStudents.role).where{ GroupStudents.role.isNotNull() }
.withDistinct(true).sortAsc(GroupStudents.role).map{ it[GroupStudents.role] ?: "" }.toList()
}
fun addStudent(student: Student) {
transaction {
GroupStudents.insert {
it[groupId] = group.id
it[studentId] = student.id
}
}
members.refresh(); availableStudents.refresh()
}
fun removeStudent(student: Student) {
transaction {
GroupStudents.deleteWhere { groupId eq group.id and (studentId eq student.id) }
}
members.refresh(); availableStudents.refresh()
}
fun updateRole(student: Student, role: String?) {
transaction {
GroupStudents.update({ GroupStudents.groupId eq group.id and (GroupStudents.studentId eq student.id) }) {
it[this.role] = role
}
members.refresh()
roles.refresh()
}
}
}
class GroupAssignmentState(val assignment: GroupAssignment) {
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(
val group: Group,
val feedback: LocalFeedback,
val individuals: List<Pair<Student, Pair<String?, LocalFeedback>>> // Student -> (Role, Feedback)
)
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(GroupAssignmentCriteria.name to SortOrder.ASC).toList()
}
val feedback = RawDbState { loadFeedback() }
val autofill = RawDbState {
val forGroups = (GroupFeedbacks innerJoin GroupAssignmentCriteria).selectAll().where { GroupAssignmentCriteria.assignmentId eq assignment.id }.flatMap {
it[GroupFeedbacks.feedback].split('\n')
}
val forIndividuals = (IndividualFeedbacks innerJoin GroupAssignmentCriteria).selectAll().where { GroupAssignmentCriteria.assignmentId eq assignment.id }.flatMap {
it[IndividualFeedbacks.feedback].split('\n')
}
(forGroups + forIndividuals).distinct().sorted()
}
private fun Transaction.loadFeedback(): List<Pair<Group, LocalGFeedback>> {
val allCrit = GroupAssignmentCriterion.find {
GroupAssignmentCriteria.assignmentId eq assignment.id
}
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, criterion: GroupAssignmentCriterion? = null) {
transaction {
GroupFeedbacks.upsert {
it[assignmentId] = assignment.id
it[groupId] = group.id
it[this.feedback] = msg
it[this.grade] = grd
it[criterionId] = criterion?.id
}
}
feedback.refresh(); autofill.refresh()
}
fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String, criterion: GroupAssignmentCriterion? = null) {
transaction {
IndividualFeedbacks.upsert {
it[assignmentId] = assignment.id
it[groupId] = group.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 {
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 role: String?, 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> {
return evaluation.edition.groups.map { group ->
val globalNotes = PeerEvaluationContents.selectAll()
.where {
(PeerEvaluationContents.groupId eq group.id) and
(PeerEvaluationContents.peerEvaluationId eq evaluation.id)
}.firstOrNull()?.let {
it[PeerEvaluationContents.content]
}
val students = group.students.map { from ->
val role = GroupStudents.selectAll().where { (GroupStudents.studentId eq from.id) and (GroupStudents.groupId eq group.id) }.firstOrNull()?.let {
it[GroupStudents.role]
}
val s2g = StudentToGroupEvaluation.selectAll().where {
(StudentToGroupEvaluation.peerEvaluationId eq evaluation.id) and
(StudentToGroupEvaluation.studentId eq from.id)
}.firstOrNull()?.let {
Student2StudentEntry(it[StudentToGroupEvaluation.grade], it[StudentToGroupEvaluation.note])
}
val others = group.students.map { other ->
val eval = StudentToStudentEvaluation.selectAll().where {
(StudentToStudentEvaluation.peerEvaluationId eq evaluation.id) and
(StudentToStudentEvaluation.studentIdFrom eq from.id) and
(StudentToStudentEvaluation.studentIdTo eq other.id)
}.firstOrNull()?.let {
Student2StudentEntry(it[StudentToStudentEvaluation.grade], it[StudentToStudentEvaluation.note])
}
other to eval
}
StudentEntry(from, role, s2g, others)
}
GroupEntry(group, globalNotes ?: "", students)
}
}
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()
} }
} }

View File

@@ -0,0 +1,450 @@
package com.jaytux.grader.viewmodel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.jaytux.grader.app
import com.jaytux.grader.data.v2.*
import com.jaytux.grader.ui.AssignmentsTabHeader
import com.jaytux.grader.ui.GroupsTabHeader
import com.jaytux.grader.ui.StudentsTabHeader
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.exposed.v1.core.Expression
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.dao.with
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Clock
class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
data class GroupData(val group: Group, val members: List<Pair<Student, String?>>)
data class CriterionData(val criterion: Criterion, val gradeType: UiGradeType) {
companion object {
context(trns: Transaction)
fun from(c: Criterion) = CriterionData(c, UiGradeType.from(c.gradeType, c.categoricGrade, c.numericGrade))
}
}
data class AssignmentData(val assignment: BaseAssignment, val global: CriterionData, val criteria: List<CriterionData>)
data class GradeSummary(val assignment: BaseAssignment, val asMember: Group?, val overridden: Boolean, val grade: Grade?)
enum class Tab(val renderTab: @Composable () -> Unit, val addText: String) {
STUDENTS(::StudentsTabHeader, "Student"),
GROUPS(::GroupsTabHeader, "Group"),
ASSIGNMENTS(::AssignmentsTabHeader, "Assignment")
}
val studentList = RawDbState { edition.students.sortedBy { it.name }.toList() }
val groupList = RawDbState {
edition.groups.with(Group::students, GroupStudent::student).sortedBy { it.name }.map {
GroupData(it, it.students.map { gs -> gs.student to gs.role }.sortedBy { it.first.name })
}
}
val assignmentList = RawDbState {
edition.assignments.sortedBy { it.number }.map {
AssignmentData(it, CriterionData.from(it.globalCriterion), it.nonBaseCriteria.map { c ->
CriterionData.from(c)
})
}
}
val usedRoles = RawDbState {
GroupStudents.select(GroupStudents.role).mapNotNull { it[GroupStudents.role] }.distinct()
}
val categoricGrades = RawDbState {
CategoricGrade.all().map {
UiGradeType.Categoric(it.options.toList(), it)
}
}
val numericGrades = RawDbState {
NumericGrade.all().map { UiGradeType.Numeric(it) }
}
val studentGrades = RawDbFocusableState { st: Student ->
val groupIds = st.groups.map { it.group.id }.toSet()
edition.assignments.map { asg ->
val (grade, memberOf, override) = when(asg.type) {
AssignmentType.GROUP -> {
val asGroup = asg.globalCriterion.feedbacks.find { it.asGroupFeedback?.id in groupIds }
val solo = asg.globalCriterion.feedbacks.find { it.forStudentsOverrideIfGroup.any { over -> over.student == st } }
val gr = (solo ?: asGroup)?.let { Grade.fromAssignment(asg.globalCriterion, it) }
gr to asGroup?.asGroupFeedback app (solo != null)
}
AssignmentType.SOLO -> {
val eval = asg.globalCriterion.feedbacks.find { it.asSoloFeedback == st }
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
eval to null app false
}
AssignmentType.PEER_EVALUATION -> {
val eval = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id == st.id }
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
eval to null app false
}
}
GradeSummary(asg, memberOf, override, grade)
}
}
val studentGroups = RawDbFocusableState { st: Student ->
st.groups.map { it.group to it.role }
}
val groupAvailableStudents = RawDbFocusableState { grp: Group ->
val exclude = grp.students.map { it.student.id }.toSet()
edition.students.filterNot { it.id in exclude }
}
val groupGrades = RawDbFocusableState { g: Group ->
edition.assignments.filter{ it.type != AssignmentType.SOLO }.map { asg ->
val grade = when(asg.type) {
AssignmentType.GROUP -> {
val asGroup = asg.globalCriterion.feedbacks.find { it.asGroupFeedback?.id == g.id }
asGroup?.let { Grade.fromAssignment(asg.globalCriterion, it) }
}
AssignmentType.PEER_EVALUATION -> {
val asGroup = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id == g.id }
asGroup?.let { Grade.fromAssignment(asg.globalCriterion, it) }
}
else -> null
}
asg to grade
}
}
val asPeerEvaluation = RawDbFocusableSingleState { asg: BaseAssignment ->
asg.asPeerEvaluation?.let { peer ->
val stuCrit = peer.studentCriterion
peer to UiGradeType.from(stuCrit.gradeType, stuCrit.categoricGrade, stuCrit.numericGrade)
}
}
private val _selectedTab = mutableStateOf(Tab.STUDENTS)
private val _focusIndex = mutableStateOf(-1)
val selectedTab = _selectedTab.immutable()
val focusIndex = _focusIndex.immutable()
fun switchTo(tab: Tab) {
_selectedTab.value = tab
_focusIndex.value = -1
}
fun focus(idx: Int) {
_focusIndex.value = idx
when(_selectedTab.value) {
Tab.STUDENTS -> {
val st = studentList.entities.value[idx]
studentGrades.focus(st)
studentGroups.focus(st)
}
Tab.GROUPS -> {
val grp = groupList.entities.value[idx].group
groupAvailableStudents.focus(grp)
groupGrades.focus(grp)
}
Tab.ASSIGNMENTS -> {
val asg = assignmentList.entities.value[idx].assignment
asPeerEvaluation.focus(asg)
}
}
}
fun focus(group: Group) {
val idx = groupList.entities.value.indexOfFirst { it.group.id == group.id }
if(idx != -1) {
switchTo(Tab.GROUPS)
focus(idx)
}
}
fun focus(student: Student) {
val idx = studentList.entities.value.indexOfFirst { it.id == student.id }
if(idx != -1) {
switchTo(Tab.STUDENTS)
focus(idx)
}
}
fun unfocus() {
_focusIndex.value = -1
studentGrades.unfocus()
studentGroups.unfocus()
}
fun mkStudent(name: String, contact: String, note: String) {
transaction {
val s = Student.new {
this.name = name
this.contact = contact
this.note = note
}
EditionStudents.insert {
it[EditionStudents.editionId] = edition.id
it[EditionStudents.studentId] = s.id
}
}
unfocus()
studentList.refresh()
}
fun modStudent(student: Student, name: String?, contact: String?, note: String?) {
transaction {
student.name = name ?: student.name
student.contact = contact ?: student.contact
student.note = note ?: student.note
}
studentList.refresh()
studentGroups.refresh()
studentGrades.refresh()
}
fun rmStudent(student: Student) {
transaction {
student.delete()
}
unfocus()
studentList.refresh()
}
fun mkGroup(name: String) {
transaction {
Group.new {
this.name = name
this.edition = this@EditionVM.edition
}
}
unfocus()
groupList.refresh()
}
fun modGroup(group: Group, name: String?) {
transaction {
group.name = name ?: group.name
}
groupList.refresh()
}
fun addStudentToGroup(student: Student, group: Group, role: String?) {
transaction {
GroupStudent.new {
this.student = student
this.group = group
this.role = role
}
}
groupList.refresh()
studentGroups.refresh()
groupAvailableStudents.refresh()
}
fun setStudentRole(student: Student, group: Group, role: String?) {
transaction {
GroupStudent.find { (GroupStudents.studentId eq student.id) and (GroupStudents.groupId eq group.id) }.firstOrNull()?.let {
it.role = role
}
}
groupList.refresh()
groupAvailableStudents.refresh()
usedRoles.refresh()
}
fun rmStudentFromGroup(student: Student, group: Group) {
transaction {
GroupStudent.find { (GroupStudents.studentId eq student.id) and (GroupStudents.groupId eq group.id) }.firstOrNull()?.delete()
}
groupList.refresh()
groupAvailableStudents.refresh()
}
fun rmGroup(group: Group) {
transaction {
group.delete()
}
unfocus()
groupList.refresh()
}
private fun Transaction.mkBaseAssignment(name: String, type: AssignmentType): BaseAssignment {
val asg = BaseAssignment.new {
this.name = name
this.assignment = ""
this.deadline = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
this.number = assignmentList.entities.value.size
this.edition = this@EditionVM.edition
this.type = type
}
val crit = Criterion.new {
this.assignment = asg
this.name = "(Default Criterion)"
this.desc = "Default criterion for assignment ${asg.name}"
this.gradeType = GradeType.NONE
}
asg.globalCriterion = crit
return asg
}
private fun postCreateAsg() {
assignmentList.refresh()
focus(assignmentList.entities.value.size - 1)
}
fun mkGroupAssignment(name: String) {
transaction {
val asg = mkBaseAssignment(name, AssignmentType.GROUP)
GroupAssignment.new { this.base = asg }
}
postCreateAsg()
}
fun mkSoloAssignment(name: String) {
transaction {
val asg = mkBaseAssignment(name, AssignmentType.SOLO)
SoloAssignment.new { this.base = asg }
}
postCreateAsg()
}
fun mkPeerEvaluation(name: String) {
transaction {
val asg = mkBaseAssignment(name, AssignmentType.PEER_EVALUATION)
val stCrit = Criterion.new {
this.assignment = asg
this.name = "@__internal"
this.desc = "INTERNAL ONLY: Criterion to store the grade type for peer evaluation assignments"
this.gradeType = GradeType.NONE
}
PeerEvaluation.new {
this.base = asg
this.studentCriterion = stCrit
}
}
postCreateAsg()
}
fun mkAssignment(name: String, type: AssignmentType) {
when(type) {
AssignmentType.GROUP -> mkGroupAssignment(name)
AssignmentType.SOLO -> mkSoloAssignment(name)
AssignmentType.PEER_EVALUATION -> mkPeerEvaluation(name)
}
}
fun modAssignment(assignment: BaseAssignment, name: String?, deadline: LocalDateTime?) {
transaction {
assignment.name = name ?: assignment.name
assignment.deadline = deadline ?: assignment.deadline
}
assignmentList.refresh()
}
fun setDesc(assignment: AssignmentData, desc: String) {
transaction {
assignment.global.criterion.desc = desc
}
assignmentList.refresh()
}
fun mkCriterion(assignment: BaseAssignment, name: String, desc: String, gradeType: UiGradeType) {
transaction {
val crit = Criterion.new {
this.assignment = assignment
this.name = name
this.desc = desc
this.gradeType = when(gradeType) {
is UiGradeType.Categoric -> GradeType.CATEGORIC
is UiGradeType.Numeric -> GradeType.NUMERIC
UiGradeType.Percentage -> GradeType.PERCENTAGE
UiGradeType.FreeText -> GradeType.NONE
}
}
when(gradeType) {
is UiGradeType.Categoric -> crit.categoricGrade = gradeType.grade
is UiGradeType.Numeric -> crit.numericGrade = gradeType.grade
else -> {}
}
}
assignmentList.refresh()
}
fun modCriterion(crit: Criterion, name: String?, desc: String?, gradeType: UiGradeType?) {
transaction {
crit.name = name ?: crit.name
crit.desc = desc ?: crit.desc
crit.gradeType = when(gradeType) {
null -> crit.gradeType
is UiGradeType.Categoric -> GradeType.CATEGORIC
is UiGradeType.Numeric -> GradeType.NUMERIC
UiGradeType.Percentage -> GradeType.PERCENTAGE
UiGradeType.FreeText -> GradeType.NONE
}
when(gradeType) {
is UiGradeType.Categoric -> crit.categoricGrade = gradeType.grade
is UiGradeType.Numeric -> crit.numericGrade = gradeType.grade
else -> {}
}
}
assignmentList.refresh()
}
fun mkScale(name: String, options: List<String>) {
transaction {
val grade = CategoricGrade.new { this.name = name }
options.forEachIndexed { idx, opt ->
CategoricGradeOption.new {
this.grade = grade
this.option = opt
this.index = idx
}
}
}
categoricGrades.refresh()
}
fun mkNumericScale(name: String, max: Double) {
transaction {
NumericGrade.new {
this.name = name
this.max = max
}
}
numericGrades.refresh()
}
fun setPEGrade(pe: PeerEvaluation, gradeType: UiGradeType) {
transaction {
pe.studentCriterion.gradeType = when (gradeType) {
is UiGradeType.Categoric -> GradeType.CATEGORIC
is UiGradeType.Numeric -> GradeType.NUMERIC
UiGradeType.Percentage -> GradeType.PERCENTAGE
UiGradeType.FreeText -> GradeType.NONE
}
when (gradeType) {
is UiGradeType.Categoric -> pe.studentCriterion.categoricGrade = gradeType.grade
is UiGradeType.Numeric -> pe.studentCriterion.numericGrade = gradeType.grade
else -> {}
}
}
asPeerEvaluation.refresh()
}
fun rmAssignment(assignment: BaseAssignment) {
transaction {
assignment.delete()
(assignment.asPeerEvaluation ?: assignment.asGroupAssignment ?: assignment.asSoloAssignment)?.delete()
}
unfocus()
assignmentList.refresh()
}
}

View File

@@ -0,0 +1,65 @@
package com.jaytux.grader.viewmodel
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.jaytux.grader.data.v2.BaseFeedback
import com.jaytux.grader.data.v2.CategoricGrade
import com.jaytux.grader.data.v2.CategoricGradeOption
import com.jaytux.grader.data.v2.CategoricGradeOptions
import com.jaytux.grader.data.v2.Criterion
import com.jaytux.grader.data.v2.GradeType
import com.jaytux.grader.data.v2.NumericGrade
import com.jaytux.grader.maxN
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
sealed class Grade {
data class FreeText(val text: String) : Grade() {
override fun toString(): String = "FreeText($text)"
}
data class Percentage(val percentage: Double) : Grade() {
override fun toString(): String = "Perc($percentage%)"
}
data class Numeric(val value: Double, val grade: NumericGrade) : Grade() {
override fun toString(): String = "Numeric($value / ${grade.max})"
}
data class Categoric(val value: CategoricGradeOption, val options: List<CategoricGradeOption>, val grade: CategoricGrade) : Grade() {
override fun toString(): String = "Categoric(${value.option})"
}
@Composable
fun render(modifier: Modifier = Modifier) = when(this) {
is FreeText -> Text(text.maxN(15), modifier)
is Categoric -> Text(value.option, modifier)
is Numeric -> Text("$value / ${grade.max}", modifier)
is Percentage -> Text("$percentage%", modifier)
}
companion object {
context(trns: Transaction)
fun fromAssignment(asg: Criterion, fdb: BaseFeedback): Grade = when(asg.gradeType) {
GradeType.CATEGORIC ->
Categoric(fdb.gradeCategoric!!, asg.categoricGrade!!.options.toList(), asg.categoricGrade!!)
GradeType.NUMERIC -> Numeric(fdb.gradeNumeric!!, asg.numericGrade!!)
GradeType.PERCENTAGE -> Percentage(fdb.gradeNumeric!!)
GradeType.NONE -> FreeText(fdb.gradeFreeText!!)
}
fun defaultFreeText() = FreeText("")
fun defaultPercentage() = Percentage(0.0)
fun defaultNumeric(grade: NumericGrade) = Numeric(0.0, grade)
fun defaultCategoric(grade: CategoricGrade, options: List<CategoricGradeOption>) = Categoric(options.first(), options, grade)
fun default(type: GradeType, cat: CategoricGrade?, num: NumericGrade?) = when(type) {
GradeType.CATEGORIC -> transaction {
cat!!
defaultCategoric(cat, cat.options.toList())
}
GradeType.NUMERIC -> defaultNumeric(num!!)
GradeType.PERCENTAGE -> defaultPercentage()
GradeType.NONE -> defaultFreeText()
}
}
}

View File

@@ -0,0 +1,154 @@
package com.jaytux.grader.viewmodel
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.jaytux.grader.data.v2.BaseAssignment
import com.jaytux.grader.data.v2.BaseFeedback
import com.jaytux.grader.data.v2.BaseFeedbacks
import com.jaytux.grader.data.v2.CategoricGrade
import com.jaytux.grader.data.v2.Course
import com.jaytux.grader.data.v2.Criterion
import com.jaytux.grader.data.v2.Edition
import com.jaytux.grader.data.v2.GradeType
import com.jaytux.grader.data.v2.Group
import com.jaytux.grader.data.v2.GroupAssignment
import com.jaytux.grader.data.v2.GroupFeedbacks
import com.jaytux.grader.data.v2.GroupStudent
import com.jaytux.grader.data.v2.NumericGrade
import com.jaytux.grader.data.v2.Student
import com.jaytux.grader.data.v2.StudentOverrideFeedback
import com.jaytux.grader.data.v2.StudentOverrideFeedbacks
import com.jaytux.grader.ui.CritData
import com.jaytux.grader.ui.FeedbackItem
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.dao.with
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.upsertReturning
class GroupsGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
data class GroupData(val group: Group, val students: List<Pair<Student, String?>>)
data class FeedbackData(val groupLevel: FeedbackItem?, val overrides: List<Pair<Student, FeedbackItem?>>)
private val _focus = mutableStateOf(-1)
val focus = _focus.immutable()
val asGroup = transaction { base.asGroupAssignment!! }
val global = transaction { CritData.fromDb(base.globalCriterion) }
val groupList = RawDbState {
edition.groups.with(Group::students, GroupStudent::student).map { group ->
GroupData(group, group.students.map { Pair(it.student, it.role) })
}
}
val globalGrade = RawDbFocusableSingleState { group: Group ->
val g = base.globalCriterion.feedbacks.find { it.asGroupFeedback?.id == group.id }?.let { FeedbackItem.fromDb(it) }
val overrides = g?.let { gl -> getOverrides(group, gl.base) } ?: group.students.map { it.student to null }
FeedbackData(g, overrides)
}
val gradeList = RawDbFocusableState { group: Group ->
base.nonBaseCriteria.map { crit ->
val groupLevel = crit.feedbacks.find { it.asGroupFeedback?.id == group.id }?.let { FeedbackItem.fromDb(it) }
val overrides = groupLevel?.let { gl -> getOverrides(group, gl.base) } ?: group.students.map { it.student to null }
CritData.fromDb(crit) to FeedbackData(groupLevel, overrides)
}
}
private fun Transaction.getOverrides(group: Group, fd: BaseFeedback): List<Pair<Student, FeedbackItem?>> {
val feedbacks = fd.forStudentsOverrideIfGroup.filter { it.group.id == group.id }.associateBy { it.student.id }
return group.students.map {
it.student to feedbacks[it.student.id]?.let { sof -> FeedbackItem.fromDb(sof.feedback) }
}
}
fun focusGroup(idx: Int) {
_focus.value = idx
val group = groupList.entities.value[idx].group
globalGrade.focus(group)
gradeList.focus(group)
}
fun focusPrev() {
if (focus.value > 0) {
focusGroup(focus.value - 1)
}
}
fun focusNext() {
if (focus.value < groupList.entities.value.size - 1) {
focusGroup(focus.value + 1)
}
}
context(trns: Transaction)
private fun BaseFeedback.set(grade: Grade, feedback: String) {
this.feedback = feedback
when(grade) {
is Grade.Categoric -> this.gradeCategoric = grade.value
is Grade.FreeText -> this.gradeFreeText = grade.text
is Grade.Numeric -> this.gradeNumeric = grade.value
is Grade.Percentage -> this.gradeNumeric = grade.percentage
}
}
fun modGroupFeedback(crit: Criterion, group: Group, grade: Grade, feedback: String) {
transaction {
val existing = group.feedbacks.find { f -> f.criterion.id == crit.id }
if(existing != null) {
existing.set(grade, feedback)
}
else {
val fdb = BaseFeedback.new {
criterion = crit
set(grade, feedback)
}
GroupFeedbacks.insert {
it[GroupFeedbacks.feedbackId] = fdb.id
it[GroupFeedbacks.groupId] = group.id
}
}
}
globalGrade.refresh()
gradeList.refresh()
}
fun modOverrideFeedback(crit: Criterion, group: Group, student: Student, groupLevel: FeedbackItem, grade: Grade, feedback: String) {
transaction {
val existing = groupLevel.base.forStudentsOverrideIfGroup.find { it.student.id == student.id }
if(existing != null) {
existing.feedback.set(grade, feedback)
}
else {
val fdb = BaseFeedback.new {
criterion = crit
set(grade, feedback)
}
StudentOverrideFeedback.new {
this.group = group
this.student = student
this.feedback = fdb
this.overrides = groupLevel.base
}
}
}
globalGrade.refresh()
gradeList.refresh()
}
fun rmOverrideFeedback(crit: Criterion, group: Group, student: Student) {
transaction {
crit.feedbacks.find {
it.asGroupFeedback!!.id == group.id // find relevant group-level feedback
}?.forStudentsOverrideIfGroup?.find {
it.student.id == student.id // find override for the student
}?.delete()
}
globalGrade.refresh()
gradeList.refresh()
}
}

View File

@@ -0,0 +1,70 @@
package com.jaytux.grader.viewmodel
import androidx.lifecycle.ViewModel
import com.jaytux.grader.data.v2.BaseAssignment
import com.jaytux.grader.data.v2.Course
import com.jaytux.grader.data.v2.Edition
import com.jaytux.grader.data.v2.Group
import com.jaytux.grader.data.v2.Student
import org.jetbrains.exposed.v1.dao.with
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
class HomeVM : ViewModel() {
data class EditionData(val edition: Edition, val students: List<Student>, val groups: List<Group>, val assignments: List<BaseAssignment>)
data class CourseData(val course: Course, val editions: List<EditionData>, val archived: List<EditionData>)
val courses = RawDbState {
Course.all().with(Course::editions, Edition::students, Edition::groups, Edition::assignments).map {
val mkEditionData = { e: Edition ->
EditionData(e, e.students.toList(), e.groups.toList(), e.assignments.toList())
}
CourseData(it, it.editions.filter { e -> !e.archived }.map(mkEditionData), it.editions.filter { e -> e.archived }.map(mkEditionData))
}
}
fun mkCourse(name: String) {
transaction {
Course.new { this.name = name }
}
courses.refresh()
}
fun rmCourse(course: Course) {
transaction {
course.delete()
}
courses.refresh()
}
fun mkEdition(course: Course, name: String) {
transaction {
Edition.new {
this.course = course
this.name = name
}
}
courses.refresh()
}
fun rmEdition(edition: Edition) {
transaction {
edition.delete()
}
courses.refresh()
}
fun archiveEdition(edition: Edition) {
transaction {
edition.archived = true
}
courses.refresh()
}
fun unarchiveEdition(edition: Edition) {
transaction {
edition.archived = false
}
courses.refresh()
}
}

View File

@@ -0,0 +1,156 @@
package com.jaytux.grader.viewmodel
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.BackHandler
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.ui.ChevronLeft
import com.jaytux.grader.ui.Icons
import com.jaytux.grader.ui.Scaffold
import com.jaytux.grader.ui.Surface
import com.jaytux.grader.ui.TitleBar
import org.jetbrains.jewel.foundation.theme.JewelTheme
import kotlin.reflect.KClass
import org.jetbrains.jewel.ui.component.*;
class Navigator private constructor(
private var _start: IDestination,
private val _typeMap: Map<KClass<out IDestination>, RenderData>
) : ViewModel() {
interface IDestination
private data class Entry<T : IDestination>(val dest: T, val token: NavToken)
inner class NavToken {
fun navTo(target: IDestination) { this@Navigator.navTo(target) }
fun back() { this@Navigator.back() }
inline fun <reified T : IDestination> backTo() { this@Navigator.backTo<T>() }
fun rewriteHistory(t: IDestination) { this@Navigator.rewriteHistory(t) }
}
internal data class RenderData(
val header: @Composable (IDestination) -> Unit,
val renderer: @Composable (IDestination, NavToken) -> Unit
)
private val _stack = mutableStateOf(listOf<Entry<*>>(Entry(_start, NavToken())))
fun navTo(target: IDestination) {
_stack.value += Entry(target, NavToken())
}
fun back() {
if(_stack.value.size > 1) _stack.value = _stack.value.dropLast(1)
}
fun <T : IDestination> backTo(cls: KClass<T>) {
val idx = _stack.value.indexOfLast { entry -> cls.isInstance(entry.dest) }
if(idx != -1 && idx != _stack.value.lastIndex) {
_stack.value = _stack.value.take(idx + 1)
}
}
fun rewriteHistory(t: IDestination) {
_stack.value = listOf(Entry(t, NavToken()))
_start = t
}
inline fun <reified T : IDestination> backTo() = backTo(T::class)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DisplayScaffold() {
val state = remember { SnackbarHostState() }
val stack by _stack
val (top, render) = remember(stack) {
val top = stack.last()
val render = _typeMap[top.dest::class]
?: throw IllegalStateException("No renderer for destination of type ${top.dest::class.simpleName}")
top to render
}
val snackVM = viewModel<SnackVM> { SnackVM() }
snackVM.Launcher(state)
BackHandler { back() }
Scaffold(
topBar = {
TitleBar(
modifier = Modifier.fillMaxWidth(),
title = { render.header(top.dest) },
) {
IconButton({ back() }, enabled = top != _start) {
Icon(Icons.ChevronLeft, contentDescription = "Back")
}
}
},
snackState = state
) { //insets ->
Surface(/*Modifier.padding(insets),*/ color = JewelTheme.globalColors.panelBackground) {
render.renderer(top.dest, top.token)
}
}
}
@DslMarker
annotation class NavigatorDslMarker
@NavigatorDslMarker
class Builder internal constructor(
private val _onBuild: (IDestination, Map<KClass<out IDestination>, RenderData>) -> Navigator
) {
private val _typeMap = mutableMapOf<KClass<out IDestination>, RenderData>()
private lateinit var _start: IDestination
fun <T : IDestination> composable(cls: KClass<T>, title: @Composable (T) -> Unit, renderer: @Composable (T, NavToken) -> Unit) {
_typeMap[cls]?.let {
throw IllegalArgumentException("Destination of type ${cls.simpleName} is already registered.")
} ?: run {
_typeMap[cls] = RenderData({
@Suppress("UNCHECKED_CAST")
title(it as T)
}) { d, t ->
@Suppress("UNCHECKED_CAST")
renderer(d as T, t)
}
}
}
inline fun <reified T : IDestination> composable(noinline title: @Composable (T) -> Unit, noinline renderer: @Composable (T, NavToken) -> Unit) {
composable(T::class, title, renderer)
}
fun start(start: IDestination) {
if(this::_start.isInitialized) throw IllegalStateException("Start destination is already set.")
_start = start
}
internal fun build(): Navigator {
if(!this::_start.isInitialized) throw IllegalStateException("Start destination is not set.")
return _onBuild(_start, _typeMap)
}
}
companion object {
fun build(block: Builder.() -> Unit): Navigator =
Builder { start, map -> Navigator(start, map) }
.apply { block() }.build()
@Composable
fun NavHost(initial: IDestination, block: Builder.() -> Unit) {
val vm = viewModel {
build {
block()
start(initial)
}
}
vm.DisplayScaffold()
}
}
}

View File

@@ -0,0 +1,217 @@
package com.jaytux.grader.viewmodel
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.jaytux.grader.app
import com.jaytux.grader.data.v2.BaseAssignment
import com.jaytux.grader.data.v2.BaseFeedback
import com.jaytux.grader.data.v2.BaseFeedbacks
import com.jaytux.grader.data.v2.Course
import com.jaytux.grader.data.v2.Edition
import com.jaytux.grader.data.v2.Group
import com.jaytux.grader.data.v2.GroupStudent
import com.jaytux.grader.data.v2.PeerEvaluationFeedbacks
import com.jaytux.grader.data.v2.PeerEvaluationS2G
import com.jaytux.grader.data.v2.PeerEvaluationS2GEvaluations
import com.jaytux.grader.data.v2.PeerEvaluationS2S
import com.jaytux.grader.data.v2.PeerEvaluationS2SEvaluations
import com.jaytux.grader.data.v2.Student
import com.jaytux.grader.ui.CritData
import com.jaytux.grader.ui.FeedbackItem
import com.jaytux.grader.viewmodel.GroupsGradingVM.GroupData
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.dao.with
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
class PeerEvalsGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
data class S2S(val evaluatee: Student, val data: FeedbackItem?)
data class Evaluation(val evaluator: Student, val groupLevel: FeedbackItem?, val s2s: List<S2S>)
private val _focus = mutableStateOf(-1)
val focus = _focus.immutable()
val asPeer = transaction { base.asPeerEvaluation!! }
val global = transaction { CritData.fromDb(base.globalCriterion) }
val studentCriterion = transaction { CritData.fromDb(asPeer.studentCriterion) }
val groupList = RawDbState {
edition.groups.with(Group::students, GroupStudent::student).map { group ->
GroupData(group, group.students.map { Pair(it.student, it.role) })
}
}
val evaluationMatrix = RawDbFocusableState { group: Group ->
val studentIds = group.students.map { it.student.id.value }
val s2gs = PeerEvaluationS2G.find {
(PeerEvaluationS2GEvaluations.peerEvalId eq asPeer.id) and
(PeerEvaluationS2GEvaluations.studentId inList studentIds)
}.also {
println("S2G for group ${group.name}:")
it.forEach { println(" ${it.student.name} -> ${it.evaluation.gradeCategoric ?: it.evaluation.gradeNumeric ?: it.evaluation.gradeFreeText}") }
}
val s2ss = PeerEvaluationS2S.find {
(PeerEvaluationS2SEvaluations.peerEvalId eq asPeer.id) and
(PeerEvaluationS2SEvaluations.studentId inList studentIds)
}
group.students.map { evaluator ->
val s2s = group.students.map { evaluatee ->
val item = s2ss.find { it.student.id == evaluator.student.id && it.evaluatedStudent.id == evaluatee.student.id }?.let {
FeedbackItem.fromDb(it.evaluation)
}
S2S(evaluatee.student, item)
}
val s2g = s2gs.find { it.student.id == evaluator.student.id }?.let { FeedbackItem.fromDb(it.evaluation) }
Evaluation(evaluator.student, s2g, s2s)
}
}
val studentGrades = RawDbFocusableState { group: Group ->
val studentIds = group.students.map { it.student.id.value }.toSet()
val mapping = global.criterion.feedbacks.mapNotNull {
it.asPeerEvaluationFeedback?.let { x ->
if(x.id.value in studentIds) x.id to FeedbackItem.fromDb(it) else null
}
}.toMap()
group.students.map { student -> student.student to mapping[student.student.id] }
}
val students = RawDbFocusableState { group: Group ->
group.students.map { it.student }
}
fun focusGroup(idx: Int) {
_focus.value = idx
val current = groupList.entities.value[idx].group
evaluationMatrix.focus(current)
students.focus(current)
studentGrades.focus(current)
}
fun focusPrev() {
if (focus.value > 0) focusGroup(focus.value - 1)
}
fun focusNext() {
if (focus.value < groupList.entities.value.size - 1) focusGroup(focus.value + 1)
}
private fun setStudentEvaluation(evaluator: Student, evaluatee: Student, grade: Grade, feedback: String) = transaction {
val existing = PeerEvaluationS2S.find {
(PeerEvaluationS2SEvaluations.peerEvalId eq asPeer.id) and
(PeerEvaluationS2SEvaluations.studentId eq evaluator.id) and
(PeerEvaluationS2SEvaluations.evaluatedStudentId eq evaluatee.id)
}.firstOrNull()
if(existing != null) {
existing.evaluation.feedback = feedback
when(grade) {
is Grade.Categoric -> existing.evaluation.gradeCategoric = grade.value
is Grade.Numeric -> existing.evaluation.gradeNumeric = grade.value
is Grade.Percentage -> existing.evaluation.gradeNumeric = grade.percentage
is Grade.FreeText -> existing.evaluation.gradeFreeText = grade.text
}
}
else {
val base = BaseFeedback.new {
criterion = studentCriterion.criterion
this.feedback = feedback
when(grade) {
is Grade.Categoric -> this.gradeCategoric = grade.value
is Grade.Numeric -> this.gradeNumeric = grade.value
is Grade.Percentage -> this.gradeNumeric = grade.percentage
is Grade.FreeText -> this.gradeFreeText = grade.text
}
}
PeerEvaluationS2S.new {
evaluation = base
peerEvaluation = asPeer
student = evaluator
evaluatedStudent = evaluatee
}
}
}
private fun setStudentGroupEvaluation(evaluator: Student, group: Group, grade: Grade, feedback: String) = transaction {
val existing = PeerEvaluationS2G.find {
(PeerEvaluationS2GEvaluations.peerEvalId eq asPeer.id) and
(PeerEvaluationS2GEvaluations.studentId eq evaluator.id) and
(PeerEvaluationS2GEvaluations.groupId eq group.id)
}.firstOrNull()
if(existing != null) {
existing.evaluation.feedback = feedback
when(grade) {
is Grade.Categoric -> existing.evaluation.gradeCategoric = grade.value
is Grade.Numeric -> existing.evaluation.gradeNumeric = grade.value
is Grade.Percentage -> existing.evaluation.gradeNumeric = grade.percentage
is Grade.FreeText -> existing.evaluation.gradeFreeText = grade.text
}
}
else {
val base = BaseFeedback.new {
criterion = studentCriterion.criterion
this.feedback = feedback
when(grade) {
is Grade.Categoric -> this.gradeCategoric = grade.value
is Grade.Numeric -> this.gradeNumeric = grade.value
is Grade.Percentage -> this.gradeNumeric = grade.percentage
is Grade.FreeText -> this.gradeFreeText = grade.text
}
}
PeerEvaluationS2G.new {
evaluation = base
peerEvaluation = asPeer
student = evaluator
this.group = group
}
}
}
fun setEvaluation(evaluator: Student, evaluatee: Student?, group: Group, grade: Grade, feedback: String) {
println("Setting: evaluator=${evaluator.name}, evaluatee=${evaluatee?.name}, group=${group.name}, grade=$grade, feedback=$feedback")
evaluatee?.let { setStudentEvaluation(evaluator, it, grade, feedback) } ?: setStudentGroupEvaluation(evaluator, group, grade, feedback)
evaluationMatrix.refresh()
}
fun setStudentGrade(student: Student, grade: Grade, feedback: String) = transaction {
val existing = BaseFeedback.find { BaseFeedbacks.criterionId eq global.criterion.id }
.find { it.asPeerEvaluationFeedback?.id?.value == student.id.value }
if(existing != null) {
existing.feedback = feedback
when(grade) {
is Grade.Categoric -> existing.gradeCategoric = grade.value
is Grade.Numeric -> existing.gradeNumeric = grade.value
is Grade.Percentage -> existing.gradeNumeric = grade.percentage
is Grade.FreeText -> existing.gradeFreeText = grade.text
}
}
else {
val base = BaseFeedback.new {
criterion = global.criterion
this.feedback = feedback
when(grade) {
is Grade.Categoric -> this.gradeCategoric = grade.value
is Grade.Numeric -> this.gradeNumeric = grade.value
is Grade.Percentage -> this.gradeNumeric = grade.percentage
is Grade.FreeText -> this.gradeFreeText = grade.text
}
}
PeerEvaluationFeedbacks.insert {
it[PeerEvaluationFeedbacks.feedbackId] = base.id
it[PeerEvaluationFeedbacks.studentId] = student.id
}
}
studentGrades.refresh()
}
}

View File

@@ -0,0 +1,28 @@
package com.jaytux.grader.viewmodel
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class SnackVM : ViewModel() {
private val _snacks = Channel<String>()
val snacks = _snacks.receiveAsFlow()
fun show(msg: String): Unit {
viewModelScope.launch {
_snacks.send(msg)
}
}
@Composable
fun Launcher(state: SnackbarHostState) {
LaunchedEffect(Unit) {
snacks.collect { snack -> state.showSnackbar(snack) }
}
}
}

View File

@@ -0,0 +1,9 @@
package com.jaytux.grader.viewmodel
import androidx.lifecycle.ViewModel
import com.jaytux.grader.data.v2.BaseAssignment
import com.jaytux.grader.data.v2.Course
import com.jaytux.grader.data.v2.Edition
class SolosGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
}

View File

@@ -0,0 +1,30 @@
package com.jaytux.grader.viewmodel
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.jaytux.grader.data.v2.BaseFeedback
import com.jaytux.grader.data.v2.CategoricGrade
import com.jaytux.grader.data.v2.CategoricGradeOption
import com.jaytux.grader.data.v2.Criterion
import com.jaytux.grader.data.v2.GradeType
import com.jaytux.grader.data.v2.NumericGrade
import com.jaytux.grader.maxN
import org.jetbrains.exposed.v1.core.Transaction
sealed class UiGradeType {
object FreeText : UiGradeType()
object Percentage : UiGradeType()
data class Numeric(val grade: NumericGrade) : UiGradeType()
data class Categoric(val options: List<CategoricGradeOption>, val grade: CategoricGrade) : UiGradeType()
companion object {
context(trns: Transaction)
fun from(type: GradeType, categoric: CategoricGrade?, numeric: NumericGrade?) = when(type) {
GradeType.CATEGORIC -> Categoric(categoric!!.options.toList(), categoric)
GradeType.NUMERIC -> Numeric(numeric!!)
GradeType.PERCENTAGE -> Percentage
GradeType.NONE -> FreeText
}
}
}

View File

@@ -1,34 +1,49 @@
[versions] [versions]
androidx-lifecycle = "2.8.4" androidx-lifecycle = "2.9.6"
compose-multiplatform = "1.7.0" compose-multiplatform = "1.9.0"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.1.0" kotlin = "2.3.0"
kotlinx-coroutines = "1.10.1" kotlinx-coroutines = "1.10.1"
exposed = "0.59.0" exposed = "1.1.1"
material3 = "1.7.3" material3 = "1.9.0"
ui-android = "1.7.8" ui-android = "1.7.8"
foundation-layout-android = "1.7.8" foundation-layout-android = "1.7.8"
rtf = "1.0.0-rc11" rtf = "1.0.0-rc11"
filekit = "0.10.0-beta04"
directories = "26"
androidx-activity-compose = "1.12.2"
jewel = "0.34.0-253.31033.149"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
compose-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-multiplatform" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" } exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" } exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" }
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" } exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }
exposed-migration = { group = "org.jetbrains.exposed", name = "exposed-migration-core", version.ref = "exposed" }
exposed-migration-jdbc = { group = "org.jetbrains.exposed", name = "exposed-migration-jdbc", version.ref = "exposed" }
exposed-kotlin-datetime = { group = "org.jetbrains.exposed", name = "exposed-kotlin-datetime", version.ref = "exposed" } exposed-kotlin-datetime = { group = "org.jetbrains.exposed", name = "exposed-kotlin-datetime", version.ref = "exposed" }
sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.34.0" } sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.34.0" }
sl4j = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.12" } sl4j = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.12" }
material3-core = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "material3" } material3-core = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "material3" }
material3-desktop = { group = "org.jetbrains.compose.material3", name = "material3-desktop", version.ref = "material3" } material3-desktop = { group = "org.jetbrains.compose.material3", name = "material3-desktop", version.ref = "material3" }
material-icons = { group = "org.jetbrains.compose.material", name = "material-icons-extended", version.ref = "material3" }
androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "ui-android" } androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "ui-android" }
androidx-foundation-layout-android = { group = "androidx.compose.foundation", name = "foundation-layout-android", version.ref = "foundation-layout-android" } androidx-foundation-layout-android = { group = "androidx.compose.foundation", name = "foundation-layout-android", version.ref = "foundation-layout-android" }
rtfield = { group = "com.mohamedrejeb.richeditor", name = "richeditor-compose", version.ref = "rtf" } rtfield = { group = "com.mohamedrejeb.richeditor", name = "richeditor-compose", version.ref = "rtf" }
filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" }
filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" }
filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" }
filekit-coil = { group = "io.github.vinceglb", name = "filekit-coil", version.ref = "filekit" }
directories = { group = "dev.dirs", name = "directories", version.ref = "directories" }
jewel = { group = "org.jetbrains.jewel", name = "jewel-int-ui-standalone", version.ref = "jewel" }
jewel-windows = { group = "org.jetbrains.jewel", name = "jewel-int-ui-decorated-window", version.ref = "jewel" }
[plugins] [plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

12
gradlew vendored
View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -115,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -173,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -206,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped. # and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line. # treated as '${Hostname}' itself on the command line.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.

3
gradlew.bat vendored
View File

@@ -70,11 +70,10 @@ goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell