Restructure DB & UI, pt.1

This commit is contained in:
2026-03-11 15:28:18 +01:00
parent eca161b251
commit 8786cc6072
43 changed files with 11051 additions and 3071 deletions

2
.gitignore vendored
View File

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

View File

@@ -7,12 +7,17 @@ plugins {
}
kotlin {
jvm("desktop")
compilerOptions {
freeCompilerArgs.add("-Xcontext-parameters")
}
jvm("desktop") {}
sourceSets {
val desktopMain by getting
commonMain.dependencies {
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
@@ -22,16 +27,13 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.material3.core)
implementation(libs.material.icons)
implementation(libs.sl4j)
}
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.exposed.core)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.dao)
implementation(libs.exposed.migration)
implementation(libs.exposed.migration.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.sqlite)
implementation(libs.material3.desktop)
@@ -40,6 +42,9 @@ kotlin {
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)
}
}
}
@@ -52,6 +57,7 @@ compose.desktop {
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.jaytux.grader"
mainClass = "com.jaytux.grader.MainKt"
packageVersion = "1.0.0"
includeAllModules = true

File diff suppressed because one or more lines are too long

View File

@@ -1,43 +1,37 @@
package com.jaytux.grader
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.jaytux.grader.ui.ChevronLeft
import com.jaytux.grader.ui.CoursesView
import com.jaytux.grader.ui.toDp
import com.jaytux.grader.viewmodel.CourseListState
import org.jetbrains.compose.ui.tooling.preview.Preview
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.ui.EditionTitle
import com.jaytux.grader.ui.EditionView
import com.jaytux.grader.ui.GroupsGradingTitle
import com.jaytux.grader.ui.GroupsGradingView
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.viewmodel.Navigator
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
@Preview
fun App() {
MaterialTheme {
val courseList = CourseListState()
var stack by remember {
val start = UiRoute("Courses Overview") { CoursesView(courseList, it) }
mutableStateOf(listOf(start))
}
Column {
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) }
}
}
Navigator.NavHost(Home) {
composable<Home>({ HomeTitle() }) { _, token -> HomeView(token) }
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) }
composable<PeerEvalGrading>({ PeerEvalsGradingTitle(it) }) { data, token -> PeerEvalsGradingView(data, token) }
}
}
}

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

@@ -1,10 +1,16 @@
package com.jaytux.grader
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import com.jaytux.grader.data.Database
import com.mohamedrejeb.richeditor.model.RichTextState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.Clock
import java.time.LocalDateTime
import java.util.prefs.Preferences
fun String.maxN(n: Int): String {
return if (this.length > n) {
@@ -14,10 +20,25 @@ fun String.maxN(n: Int): String {
}
}
fun RichTextState.toClipboard(clip: ClipboardManager) {
suspend fun RichTextState.toClipboard(clip: ClipboardManager) {
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 ?: "") }
}
}
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)

View File

@@ -1,145 +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")
val globalCriterion = reference("global_crit", GroupAssignmentCriteria.id)
}
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")
val globalCriterion = reference("global_crit", SoloAssignmentCriteria.id)
}
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)
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)
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)
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,42 +1,64 @@
package com.jaytux.grader.data
import MigrationUtils
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import com.jaytux.grader.app
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.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 {
val dataDir: String = ProjectDirectories.from("com", "jaytux", "grader").dataDir.also {
val path = Path(it)
if(!path.exists()) path.createDirectories()
}
val db by lazy {
val actual = Database.connect("jdbc:sqlite:file:./grader.db", "org.sqlite.JDBC")
transaction {
SchemaUtils.create(
Courses, Editions, Groups,
Students, GroupStudents, EditionStudents,
GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria,
GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks,
PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation,
StudentToGroupEvaluation
)
val migrate = MigrationUtils.statementsRequiredForDatabaseMigration(
Courses, Editions, Groups,
Students, GroupStudents, EditionStudents,
GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria,
GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks,
PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation,
StudentToGroupEvaluation,
withLogs = true
)
println(" --- Migration --- ")
migrate.forEach { println(it); exec(it) }
println(" --- End migration --- ")
val actual = Database.connect("jdbc:sqlite:file:${dataDir}/grader.db", "org.sqlite.JDBC")
transaction(actual) {
SchemaUtils.create(*v2Tables)
}
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,139 +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
var globalCriterion by GroupAssignmentCriterion referencedOn GroupAssignments.globalCriterion
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
var globalCriterion by SoloAssignmentCriterion referencedOn SoloAssignments.globalCriterion
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

@@ -1,90 +1,118 @@
package com.jaytux.grader.data
import com.jaytux.grader.viewmodel.Assignment
import com.jaytux.grader.viewmodel.GroupAssignmentState
import io.github.vinceglb.filekit.PlatformFile
class MdBuilder {
private val content = StringBuilder()
fun appendHeader(text: String, level: Int = 1) {
require(level in 1..6) { "Header level must be between 1 and 6" }
content.appendLine()
content.appendLine("#".repeat(level) + " $text")
content.appendLine()
}
fun appendMd(text: String) { content.appendLine(text) }
fun appendParagraph(text: String, bold: Boolean = false, italic: Boolean = false) {
val formattedText = buildString {
if (bold) append("**")
if (italic) append("_")
append(text)
if (italic) append("_")
if (bold) append("**")
}
content.appendLine(formattedText)
content.appendLine()
}
fun build(): String = content.toString()
}
fun GroupAssignmentState.LocalGFeedback.exportTo(path: PlatformFile, assignment: GroupAssignment) {
val builder = MdBuilder()
builder.appendHeader("${assignment.name} Feedback for ${group.name}")
if(feedback.global != null && feedback.global.grade.isNotBlank()) {
val global = feedback.global.grade
builder.appendParagraph("Overall grade: ${feedback.global.grade}", true, true)
individuals.forEach { (student, it) ->
val (_, data) = it
if(data.global != null && data.global.grade.isNotBlank() && data.global.grade != global) {
builder.appendParagraph("${student.name} grade: ${data.global.grade}", true, true)
}
}
}
fun appendFeedback(heading: String, group: GroupAssignmentState.FeedbackEntry?, byStudent: List<Pair<Student, GroupAssignmentState.FeedbackEntry>>) {
if(group != null || byStudent.isNotEmpty()) {
builder.appendHeader(heading, 2)
if(group != null) {
if(group.grade.isNotBlank()) {
builder.appendParagraph("Group grade: ${group.grade}", true, true)
}
if(group.feedback.isNotBlank()) {
builder.appendMd(group.feedback)
}
}
byStudent.forEach { (student, it) ->
if(it.grade.isNotBlank() || it.feedback.isNotBlank()) builder.appendHeader(student.name, 3)
if(it.grade.isNotBlank()) {
builder.appendParagraph("Grade: ${it.grade}", true, true)
}
if(it.feedback.isNotBlank()) {
builder.appendMd(it.feedback)
}
}
}
}
appendFeedback("Overall Feedback", feedback.global,
individuals.mapNotNull { it.second.second.global?.let { g -> it.first to g } }
)
val criteria = (feedback.byCriterion.map { (c, _) -> c } +
individuals.flatMap { (_, it) -> it.second.byCriterion.map { (c, _) -> c } }).distinctBy { it.id.value }
criteria.forEach { c ->
appendFeedback(
c.name,
feedback.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry,
individuals.mapNotNull { (student, it) ->
val entry = it.second.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry
entry?.let { student to it }
}
)
}
path.file.writeText(builder.build())
}
//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,181 @@
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", GroupFeedbacks.feedbackId)
}
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()
}
object PeerEvaluationFeedbacks : CompositeIdTable("peerEvalFdbks") {
val groupId = reference("group_id", Groups.id)
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
override val primaryKey = PrimaryKey(groupId, feedbackId)
}
object PeerEvaluationStudentOverrideFeedbacks : UUIDTable("peerEvalStudOvrFdbks") {
val groupId = reference("group_id", Groups.id)
val studentId = reference("student_id", Students.id)
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
val overrides = reference("overrides", BaseFeedbacks.id)
}
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, PeerEvaluationStudentOverrideFeedbacks, PeerEvaluationS2GEvaluations,
PeerEvaluationS2SEvaluations, CategoricGrades, CategoricGradeOptions, NumericGrades
)

View File

@@ -0,0 +1,190 @@
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
}
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 _forGroupIfPeer by Group via PeerEvaluationFeedbacks
val asSoloFeedback get() = _forStudentIfSolo.singleOrNull()
val asGroupFeedback get() = _forGroupIfGroup.singleOrNull()
val asPeerEvaluationFeedback get() = _forGroupIfPeer.singleOrNull()
val forStudentsOverrideIfGroup by StudentOverrideFeedback referrersOn StudentOverrideFeedbacks.overrides
val forStudentsOverrideIfPeer by PeerEvaluationStudentOverrideFeedback referrersOn PeerEvaluationStudentOverrideFeedbacks.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 PeerEvaluationStudentOverrideFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, PeerEvaluationStudentOverrideFeedback>(PeerEvaluationStudentOverrideFeedbacks)
var group by Group referencedOn PeerEvaluationStudentOverrideFeedbacks.groupId
var student by Student referencedOn PeerEvaluationStudentOverrideFeedbacks.studentId
var feedback by BaseFeedback referencedOn PeerEvaluationStudentOverrideFeedbacks.feedbackId
var overrides by BaseFeedback referencedOn PeerEvaluationStudentOverrideFeedbacks.overrides
}
class PeerEvaluationS2G(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, PeerEvaluationS2G>(PeerEvaluationS2GEvaluations)
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

@@ -0,0 +1,500 @@
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.*
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
@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(), tonalElevation = 7.dp) {
ListOrEmpty(assignments, { Text("No groups yet.") }) { idx, it ->
QuickAssignment(idx, it, vm)
}
}
Surface(Modifier.weight(0.75f).fillMaxHeight(), tonalElevation = 1.dp) {
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)) {
Text(assignment.assignment.name, style = MaterialTheme.typography.headlineMedium)
Text("Deadline: ${assignment.assignment.deadline.format(fmt)}", Modifier.padding(top = 5.dp).clickable { updatingDeadline = true }, fontStyle = FontStyle.Italic)
Row {
Text("${assignment.assignment.type.display} using grading ", Modifier.align(Alignment.CenterVertically))
Surface(shape = MaterialTheme.shapes.small, tonalElevation = 10.dp) {
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"
})
}
}
}
Row {
Column(Modifier.weight(0.75f)) {
Row {
Text("Description:", style = MaterialTheme.typography.headlineSmall, 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 = MaterialTheme.typography.headlineSmall)
IconButton({ addingRubric = true }) {
Icon(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(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(tonalElevation = if(focus == idx) 15.dp else 0.dp, shape = MaterialTheme.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(tonalElevation = 5.dp, shape = MaterialTheme.shapes.extraLarge) {
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 = MaterialTheme.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 = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 10.dp))
Surface(shape = MaterialTheme.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(
tonalElevation = if (selectedCategory == idx) 15.dp else 0.dp,
shape = MaterialTheme.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(
tonalElevation = if (selectedNumeric == idx) 15.dp else 0.dp,
shape = MaterialTheme.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 = MaterialTheme.typography.headlineSmall, 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(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 +1,87 @@
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") }
}
}
}
//@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,100 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.EditionDetail
import com.jaytux.grader.data.v2.BaseAssignment
import com.jaytux.grader.data.v2.Student
import com.jaytux.grader.viewmodel.EditionVM
import com.jaytux.grader.viewmodel.Navigator
@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 = MaterialTheme.typography.headlineMedium)
Button({ adding = true }) {
Icon(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(UserIcon, "Students")
Spacer(Modifier.width(5.dp))
Text("Students")
}
@Composable
fun GroupsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
Icon(UserGroupIcon, "Groups")
Spacer(Modifier.width(5.dp))
Text("Groups")
}
@Composable
fun AssignmentsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
Icon(AssignmentIcon, "Assignments")
Spacer(Modifier.width(5.dp))
Text("Assignments")
}

View File

@@ -1,419 +1,409 @@
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, idx -> state.delete(s); if(id == idx) state.clearHistoryIndex() }
OpenPanel.Group -> GroupPanel(
course, edition, groups, id,
{ state.navTo(it) },
{ name -> state.newGroup(name) },
{ g, name -> state.setGroupName(g, name) }
) { g, idx -> state.delete(g); if(id == idx) state.clearHistoryIndex() }
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, idx -> state.delete(a); if(id == idx) state.clearHistoryIndex() }
}
}
}
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, Int) -> 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], 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, Int) -> 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], 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, Int) -> 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], deleting) }
) { if(deleting != -1) 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()
}
}
}
}
}
}
}
//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 scope = rememberCoroutineScope()
//
// var groupExporting by remember { mutableStateOf<GroupAssignmentState?>(null) }
// val groupPopup = rememberDirectoryPickerLauncher(directory = PlatformFile(Preferences.exportPath)) { path ->
// if(path != null) {
// groupExporting?.let {
// Preferences.exportPath = path.toKotlinxIoPath().toString()
// scope.launch { it.batchExport(path.toKotlinxIoPath()) }
// }
// }
// }
//
// 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, idx -> state.delete(s); if(id == idx) state.clearHistoryIndex() }
//
// OpenPanel.Group -> GroupPanel(
// course, edition, groups, id,
// { state.navTo(it) },
// { name -> state.newGroup(name) },
// { g, name -> state.setGroupName(g, name) }
// ) { g, idx -> state.delete(g); if(id == idx) state.clearHistoryIndex() }
//
// 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, idx -> state.delete(a); if(id == idx) state.clearHistoryIndex() }
// }
// }
// }
//
// 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) {
// groupExporting = GroupAssignmentState(a.assignment); groupPopup.launch()
// }
// 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, Int) -> 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], 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, Int) -> 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], 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, Int) -> 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], deleting) }
// ) { if(deleting != -1) 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,266 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.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.CategoricGrade
import com.jaytux.grader.data.v2.Criterion
import com.jaytux.grader.data.v2.GradeType
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.GroupsGradingVM
import com.jaytux.grader.viewmodel.Navigator
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import java.util.UUID
@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}", Modifier.weight(1f), style = MaterialTheme.typography.headlineMedium)
Text("Group assignment in ${vm.course.name} - ${vm.edition.name}")
Spacer(Modifier.height(5.dp))
Row(Modifier.fillMaxSize()) {
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
QuickAGroup(idx == focus, { vm.focusGroup(idx) }, it)
}
}
Surface(Modifier.weight(0.75f).fillMaxHeight(), tonalElevation = 1.dp) {
if (focus == -1 || selectedGroup == null) {
Box(Modifier.weight(0.75f).fillMaxHeight()) {
Text("Select a group to start grading.", Modifier.align(Alignment.Center))
}
} else {
Column(Modifier.weight(0.75f).padding(15.dp)) {
Row {
IconButton({ vm.focusPrev() }, Modifier.align(Alignment.CenterVertically), enabled = focus > 0) {
Icon(DoubleBack, "Previous group")
}
Spacer(Modifier.width(10.dp))
Text(selectedGroup.group.name, Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.weight(1f))
IconButton({ vm.focusNext() }, Modifier.align(Alignment.CenterVertically), enabled = focus < groups.size - 1) {
Icon(DoubleForward, "Next group")
}
}
Spacer(Modifier.height(10.dp))
val global by vm.globalGrade.entity
val byCriteria by vm.gradeList.entities
Surface(Modifier.fillMaxSize(), color = Color.White, shape = MaterialTheme.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) { 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,
(byCriteria ?: listOf()).flatMap { (_, it) ->
it.overrides.mapNotNull { o ->
o.second?.let { _ -> o.first.id.value }
}
}.toSet()
) {}
}
}
}
}
}
}
}
}
}
}
}
@Composable
fun QuickAGroup(isFocus: Boolean, onFocus: () -> Unit, group: GroupsGradingVM.GroupData) {
Surface(tonalElevation = if(isFocus) 15.dp else 0.dp, shape = MaterialTheme.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)
}
}
}
private fun gradeState(crit: GroupsGradingVM.CritData, current: Grade?): Grade = transaction {
if(current == null) Grade.default(crit.criterion.gradeType, crit.cat, crit.num)
when(crit.criterion.gradeType) {
GradeType.CATEGORIC ->
if(current is Grade.Categoric && current.grade.id == crit.criterion.categoricGrade?.id) current
else Grade.default(GradeType.CATEGORIC, crit.cat, crit.num)
GradeType.NUMERIC ->
if(current is Grade.Numeric && current.grade.id == crit.criterion.numericGrade?.id) current
else Grade.default(GradeType.NUMERIC, crit.cat, crit.num)
GradeType.PERCENTAGE ->
current as? Grade.Percentage ?: Grade.default(GradeType.PERCENTAGE, crit.cat, crit.num)
GradeType.NONE ->
current as? Grade.FreeText ?: Grade.default(GradeType.NONE, crit.cat, crit.num)
}
}
@Composable
fun GFWidget(crit: GroupsGradingVM.CritData, gr: Group, feedback: GroupsGradingVM.FeedbackData, vm: GroupsGradingVM, key: Any, isOpen: Boolean, markOverridden: Set<UUID> = setOf(), onToggle: () -> Unit) = Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
shadowElevation = 3.dp
) {
Column {
Surface(tonalElevation = 5.dp) {
Row(Modifier.fillMaxWidth().clickable { onToggle() }.padding(10.dp)) {
Icon(if(isOpen) ChevronDown else ChevronRight, "Toggle criterion detail grading", Modifier.align(Alignment.CenterVertically))
Spacer(Modifier.width(5.dp))
Text(crit.criterion.name, Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.width(5.dp))
feedback.groupLevel?.grade?.let {
Row(Modifier.align(Alignment.Bottom)) {
ProvideTextStyle(MaterialTheme.typography.bodySmall) {
Text("(Grade: ")
it.render()
Text(")")
}
}
}
}
}
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))
Button({ vm.modGroupFeedback(crit.criterion, gr, grade, text) }) {
Text("Save grade and feedback")
}
}
feedback.groupLevel?.let { groupLevel ->
Spacer(Modifier.width(10.dp))
Surface(Modifier.weight(0.5f).height(IntrinsicSize.Min), tonalElevation = 10.dp, shape = MaterialTheme.shapes.small) {
Column(Modifier.padding(10.dp)) {
Text("Individual overrides", style = MaterialTheme.typography.bodyLarge)
feedback.overrides.forEach { (student, it) ->
var enable by remember(key, it) { mutableStateOf(false) }
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 = MaterialTheme.typography.bodySmall, fontStyle = FontStyle.Italic, color = Color.Red)
}
}
if(enable) Row {
Spacer(Modifier.width(15.dp))
Surface(color = Color.White, shape = MaterialTheme.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))
Button({ 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,285 @@
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.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.foundation.lazy.itemsIndexed
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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 com.jaytux.grader.data.v2.Group
import com.jaytux.grader.data.v2.Student
import com.jaytux.grader.viewmodel.EditionVM
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
@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
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
QuickGroup(idx, it, vm)
}
}
Surface(Modifier.weight(0.75f).fillMaxHeight(), tonalElevation = 1.dp) {
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)) {
Text(group.group.name, style = MaterialTheme.typography.headlineMedium)
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 = MaterialTheme.shapes.medium, color = Color.White, shadowElevation = 1.dp) {
LazyColumn {
item {
Surface(tonalElevation = 15.dp) {
Row(Modifier.fillMaxWidth().padding(10.dp)) {
Text("Members", style = MaterialTheme.typography.headlineSmall, 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), tonalElevation = 5.dp, shape = MaterialTheme.shapes.small) {
Box(Modifier.clickable { swappingRole = -1 }.clickable { swappingRole = idx }) {
Text(role, Modifier.padding(horizontal = 5.dp, vertical = 2.dp), style = MaterialTheme.typography.labelMedium)
}
}
}
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(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 = MaterialTheme.typography.headlineSmall)
Surface(shape = MaterialTheme.shapes.medium, color = Color.White) {
LazyColumn {
item {
Surface(tonalElevation = 15.dp) {
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 = MaterialTheme.shapes.medium, color = Color.White, shadowElevation = 1.dp) {
LazyColumn {
item {
Surface(tonalElevation = 15.dp) {
Row(Modifier.fillMaxWidth().padding(10.dp)) {
Text("Available Students", style = MaterialTheme.typography.headlineSmall, 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(tonalElevation = if(focus == idx) 15.dp else 0.dp, shape = MaterialTheme.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(CirclePlus, "Add ${student.name} to group")
}
}
}

View File

@@ -0,0 +1,147 @@
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.material3.*
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
@Composable
fun HomeTitle() = Text("Grader")
@Composable
fun HomeView(token: Navigator.NavToken) {
val vm = viewModel<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 = MaterialTheme.typography.headlineMedium)
Button({ addingCourse = true }) {
Icon(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)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@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 = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 5.dp, modifier = Modifier.fillMaxWidth().padding(10.dp)) {
Column(Modifier.padding(8.dp)) {
Row {
Text(course.course.name, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f))
IconButton({ deleting = true }) { Icon(Delete, "Delete course") }
}
Row {
Text("Editions", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f))
Button({ addingEdition = true }) {
Icon(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 = MaterialTheme.typography.headlineSmall)
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 = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 5.dp, 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 = MaterialTheme.typography.headlineSmall)
Text(
"$type\n${edition.students.size} student(s) • ${edition.groups.size} group(s) • ${edition.assignments.size} assignment(s)",
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(Modifier.height(5.dp))
Row {
if(edition.edition.archived) {
Button({ vm.unarchiveEdition(edition.edition) }, Modifier.weight(0.5f)) {
Icon(Unarchive, "Unarchive edition")
Spacer(Modifier.width(5.dp))
Text("Unarchive edition")
}
}
else {
Button({ vm.archiveEdition(edition.edition) }, Modifier.weight(0.5f)) {
Icon(Archive, "Archive edition")
Spacer(Modifier.width(5.dp))
Text("Archive edition")
}
}
Spacer(Modifier.width(10.dp))
Button({ deleting = true }, Modifier.weight(0.5f)) {
Icon(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,20 @@
package com.jaytux.grader.ui
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.GroupGrading
import com.jaytux.grader.PeerEvalGrading
import com.jaytux.grader.viewmodel.GroupsGradingVM
import com.jaytux.grader.viewmodel.Navigator
import com.jaytux.grader.viewmodel.PeerEvalsGradingVM
@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)
}
}

View File

@@ -4,12 +4,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
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.rememberCoroutineScope
@@ -18,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
@@ -29,13 +24,14 @@ import com.jaytux.grader.loadClipboard
import com.jaytux.grader.toClipboard
import com.mohamedrejeb.richeditor.model.RichTextState
import com.mohamedrejeb.richeditor.ui.material.OutlinedRichTextEditor
import kotlinx.coroutines.launch
@Composable
fun RichTextStyleRow(
modifier: Modifier = Modifier,
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()
Row(modifier.fillMaxWidth()) {
@@ -53,7 +49,7 @@ fun RichTextStyleRow(
)
},
isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold,
icon = Icons.Outlined.FormatBold
icon = FormatBold
)
}
@@ -67,7 +63,7 @@ fun RichTextStyleRow(
)
},
isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic,
icon = Icons.Outlined.FormatItalic
icon = FormatItalic
)
}
@@ -81,7 +77,7 @@ fun RichTextStyleRow(
)
},
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true,
icon = Icons.Outlined.FormatUnderlined
icon = FormatUnderline
)
}
@@ -95,7 +91,7 @@ fun RichTextStyleRow(
)
},
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true,
icon = Icons.Outlined.FormatStrikethrough
icon = FormatStrikethrough
)
}
@@ -109,7 +105,7 @@ fun RichTextStyleRow(
)
},
isSelected = state.currentSpanStyle.fontSize == 28.sp,
icon = Icons.Outlined.FormatSize
icon = FormatSize
)
}
@@ -123,7 +119,7 @@ fun RichTextStyleRow(
)
},
isSelected = state.currentSpanStyle.color == Color.Red,
icon = Icons.Filled.Circle,
icon = CircleFilled,
tint = Color.Red
)
}
@@ -138,7 +134,7 @@ fun RichTextStyleRow(
)
},
isSelected = state.currentSpanStyle.background == Color.Yellow,
icon = Icons.Outlined.Circle,
icon = CircleOutline,
tint = Color.Yellow
)
}
@@ -158,7 +154,7 @@ fun RichTextStyleRow(
state.toggleUnorderedList()
},
isSelected = state.isUnorderedList,
icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
icon = FormatListBullet,
)
}
@@ -168,7 +164,7 @@ fun RichTextStyleRow(
state.toggleOrderedList()
},
isSelected = state.isOrderedList,
icon = Icons.Outlined.FormatListNumbered,
icon = FormatListNumber,
)
}
@@ -187,16 +183,16 @@ fun RichTextStyleRow(
state.toggleCodeSpan()
},
isSelected = state.isCodeSpan,
icon = Icons.Outlined.Code,
icon = FormatCode,
)
}
}
IconButton({ state.toClipboard(clip) }) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy markdown")
IconButton({ scope.launch { state.toClipboard(clip) } }) {
Icon(ContentCopy, contentDescription = "Copy markdown")
}
IconButton({ state.loadClipboard(clip, scope) }) {
Icon(Icons.Default.ContentPaste, contentDescription = "Paste markdown")
IconButton({ scope.launch { state.loadClipboard(clip, scope) } }) {
Icon(ContentPaste, contentDescription = "Paste markdown")
}
}
}

View File

@@ -0,0 +1,20 @@
package com.jaytux.grader.ui
import androidx.compose.material3.Text
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
@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,193 @@
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.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
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 com.jaytux.grader.data.v2.Edition
import com.jaytux.grader.data.v2.Student
import com.jaytux.grader.viewmodel.EditionVM
@Composable
fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
val students by vm.studentList.entities
val focus by vm.focusIndex
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
ListOrEmpty(students, { Text("No students yet.") }) { idx, it ->
QuickStudent(idx, it, vm)
}
}
Surface(Modifier.weight(0.75f).fillMaxHeight(), tonalElevation = 1.dp) {
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(), tonalElevation = 10.dp, shadowElevation = 2.dp, shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(10.dp)) {
Text(students[focus].name, style = MaterialTheme.typography.headlineSmall)
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(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(Check, "Confirm edit", Modifier.align(Alignment.CenterVertically).clickable {
vm.modStudent(students[focus], null, mod, null)
editing = false
})
Spacer(Modifier.width(5.dp))
Icon(Close, "Cancel edit", Modifier.align(Alignment.CenterVertically).clickable { editing = false })
}
}
Column {
Text("Groups:", style = MaterialTheme.typography.headlineSmall)
groups?.let { gList ->
if(gList.isEmpty()) null
else {
FlowRow(Modifier.padding(start = 10.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
gList.forEach { group ->
Surface(tonalElevation = 15.dp, shadowElevation = 1.dp, shape = MaterialTheme.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))
Button({ vm.modStudent(students[focus], null, null, mod) }) {
Text("Update note")
}
}
}
}
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(0.66f)) {
Text("Grade Summary: ", style = MaterialTheme.typography.headlineSmall)
Surface(shape = MaterialTheme.shapes.medium, color = Color.White) {
LazyColumn {
item {
Surface(tonalElevation = 15.dp) {
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(tonalElevation = if(focus == idx) 15.dp else 0.dp, shape = MaterialTheme.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

@@ -1,220 +1,200 @@
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()
}
}
}
}
}
//@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,45 +1,34 @@
package com.jaytux.grader.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.*
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.graphics.TransformOrigin
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.text.TextRange
import androidx.compose.ui.text.capitalize
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.intl.Locale
import androidx.compose.ui.unit.*
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import com.jaytux.grader.data.Course
import com.jaytux.grader.data.Edition
import com.jaytux.grader.viewmodel.PeerEvaluationState
import com.mohamedrejeb.richeditor.model.RichTextState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.jaytux.grader.maxN
import com.jaytux.grader.viewmodel.Grade
import kotlinx.datetime.*
import kotlinx.datetime.TimeZone
import java.util.*
import kotlin.time.toJavaInstant
@Composable
fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "Cancel", saveText: String = "Save", onSave: () -> Unit) {
@@ -50,37 +39,18 @@ fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "
}
}
@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
fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, current: String = "", onSave: (String) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
) {
val focus = remember { FocusRequester() }
Surface(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
var name by remember(current) { mutableStateOf(current) }
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) {
onSave(name)
onClose()
@@ -88,6 +58,8 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
}
}
}
LaunchedEffect(Unit) { focus.requestFocus() }
}
@Composable
@@ -118,299 +90,25 @@ fun ConfirmDeleteDialog(
fun <T> ListOrEmpty(
data: List<T>,
onEmpty: @Composable ColumnScope.() -> Unit,
addOptions: @Composable ColumnScope.() -> Unit,
addAfterLazy: Boolean = true,
modifier: Modifier = Modifier,
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
) {
if(data.isEmpty()) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center)) {
onEmpty()
addOptions()
Box(modifier) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center)) {
onEmpty()
}
}
}
}
else {
Column {
Column(modifier) {
LazyColumn(Modifier.weight(1f)) {
itemsIndexed(data) { 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__(
// state: RichTextState,
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")
}
}
}
@@ -438,19 +136,19 @@ fun FromTo(size: Dp) {
}
}
@Composable
fun PEGradeWidget(
grade: PeerEvaluationState.Student2StudentEntry?,
onSelect: () -> Unit, onDeselect: () -> Unit,
isSelected: Boolean,
modifier: Modifier = Modifier
) = Box(modifier.padding(2.dp)) {
Selectable(isSelected, onSelect, onDeselect) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(grade?.let { if(it.grade.isNotBlank()) it.grade else if(it.feedback.isNotBlank()) "(other)" else null } ?: "none")
}
}
}
//@Composable
//fun PEGradeWidget(
// grade: PeerEvaluationState.Student2StudentEntry?,
// onSelect: () -> Unit, onDeselect: () -> Unit,
// isSelected: Boolean,
// modifier: Modifier = Modifier
//) = Box(modifier.padding(2.dp)) {
// Selectable(isSelected, onSelect, onDeselect) {
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// Text(grade?.let { if(it.grade.isNotBlank()) it.grade else if(it.feedback.isNotBlank()) "(other)" else null } ?: "none")
// }
// }
//}
@Composable
fun VLine(width: Dp = 1.dp, color: Color = Color.Black) = Spacer(Modifier.fillMaxHeight().width(width).background(color))
@@ -459,4 +157,103 @@ fun VLine(width: Dp = 1.dp, color: Color = Color.Black) = Spacer(Modifier.fillMa
fun MeasuredLazyItemScope.HLine(height: Dp = 1.dp, color: Color = Color.Black) {
val width by measuredWidth()
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 }, 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()
}
}
}
}
}
@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 = MaterialTheme.typography.bodySmall)
}
}
}
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 = MaterialTheme.typography.bodySmall)
}
}
}
}
}
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)
Button({ 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
)
Button({ 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)
Button({ onUpdate(Grade.Percentage(perc.toDoubleOrNull() ?: 0.0)) }) {
Text("Save")
}
}
}
}

View File

@@ -3,821 +3,64 @@ package com.jaytux.grader.viewmodel
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import com.jaytux.grader.data.*
import com.jaytux.grader.data.EditionStudents.editionId
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
import org.jetbrains.exposed.v1.core.Transaction
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
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>)) {
private val rawEntities by lazy {
mutableStateOf(transaction { loader() })
}
val entities = rawEntities.immutable()
val entities = rawEntities.immutable()
fun refresh() {
rawEntities.value = transaction { loader() }
}
}
class CourseListState {
val courses = RawDbState { Course.all().sortAsc(Courses.name).toList() }
fun new(name: String) {
transaction { Course.new { this.name = name } }
courses.refresh()
class RawDbFocusableSingleState<TIn, TOut: Any>(private val loader: (Transaction.(TIn) -> TOut?)) {
private var _input: TIn? = null
private val rawEntity by lazy {
mutableStateOf<TOut?>(null)
}
fun delete(course: Course) {
transaction { course.delete() }
courses.refresh()
val entity: State<TOut?> = rawEntity.immutable()
fun focus(input: TIn) {
_input = input
rawEntity.value = transaction { loader(input) }
}
fun getEditions(course: Course) = EditionListState(course)
}
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 unfocus() {
_input = null
rawEntity.value = null
}
fun delete(edition: Edition) {
transaction { edition.delete() }
editions.refresh()
fun refresh() {
rawEntity.value = transaction { _input?.let { loader(it) } }
}
}
enum class OpenPanel(val tabName: String) {
Student("Students"), Group("Groups"), Assignment("Assignments")
}
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()
class RawDbFocusableState<TIn, TOut: Any>(private val loader: (Transaction.(TIn) -> List<TOut>)) {
private var _input: TIn? = null
private val rawEntities by lazy {
mutableStateOf<List<TOut>?>(null)
}
fun newStudent(name: String, contact: String, note: String, addToEdition: Boolean) {
transaction {
val student = Student.new { this.name = name; this.contact = contact; this.note = note }
if(addToEdition) EditionStudents.insert {
it[editionId] = edition.id
it[studentId] = student.id
}
}
val entities: State<List<TOut>?> = rawEntities.immutable()
if(addToEdition) students.refresh()
else availableStudents.refresh()
}
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 focus(input: TIn) {
_input = input
rawEntities.value = transaction { loader(input) }
}
fun newGroup(name: String) {
transaction {
Group.new { this.name = name; this.edition = this@EditionState.edition }
groups.refresh()
}
}
fun setGroupName(group: Group, name: String) {
transaction {
group.name = name
}
groups.refresh()
fun unfocus() {
_input = null
rawEntities.value = null
}
private fun now(): LocalDateTime {
val instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
return instant.toLocalDateTime(TimeZone.currentSystemDefault())
fun refresh() {
rawEntities.value = transaction { _input?.let { loader(it) } }
}
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 {
val assign = SoloAssignment.new {
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
this.number = nextIdx()
}
val global = SoloAssignmentCriterion.new {
this.name = "_global"; this.description = "[Global] Meta-criterion for $name"; this.assignment = assign
}
assign.globalCriterion = global
solo.refresh()
}
}
fun setSoloAssignmentTitle(assignment: SoloAssignment, title: String) {
transaction {
assignment.name = title
}
solo.refresh()
}
fun newGroupAssignment(name: String) {
transaction {
val assign = GroupAssignment.new {
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
this.number = nextIdx()
}
val global = GroupAssignmentCriterion.new {
this.name = "_global"; this.description = "[Global] Meta-criterion for $name"; this.assignment = assign
}
assign.globalCriterion = global
groupAs.refresh()
}
}
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
}
fun clearHistoryIndex() {
val last = _history.value.lastOrNull() ?: return
_history.value = _history.value.filter { (i, panel) -> panel != last.second || i != last.first } + (-1 to last.second)
}
}
class StudentState(val student: Student, edition: Edition) {
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.id eq GroupAssignments.globalCriterion)
}.map { it[GroupAssignments.id] to it }
val asIndividual = (GroupAssignments innerJoin GroupAssignmentCriteria innerJoin IndividualFeedbacks innerJoin Groups).selectAll().where {
(IndividualFeedbacks.studentId eq student.id) and
(GroupAssignmentCriteria.id eq GroupAssignments.globalCriterion)
}.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).filter { it.id != assignment.globalCriterion.id }
}
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
}.orderBy(GroupAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.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 = GroupAssignmentCriterion[row[GroupFeedbacks.criterionId]]
val fdbk = row[GroupFeedbacks.feedback]
val grade = row[GroupFeedbacks.grade]
crit to FeedbackEntry(fdbk, grade)
}
val global = forGroup.firstOrNull { it.first.id == assignment.globalCriterion.id }?.second
val byCrit_ = forGroup
.filter{ it.first.id != assignment.globalCriterion.id }
.map { LocalCriterionFeedback(it.first, it.second) }
.associateBy { it.criterion.id }
val byCrit = allCrit.map { c ->
byCrit_[c.id] ?: LocalCriterionFeedback(c, null)
}
val byGroup = LocalFeedback(global, byCrit)
val indiv = group.studentRoles.map {
val student = it.student
val role = it.role
val forSt = (IndividualFeedbacks innerJoin Groups)
.selectAll().where {
(IndividualFeedbacks.assignmentId eq assignment.id) and
(IndividualFeedbacks.studentId eq student.id) and (Groups.id eq group.id)
}.map { row ->
val stdId = row[IndividualFeedbacks.studentId]
val crit = GroupAssignmentCriterion[row[IndividualFeedbacks.criterionId]]
val fdbk = row[IndividualFeedbacks.feedback]
val grade = row[IndividualFeedbacks.grade]
crit to FeedbackEntry(fdbk, grade)
}
val global = forSt.firstOrNull { it.first.id == assignment.globalCriterion.id }?.second
val byCrit_ = forSt
.filter { it.first != assignment.globalCriterion.id }
.map { LocalCriterionFeedback(it.first, it.second) }
.associateBy { it.criterion.id }
val byCrit = allCrit.map { c ->
byCrit_[c.id] ?: LocalCriterionFeedback(c, null)
}
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 ?: assignment.globalCriterion.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 ?: assignment.globalCriterion.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).filter { it.id != assignment.globalCriterion.id }
}
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>> {3
val allCrit = SoloAssignmentCriterion.find {
SoloAssignmentCriteria.assignmentId eq assignment.id
}.orderBy(SoloAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.id }
return editionCourse.second.soloStudents.sortAsc(Students.name).map { student ->
val forStudent = (IndividualFeedbacks innerJoin Students).selectAll().where {
(IndividualFeedbacks.assignmentId eq assignment.id) and (Students.id eq student.id)
}.map { row ->
val crit = SoloAssignmentCriterion[row[IndividualFeedbacks.criterionId]]
val fdbk = row[IndividualFeedbacks.feedback]
val grade = row[IndividualFeedbacks.grade]
crit to LocalFeedback(fdbk, grade)
}
val global = forStudent.firstOrNull { it.first == assignment.globalCriterion.id }?.second
val byCrit_ = forStudent
.filter { it.first != assignment.globalCriterion.id }
.map { Pair(it.first, it.second) }
.associateBy { it.first.id }
val byCrit = allCrit.map { c ->
byCrit_[c.id] ?: Pair(c, null)
}
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 ?: assignment.globalCriterion.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,419 @@
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, when(c.gradeType) {
GradeType.CATEGORIC -> UiGradeType.Categoric(c.categoricGrade!!.options.toList(), c.categoricGrade!!)
GradeType.NUMERIC -> UiGradeType.Numeric(c.numericGrade!!)
GradeType.PERCENTAGE -> UiGradeType.Percentage
GradeType.NONE -> UiGradeType.FreeText
})
}
}
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 = null // asg.globalCriterion.feedbacks.find { it.forStudentsOverrideIfGroup.any { over -> over.student == st } } // TODO
val gr = (solo ?: asGroup)?.let { Grade.fromAssignment(asg.globalCriterion, it) }
gr to asGroup?.asGroupFeedback app (solo != null)
}
AssignmentType.SOLO -> {
val gr = asg.globalCriterion.feedbacks.find { it.asSoloFeedback == st }
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
gr to null app false
}
AssignmentType.PEER_EVALUATION -> {
val asGroup = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id in groupIds }
val solo = asg.globalCriterion.feedbacks.find { it.forStudentsOverrideIfPeer.any { over -> over.student == st } }
val gr = (solo ?: asGroup)?.let { Grade.fromAssignment(asg.globalCriterion, it) }
gr to asGroup?.asPeerEvaluationFeedback app (solo != null)
}
}
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
}
}
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 -> {}
}
}
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() {
focus(assignmentList.entities.value.size)
assignmentList.refresh()
}
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)
PeerEvaluation.new { this.base = asg }
}
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 rmAssignment(assignment: BaseAssignment) {
transaction {
assignment.delete()
(assignment.asPeerEvaluation ?: assignment.asGroupAssignment ?: assignment.asSoloAssignment)?.delete()
}
unfocus()
assignmentList.refresh()
}
}

View File

@@ -0,0 +1,57 @@
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()
data class Percentage(val percentage: Double) : Grade()
data class Numeric(val value: Double, val grade: NumericGrade) : Grade()
data class Categoric(val value: CategoricGradeOption, val options: List<CategoricGradeOption>, val grade: CategoricGrade) : Grade()
@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,174 @@
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 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 FeedbackItem(val base: BaseFeedback, val grade: Grade, val feedback: String) {
companion object {
context(trns: Transaction)
fun fromDb(f: BaseFeedback): FeedbackItem = when(f.criterion.gradeType) {
GradeType.CATEGORIC -> {
val categoric = f.criterion.categoricGrade!!
val options = categoric.options.toList()
Grade.Categoric(f.gradeCategoric ?: options.first(), options, categoric)
}
GradeType.NUMERIC -> Grade.Numeric(f.gradeNumeric ?: 0.0, f.criterion.numericGrade!!)
GradeType.PERCENTAGE -> Grade.Percentage(f.gradeNumeric ?: 0.0)
GradeType.NONE -> Grade.FreeText(f.gradeFreeText ?: "")
}.let { FeedbackItem(f, it, f.feedback) }
}
}
data class FeedbackData(val groupLevel: FeedbackItem?, val overrides: List<Pair<Student, FeedbackItem?>>)
data class CritData(val criterion: Criterion, val cat: CategoricGrade?, val num: NumericGrade?) {
companion object {
context(trns: Transaction)
fun fromDb(c: Criterion) = CritData(c, c.categoricGrade, c.numericGrade)
}
}
private val _focus = mutableStateOf(-1)
val focus = _focus.immutable()
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) }
// }
return listOf() // TODO!!!
}
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,152 @@
package com.jaytux.grader.viewmodel
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
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 kotlin.reflect.KClass
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(ExperimentalMaterial3Api::class, 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.Launcher(state)
BackHandler { back() }
Scaffold(
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
title = { render.header(top.dest) },
navigationIcon = {
IconButton({ back() }, enabled = top != _start) {
Icon(ChevronLeft, contentDescription = "Back")
}
}
)
},
snackbarHost = {
SnackbarHost(state)
}
) { insets ->
Surface(Modifier.padding(insets), color = MaterialTheme.colorScheme.surface) {
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,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 PeerEvalsGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
}

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,20 @@
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()
}

View File

@@ -1,33 +1,38 @@
[versions]
androidx-lifecycle = "2.8.4"
compose-multiplatform = "1.8.1"
androidx-lifecycle = "2.9.6"
compose-multiplatform = "1.9.0"
junit = "4.13.2"
kotlin = "2.1.0"
kotlin = "2.3.0"
kotlinx-coroutines = "1.10.1"
exposed = "0.59.0"
material3 = "1.7.3"
exposed = "1.1.1"
material3 = "1.9.0"
ui-android = "1.7.8"
foundation-layout-android = "1.7.8"
rtf = "1.0.0-rc11"
filekit = "0.10.0-beta04"
directories = "26"
androidx-activity-compose = "1.12.2"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
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-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" }
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-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" }
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }
exposed-migration = { group = "org.jetbrains.exposed", name = "exposed-migration", version.ref = "exposed" }
exposed-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" }
sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.34.0" }
sl4j = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.12" }
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" }
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-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" }
@@ -35,6 +40,7 @@ filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.re
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" }
[plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

12
gradlew vendored
View File

@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# 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
' "$PWD" ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# 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
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -206,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# 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.
# * 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.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

3
gradlew.bat vendored
View File

@@ -70,11 +70,10 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@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
@rem End local scope for the variables with windows NT shell