Compare commits
5 Commits
eca161b251
...
feat/jewel
| Author | SHA1 | Date | |
|---|---|---|---|
|
28c3b29c3a
|
|||
|
18a7a82c36
|
|||
|
bdc56748dd
|
|||
|
52ff467d9c
|
|||
|
8786cc6072
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,4 +18,4 @@ captures
|
|||||||
!*.xcworkspace/contents.xcworkspacedata
|
!*.xcworkspace/contents.xcworkspacedata
|
||||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
**/grader.db
|
**/grader.db
|
||||||
**/*.backup
|
**/*.backup
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvm("desktop")
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xcontext-parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
jvm("desktop") {}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
val desktopMain by getting
|
val desktopMain by getting
|
||||||
|
|
||||||
commonMain.dependencies {
|
desktopMain.dependencies {
|
||||||
|
implementation(compose.desktop.currentOs) {
|
||||||
|
exclude(group = "org.jetbrains.compose.material")
|
||||||
|
}
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
implementation(compose.material)
|
implementation(compose.material)
|
||||||
@@ -22,16 +29,13 @@ kotlin {
|
|||||||
implementation(libs.androidx.lifecycle.viewmodel)
|
implementation(libs.androidx.lifecycle.viewmodel)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.material3.core)
|
implementation(libs.material3.core)
|
||||||
implementation(libs.material.icons)
|
|
||||||
implementation(libs.sl4j)
|
implementation(libs.sl4j)
|
||||||
}
|
|
||||||
desktopMain.dependencies {
|
|
||||||
implementation(compose.desktop.currentOs)
|
|
||||||
implementation(libs.kotlinx.coroutines.swing)
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
implementation(libs.exposed.core)
|
implementation(libs.exposed.core)
|
||||||
implementation(libs.exposed.jdbc)
|
implementation(libs.exposed.jdbc)
|
||||||
implementation(libs.exposed.dao)
|
implementation(libs.exposed.dao)
|
||||||
implementation(libs.exposed.migration)
|
implementation(libs.exposed.migration)
|
||||||
|
implementation(libs.exposed.migration.jdbc)
|
||||||
implementation(libs.exposed.kotlin.datetime)
|
implementation(libs.exposed.kotlin.datetime)
|
||||||
implementation(libs.sqlite)
|
implementation(libs.sqlite)
|
||||||
implementation(libs.material3.desktop)
|
implementation(libs.material3.desktop)
|
||||||
@@ -40,6 +44,11 @@ kotlin {
|
|||||||
implementation(libs.filekit.dialogs)
|
implementation(libs.filekit.dialogs)
|
||||||
implementation(libs.filekit.dialogs.compose)
|
implementation(libs.filekit.dialogs.compose)
|
||||||
implementation(libs.filekit.coil)
|
implementation(libs.filekit.coil)
|
||||||
|
implementation(libs.directories)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.compose.backhandler)
|
||||||
|
implementation(libs.jewel)
|
||||||
|
implementation(libs.jewel.windows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +61,7 @@ compose.desktop {
|
|||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||||
packageName = "com.jaytux.grader"
|
packageName = "com.jaytux.grader"
|
||||||
|
mainClass = "com.jaytux.grader.MainKt"
|
||||||
packageVersion = "1.0.0"
|
packageVersion = "1.0.0"
|
||||||
includeAllModules = true
|
includeAllModules = true
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,39 @@
|
|||||||
package com.jaytux.grader
|
package com.jaytux.grader
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import com.jaytux.grader.data.v2.BaseAssignment
|
||||||
import androidx.compose.ui.Modifier
|
import com.jaytux.grader.data.v2.Course
|
||||||
import androidx.compose.ui.unit.dp
|
import com.jaytux.grader.data.v2.Edition
|
||||||
import com.jaytux.grader.ui.ChevronLeft
|
import com.jaytux.grader.ui.EditionTitle
|
||||||
import com.jaytux.grader.ui.CoursesView
|
import com.jaytux.grader.ui.EditionView
|
||||||
import com.jaytux.grader.ui.toDp
|
import com.jaytux.grader.ui.GroupsGradingTitle
|
||||||
import com.jaytux.grader.viewmodel.CourseListState
|
import com.jaytux.grader.ui.GroupsGradingView
|
||||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
import com.jaytux.grader.ui.HomeTitle
|
||||||
|
import com.jaytux.grader.ui.HomeView
|
||||||
|
import com.jaytux.grader.ui.PeerEvalsGradingTitle
|
||||||
|
import com.jaytux.grader.ui.PeerEvalsGradingView
|
||||||
|
import com.jaytux.grader.ui.SolosGradingTitle
|
||||||
|
import com.jaytux.grader.ui.SolosGradingView
|
||||||
|
import com.jaytux.grader.ui.Surface
|
||||||
|
import com.jaytux.grader.viewmodel.Navigator
|
||||||
|
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
|
||||||
|
|
||||||
data class UiRoute(val heading: String, val content: @Composable (push: (UiRoute) -> Unit) -> Unit)
|
object Home : Navigator.IDestination
|
||||||
|
data class EditionDetail(val ed: Edition, val course: Course) : Navigator.IDestination
|
||||||
|
data class GroupGrading(val course: Course, val edition: Edition, val assignment: BaseAssignment) : Navigator.IDestination
|
||||||
|
data class SoloGrading(val course: Course, val edition: Edition, val assignment: BaseAssignment) : Navigator.IDestination
|
||||||
|
data class PeerEvalGrading(val course: Course, val edition: Edition, val assignment: BaseAssignment) : Navigator.IDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
|
||||||
fun App() {
|
fun App() {
|
||||||
MaterialTheme {
|
IntUiTheme(isDark = true) {
|
||||||
val courseList = CourseListState()
|
Surface {
|
||||||
var stack by remember {
|
Navigator.NavHost(Home) {
|
||||||
val start = UiRoute("Courses Overview") { CoursesView(courseList, it) }
|
composable<Home>({ HomeTitle() }) { _, token -> HomeView(token) }
|
||||||
mutableStateOf(listOf(start))
|
composable<EditionDetail>({ EditionTitle(it) }) { data, token -> EditionView(data, token) }
|
||||||
}
|
composable<GroupGrading>({ GroupsGradingTitle(it) }) { data, token -> GroupsGradingView(data, token) }
|
||||||
|
composable<SoloGrading>({ SolosGradingTitle(it) }) { data, token -> SolosGradingView(data, token) }
|
||||||
Column {
|
composable<PeerEvalGrading>({ PeerEvalsGradingTitle(it) }) { data, token -> PeerEvalsGradingView(data, token) }
|
||||||
Surface(Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primary, tonalElevation = 10.dp, shadowElevation = 10.dp) {
|
|
||||||
Row(Modifier.padding(10.dp)) {
|
|
||||||
IconButton({ stack = stack.toMutableList().also { it.removeLast() } }, enabled = stack.size >= 2) {
|
|
||||||
Icon(ChevronLeft, "Back", Modifier.size(MaterialTheme.typography.headlineLarge.fontSize.toDp()))
|
|
||||||
}
|
|
||||||
Text(stack.last().heading, Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.headlineLarge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Surface(Modifier.fillMaxSize()) {
|
|
||||||
Box {
|
|
||||||
stack.last().content { stack += (it) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import androidx.compose.ui.text.AnnotatedString
|
|||||||
import com.mohamedrejeb.richeditor.model.RichTextState
|
import com.mohamedrejeb.richeditor.model.RichTextState
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.awt.Desktop
|
||||||
|
import java.net.URI
|
||||||
|
import java.util.prefs.Preferences
|
||||||
|
|
||||||
fun String.maxN(n: Int): String {
|
fun String.maxN(n: Int): String {
|
||||||
return if (this.length > n) {
|
return if (this.length > n) {
|
||||||
@@ -14,10 +17,35 @@ fun String.maxN(n: Int): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun RichTextState.toClipboard(clip: ClipboardManager) {
|
suspend fun RichTextState.toClipboard(clip: ClipboardManager) {
|
||||||
clip.setText(AnnotatedString(this.toMarkdown()))
|
clip.setText(AnnotatedString(this.toMarkdown()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) {
|
suspend fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) {
|
||||||
scope.launch { setMarkdown(clip.getText()?.text ?: "") }
|
scope.launch { setMarkdown(clip.getText()?.text ?: "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
object Preferences {
|
||||||
|
private val _p = Preferences.userNodeForPackage(this::class.java)
|
||||||
|
|
||||||
|
operator fun get(key: String): String? = _p.get(key, null)
|
||||||
|
operator fun set(key: String, value: String) {
|
||||||
|
_p.put(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
var exportPath
|
||||||
|
get() = get("exportPath") ?: System.getProperty("user.home") + "/grader_export"
|
||||||
|
set(value) { set("exportPath", value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
infix fun <T1, T2, T3> Pair<T1, T2>.app(x: T3) = Triple(first, second, x)
|
||||||
|
|
||||||
|
fun startEmail(recipients: List<String>, onError: (String) -> Unit) {
|
||||||
|
if(Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.MAIL)) {
|
||||||
|
val mailTo = "mailto:${recipients.joinToString(",")}"
|
||||||
|
Desktop.getDesktop().mail(URI(mailTo))
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onError("Email client is not supported on this platform.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,42 +1,64 @@
|
|||||||
package com.jaytux.grader.data
|
package com.jaytux.grader.data
|
||||||
|
|
||||||
import MigrationUtils
|
import com.jaytux.grader.app
|
||||||
import org.jetbrains.exposed.sql.Database
|
import com.jaytux.grader.data.v2.CategoricGrade
|
||||||
import org.jetbrains.exposed.sql.SchemaUtils
|
import com.jaytux.grader.data.v2.CategoricGradeOption
|
||||||
import org.jetbrains.exposed.sql.and
|
import com.jaytux.grader.data.v2.CategoricGradeOptions
|
||||||
import org.jetbrains.exposed.sql.selectAll
|
import com.jaytux.grader.data.v2.CategoricGrades
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import com.jaytux.grader.data.v2.Courses
|
||||||
import org.jetbrains.exposed.sql.update
|
import com.jaytux.grader.data.v2.NumericGrade
|
||||||
|
import com.jaytux.grader.data.v2.v2Tables
|
||||||
|
import dev.dirs.ProjectDirectories
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import kotlin.getValue
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
import kotlin.io.path.createDirectories
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.Database
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.batchInsert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||||
|
|
||||||
object Database {
|
object Database {
|
||||||
|
val dataDir: String = ProjectDirectories.from("com", "jaytux", "grader").dataDir.also {
|
||||||
|
val path = Path(it)
|
||||||
|
if(!path.exists()) path.createDirectories()
|
||||||
|
}
|
||||||
val db by lazy {
|
val db by lazy {
|
||||||
val actual = Database.connect("jdbc:sqlite:file:./grader.db", "org.sqlite.JDBC")
|
val actual = Database.connect("jdbc:sqlite:file:${dataDir}/grader.db", "org.sqlite.JDBC")
|
||||||
transaction {
|
transaction(actual) {
|
||||||
SchemaUtils.create(
|
SchemaUtils.create(*v2Tables)
|
||||||
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 --- ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual
|
actual
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init() { db }
|
fun init() {
|
||||||
|
TransactionManager.defaultDatabase = db
|
||||||
|
transaction {
|
||||||
|
if(CategoricGrade.count() == 0L) {
|
||||||
|
val (pf, af) = CategoricGrades.batchInsert(listOf("Pass/Fail", "Default A-F"), shouldReturnGeneratedValues = true) {
|
||||||
|
this[CategoricGrades.name] = it
|
||||||
|
}.map {
|
||||||
|
it[CategoricGrades.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoricGradeOptions.batchInsert(
|
||||||
|
listOf("Pass", "Fail").mapIndexed { idx, it -> it to pf app idx } +
|
||||||
|
listOf("A (Excellent)", "B (Good)", "C (Satisfactory)", "D (Poor)", "F (Fail)").mapIndexed { idx, it -> it to af app idx }
|
||||||
|
) {
|
||||||
|
this[CategoricGradeOptions.option] = it.first
|
||||||
|
this[CategoricGradeOptions.gradeId] = it.second
|
||||||
|
this[CategoricGradeOptions.index] = it.third
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(NumericGrade.count() == 0L) {
|
||||||
|
NumericGrade.new {
|
||||||
|
name = "Max-20"
|
||||||
|
max = 20.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,90 +1,118 @@
|
|||||||
package com.jaytux.grader.data
|
package com.jaytux.grader.data
|
||||||
|
|
||||||
import com.jaytux.grader.viewmodel.Assignment
|
//class MdBuilder {
|
||||||
import com.jaytux.grader.viewmodel.GroupAssignmentState
|
// private val content = StringBuilder()
|
||||||
import io.github.vinceglb.filekit.PlatformFile
|
//
|
||||||
|
// fun appendHeader(text: String, level: Int = 1) {
|
||||||
class MdBuilder {
|
// require(level in 1..6) { "Header level must be between 1 and 6" }
|
||||||
private val content = StringBuilder()
|
// content.appendLine()
|
||||||
|
// content.appendLine("#".repeat(level) + " $text")
|
||||||
fun appendHeader(text: String, level: Int = 1) {
|
// content.appendLine()
|
||||||
require(level in 1..6) { "Header level must be between 1 and 6" }
|
// }
|
||||||
content.appendLine()
|
// fun appendMd(text: String) { content.appendLine(text) }
|
||||||
content.appendLine("#".repeat(level) + " $text")
|
// fun appendParagraph(text: String, bold: Boolean = false, italic: Boolean = false) {
|
||||||
content.appendLine()
|
// val formattedText = buildString {
|
||||||
}
|
// if (bold) append("**")
|
||||||
fun appendMd(text: String) { content.appendLine(text) }
|
// if (italic) append("_")
|
||||||
fun appendParagraph(text: String, bold: Boolean = false, italic: Boolean = false) {
|
// append(text)
|
||||||
val formattedText = buildString {
|
// if (italic) append("_")
|
||||||
if (bold) append("**")
|
// if (bold) append("**")
|
||||||
if (italic) append("_")
|
// }
|
||||||
append(text)
|
// content.appendLine(formattedText)
|
||||||
if (italic) append("_")
|
// content.appendLine()
|
||||||
if (bold) append("**")
|
// }
|
||||||
}
|
//
|
||||||
content.appendLine(formattedText)
|
// fun build(title: String, scheme: String = "dark"): String = "${prologue(title, scheme)}\n\n${content.toString()}"
|
||||||
content.appendLine()
|
// private fun prologue(title: String, scheme: String = "dark") = """
|
||||||
}
|
// ---
|
||||||
|
// title: $title
|
||||||
fun build(): String = content.toString()
|
// date: ${Clock.System.now()}
|
||||||
}
|
// header-includes:
|
||||||
|
// - '<link rel="stylesheet" href="https://classless.de/classless-tiny.css" media="(prefers-color-scheme: $scheme)" />'
|
||||||
fun GroupAssignmentState.LocalGFeedback.exportTo(path: PlatformFile, assignment: GroupAssignment) {
|
// - '<link rel="stylesheet" href="https://classless.de/addons/themes.css" media="(prefers-color-scheme: light)" />'
|
||||||
val builder = MdBuilder()
|
// ---
|
||||||
builder.appendHeader("${assignment.name} Feedback for ${group.name}")
|
// """.trimIndent()
|
||||||
if(feedback.global != null && feedback.global.grade.isNotBlank()) {
|
//}
|
||||||
val global = feedback.global.grade
|
//
|
||||||
builder.appendParagraph("Overall grade: ${feedback.global.grade}", true, true)
|
//object Exporter {
|
||||||
|
// private fun MdBuilder.appendGroupFeedback(assignment: GroupAssignment, it: GroupAssignmentState.LocalGFeedback) {
|
||||||
individuals.forEach { (student, it) ->
|
// appendHeader("${assignment.name} (group: ${it.group.name})", 1)
|
||||||
val (_, data) = it
|
// if (it.feedback.global != null && it.feedback.global.grade.isNotBlank()) {
|
||||||
if(data.global != null && data.global.grade.isNotBlank() && data.global.grade != global) {
|
// val global = it.feedback.global.grade
|
||||||
builder.appendParagraph("${student.name} grade: ${data.global.grade}", true, true)
|
// 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) {
|
||||||
fun appendFeedback(heading: String, group: GroupAssignmentState.FeedbackEntry?, byStudent: List<Pair<Student, GroupAssignmentState.FeedbackEntry>>) {
|
// appendParagraph("${student.name} grade: ${data.global.grade}", true, true)
|
||||||
if(group != null || byStudent.isNotEmpty()) {
|
// }
|
||||||
builder.appendHeader(heading, 2)
|
// }
|
||||||
if(group != null) {
|
// }
|
||||||
if(group.grade.isNotBlank()) {
|
//
|
||||||
builder.appendParagraph("Group grade: ${group.grade}", true, true)
|
// fun appendFeedback(
|
||||||
}
|
// heading: String,
|
||||||
if(group.feedback.isNotBlank()) {
|
// group: GroupAssignmentState.FeedbackEntry?,
|
||||||
builder.appendMd(group.feedback)
|
// byStudent: List<Pair<Student, GroupAssignmentState.FeedbackEntry>>
|
||||||
}
|
// ) {
|
||||||
}
|
// if (group != null || byStudent.isNotEmpty()) {
|
||||||
|
// appendHeader(heading, 2)
|
||||||
byStudent.forEach { (student, it) ->
|
// if (group != null) {
|
||||||
if(it.grade.isNotBlank() || it.feedback.isNotBlank()) builder.appendHeader(student.name, 3)
|
// if (group.grade.isNotBlank()) {
|
||||||
if(it.grade.isNotBlank()) {
|
// appendParagraph("Group grade: ${group.grade}", true, true)
|
||||||
builder.appendParagraph("Grade: ${it.grade}", true, true)
|
// }
|
||||||
}
|
// if (group.feedback.isNotBlank()) {
|
||||||
if(it.feedback.isNotBlank()) {
|
// appendMd(group.feedback)
|
||||||
builder.appendMd(it.feedback)
|
// }
|
||||||
}
|
// }
|
||||||
}
|
//
|
||||||
}
|
// byStudent.forEach { (student, it) ->
|
||||||
}
|
// if (it.grade.isNotBlank() || it.feedback.isNotBlank()) appendHeader(student.name, 3)
|
||||||
|
// if (it.grade.isNotBlank()) {
|
||||||
appendFeedback("Overall Feedback", feedback.global,
|
// appendParagraph("Grade: ${it.grade}", true, true)
|
||||||
individuals.mapNotNull { it.second.second.global?.let { g -> it.first to g } }
|
// }
|
||||||
)
|
// if (it.feedback.isNotBlank()) {
|
||||||
|
// appendMd(it.feedback)
|
||||||
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,
|
// appendFeedback(
|
||||||
feedback.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry,
|
// "Overall Feedback", it.feedback.global,
|
||||||
individuals.mapNotNull { (student, it) ->
|
// it.individuals.mapNotNull { ind -> ind.second.second.global?.let { g -> ind.first to g } }
|
||||||
val entry = it.second.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry
|
// )
|
||||||
entry?.let { student to it }
|
//
|
||||||
}
|
// 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 ->
|
||||||
path.file.writeText(builder.build())
|
// 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)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.jaytux.grader.data.v2
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable
|
||||||
|
import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable
|
||||||
|
import org.jetbrains.exposed.v1.datetime.datetime
|
||||||
|
|
||||||
|
object Courses : UUIDTable("courses") {
|
||||||
|
val name = varchar("name", 50).uniqueIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
object Editions : UUIDTable("editions") {
|
||||||
|
val courseId = reference("course_id", Courses.id)
|
||||||
|
val name = varchar("name", 50)
|
||||||
|
val archived = bool("archived").default(false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
uniqueIndex(courseId, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Groups : UUIDTable("groups") {
|
||||||
|
val editionId = reference("edition_id", Editions.id)
|
||||||
|
val name = varchar("name", 50)
|
||||||
|
|
||||||
|
init {
|
||||||
|
uniqueIndex(editionId, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Students : UUIDTable("students") {
|
||||||
|
val name = varchar("name", 50)
|
||||||
|
val contact = varchar("contact", 50)
|
||||||
|
val note = text("note")
|
||||||
|
}
|
||||||
|
|
||||||
|
object GroupStudents : UUIDTable("grpStudents") {
|
||||||
|
val groupId = reference("group_id", Groups.id)
|
||||||
|
val studentId = reference("student_id", Students.id)
|
||||||
|
val role = varchar("role", 50).nullable()
|
||||||
|
|
||||||
|
init {
|
||||||
|
uniqueIndex(groupId, studentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object EditionStudents : CompositeIdTable("editionStudents") {
|
||||||
|
val editionId = reference("edition_id", Editions.id)
|
||||||
|
val studentId = reference("student_id", Students.id)
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(editionId, studentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
object BaseAssignments : UUIDTable("baseAssgmts") {
|
||||||
|
val editionId = reference("edition_id", Editions.id)
|
||||||
|
val name = varchar("name", 50)
|
||||||
|
val assignment = text("assignment")
|
||||||
|
val globalCriterion = reference("global_crit", Criteria.id)
|
||||||
|
val deadline = datetime("deadline")
|
||||||
|
val number = integer("number").nullable()
|
||||||
|
val type = enumerationByName("type", 20, AssignmentType::class)
|
||||||
|
}
|
||||||
|
|
||||||
|
object Criteria : UUIDTable("criteria") {
|
||||||
|
val assignmentId = reference("assignment_id", BaseAssignments.id)
|
||||||
|
val name = varchar("name", 50)
|
||||||
|
val desc = text("desc")
|
||||||
|
val gradeType = enumerationByName("grade_type", 20, GradeType::class)
|
||||||
|
val categoricGrade = reference("categoric_grade_id", CategoricGrades.id).nullable()
|
||||||
|
val numericGrade = reference("numeric_grade_id", NumericGrades.id).nullable()
|
||||||
|
}
|
||||||
|
|
||||||
|
object GroupAssignments : UUIDTable("grpAssgmts") {
|
||||||
|
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
object SoloAssignments : UUIDTable("soloAssgmts") {
|
||||||
|
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
object BaseFeedbacks : UUIDTable("baseFeedbacks") {
|
||||||
|
val criterionId = reference("criterion_id", Criteria.id)
|
||||||
|
val feedback = text("feedback")
|
||||||
|
val gradeFreeText = varchar("grade_text", 32).nullable()
|
||||||
|
val gradeCategoric = reference("grade_categoric", CategoricGradeOptions.id).nullable()
|
||||||
|
val gradeNumeric = double("grade_numeric").nullable()
|
||||||
|
}
|
||||||
|
|
||||||
|
object GroupFeedbacks : CompositeIdTable("grpFdbks") {
|
||||||
|
val groupId = reference("group_id", Groups.id)
|
||||||
|
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(groupId, feedbackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
object StudentOverrideFeedbacks : UUIDTable("studOvrFdbks") {
|
||||||
|
val groupId = reference("group_id", Groups.id)
|
||||||
|
val studentId = reference("student_id", Students.id)
|
||||||
|
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||||
|
val overrides = reference("overrides", BaseFeedbacks.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
object SoloFeedbacks : CompositeIdTable("soloFdbks") {
|
||||||
|
val studentId = reference("student_id", Students.id)
|
||||||
|
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(studentId, feedbackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
object PeerEvaluations : UUIDTable("peerEvals") {
|
||||||
|
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
|
||||||
|
val studentCriterion = reference("student_crit", Criteria.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
object PeerEvaluationFeedbacks : CompositeIdTable("peerEvalFdbks") {
|
||||||
|
val studentId = reference("student_id", Students.id)
|
||||||
|
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(studentId, feedbackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
object PeerEvaluationS2GEvaluations : UUIDTable("peerEvalS2GEvals") {
|
||||||
|
val peerEvalId = reference("peer_eval_id", PeerEvaluations.id)
|
||||||
|
val studentId = reference("student_id", Students.id)
|
||||||
|
val groupId = reference("group_id", Groups.id)
|
||||||
|
val evaluationId = reference("evaluation_id", BaseFeedbacks.id)
|
||||||
|
|
||||||
|
init {
|
||||||
|
uniqueIndex(peerEvalId, groupId, studentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object PeerEvaluationS2SEvaluations : UUIDTable("peerEvalS2SEvals") {
|
||||||
|
val peerEvalId = reference("peer_eval_id", PeerEvaluations.id)
|
||||||
|
val studentId = reference("student_id", Students.id)
|
||||||
|
val evaluatedStudentId = reference("evaluated_student_id", Students.id)
|
||||||
|
val evaluationId = reference("evaluation_id", BaseFeedbacks.id)
|
||||||
|
|
||||||
|
init {
|
||||||
|
uniqueIndex(peerEvalId, studentId, evaluatedStudentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object CategoricGrades : UUIDTable("categoricGrades") {
|
||||||
|
val name = varchar("name", 50).uniqueIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
object CategoricGradeOptions : UUIDTable("categoricGradeOpts") {
|
||||||
|
val gradeId = reference("grade_id", CategoricGrades.id)
|
||||||
|
val option = varchar("option", 50)
|
||||||
|
val index = integer("index")
|
||||||
|
|
||||||
|
init {
|
||||||
|
uniqueIndex(gradeId, option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object NumericGrades : UUIDTable("numericGrades") {
|
||||||
|
val name = varchar("name", 50).uniqueIndex()
|
||||||
|
val max = double("max")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class GradeType {
|
||||||
|
CATEGORIC, NUMERIC, PERCENTAGE, NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AssignmentType(val display: String) {
|
||||||
|
GROUP("Group Assignment"), SOLO("Individual Assignment"), PEER_EVALUATION("Peer Evaluation")
|
||||||
|
}
|
||||||
|
|
||||||
|
val v2Tables = arrayOf(
|
||||||
|
Courses, Editions, Groups, Students, GroupStudents, EditionStudents, BaseAssignments, Criteria, GroupAssignments,
|
||||||
|
SoloAssignments, BaseFeedbacks, GroupFeedbacks, StudentOverrideFeedbacks, SoloFeedbacks, PeerEvaluations,
|
||||||
|
PeerEvaluationFeedbacks, PeerEvaluationS2GEvaluations, PeerEvaluationS2SEvaluations, CategoricGrades,
|
||||||
|
CategoricGradeOptions, NumericGrades
|
||||||
|
)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.jaytux.grader.data.v2
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.v1.dao.Entity
|
||||||
|
import org.jetbrains.exposed.v1.dao.EntityClass
|
||||||
|
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||||
|
import org.jetbrains.exposed.v1.dao.java.UUIDEntity
|
||||||
|
import org.jetbrains.exposed.v1.dao.java.UUIDEntityClass
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class Course(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : UUIDEntityClass<Course>(Courses)
|
||||||
|
|
||||||
|
var name by Courses.name
|
||||||
|
|
||||||
|
val editions by Edition referrersOn Editions.courseId orderBy Editions.name
|
||||||
|
}
|
||||||
|
|
||||||
|
class Edition(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, Edition>(Editions)
|
||||||
|
|
||||||
|
var course by Course referencedOn Editions.courseId
|
||||||
|
var name by Editions.name
|
||||||
|
var archived by Editions.archived
|
||||||
|
|
||||||
|
val students by Student via EditionStudents orderBy Students.name
|
||||||
|
val groups by Group referrersOn Groups.editionId orderBy Groups.name
|
||||||
|
val assignments by BaseAssignment referrersOn BaseAssignments.editionId orderBy BaseAssignments.number
|
||||||
|
}
|
||||||
|
|
||||||
|
class Group(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, Group>(Groups)
|
||||||
|
|
||||||
|
var edition by Edition referencedOn Groups.editionId
|
||||||
|
var name by Groups.name
|
||||||
|
|
||||||
|
val students by GroupStudent referrersOn GroupStudents.groupId
|
||||||
|
val feedbacks by BaseFeedback via GroupFeedbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
class Student(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, Student>(Students)
|
||||||
|
|
||||||
|
var name by Students.name
|
||||||
|
var note by Students.note
|
||||||
|
var contact by Students.contact
|
||||||
|
|
||||||
|
val editions by Edition via EditionStudents orderBy Editions.name
|
||||||
|
val groups by GroupStudent referrersOn GroupStudents.studentId
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupStudent(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, GroupStudent>(GroupStudents)
|
||||||
|
|
||||||
|
var student by Student referencedOn GroupStudents.studentId
|
||||||
|
var group by Group referencedOn GroupStudents.groupId
|
||||||
|
var role by GroupStudents.role
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseAssignment(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, BaseAssignment>(BaseAssignments)
|
||||||
|
|
||||||
|
var name by BaseAssignments.name
|
||||||
|
var assignment by BaseAssignments.assignment
|
||||||
|
var globalCriterion by Criterion referencedOn BaseAssignments.globalCriterion
|
||||||
|
var deadline by BaseAssignments.deadline
|
||||||
|
var number by BaseAssignments.number
|
||||||
|
var edition by Edition referencedOn BaseAssignments.editionId
|
||||||
|
var type by BaseAssignments.type
|
||||||
|
|
||||||
|
private val _asGroupAssignment by GroupAssignment referrersOn GroupAssignments.baseAssignmentId
|
||||||
|
private val _asSoloAssignment by SoloAssignment referrersOn SoloAssignments.baseAssignmentId
|
||||||
|
private val _asPeerEvaluation by PeerEvaluation referrersOn PeerEvaluations.baseAssignmentId
|
||||||
|
val asGroupAssignment get() = _asGroupAssignment.singleOrNull()
|
||||||
|
val asSoloAssignment get() = _asSoloAssignment.singleOrNull()
|
||||||
|
val asPeerEvaluation get() = _asPeerEvaluation.singleOrNull()
|
||||||
|
|
||||||
|
val criteria by Criterion referrersOn Criteria.assignmentId orderBy Criteria.name
|
||||||
|
val nonBaseCriteria get() = criteria.filterNot { it.id.value == globalCriterion.id.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupAssignment(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, GroupAssignment>(GroupAssignments)
|
||||||
|
|
||||||
|
var base by BaseAssignment referencedOn GroupAssignments.baseAssignmentId
|
||||||
|
}
|
||||||
|
|
||||||
|
class SoloAssignment(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, SoloAssignment>(SoloAssignments)
|
||||||
|
|
||||||
|
var base by BaseAssignment referencedOn SoloAssignments.baseAssignmentId
|
||||||
|
}
|
||||||
|
|
||||||
|
class PeerEvaluation(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, PeerEvaluation>(PeerEvaluations)
|
||||||
|
|
||||||
|
var base by BaseAssignment referencedOn PeerEvaluations.baseAssignmentId
|
||||||
|
var studentCriterion by Criterion referencedOn PeerEvaluations.studentCriterion
|
||||||
|
}
|
||||||
|
|
||||||
|
class CategoricGrade(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, CategoricGrade>(CategoricGrades)
|
||||||
|
|
||||||
|
var name by CategoricGrades.name
|
||||||
|
|
||||||
|
val options by CategoricGradeOption referrersOn CategoricGradeOptions.gradeId orderBy CategoricGradeOptions.index
|
||||||
|
}
|
||||||
|
|
||||||
|
class CategoricGradeOption(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, CategoricGradeOption>(CategoricGradeOptions)
|
||||||
|
|
||||||
|
var grade by CategoricGrade referencedOn CategoricGradeOptions.gradeId
|
||||||
|
var option by CategoricGradeOptions.option
|
||||||
|
var index by CategoricGradeOptions.index
|
||||||
|
}
|
||||||
|
|
||||||
|
class NumericGrade(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, NumericGrade>(NumericGrades)
|
||||||
|
|
||||||
|
var name by NumericGrades.name
|
||||||
|
var max by NumericGrades.max
|
||||||
|
}
|
||||||
|
|
||||||
|
class Criterion(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, Criterion>(Criteria)
|
||||||
|
|
||||||
|
var assignment by BaseAssignment referencedOn Criteria.assignmentId
|
||||||
|
var name by Criteria.name
|
||||||
|
var desc by Criteria.desc
|
||||||
|
var gradeType by Criteria.gradeType
|
||||||
|
var categoricGrade by CategoricGrade optionalReferencedOn Criteria.categoricGrade
|
||||||
|
var numericGrade by NumericGrade optionalReferencedOn Criteria.numericGrade
|
||||||
|
|
||||||
|
val feedbacks by BaseFeedback referrersOn BaseFeedbacks.criterionId
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, BaseFeedback>(BaseFeedbacks)
|
||||||
|
|
||||||
|
var criterion by Criterion referencedOn BaseFeedbacks.criterionId
|
||||||
|
var feedback by BaseFeedbacks.feedback
|
||||||
|
var gradeFreeText by BaseFeedbacks.gradeFreeText
|
||||||
|
var gradeCategoric by CategoricGradeOption optionalReferencedOn BaseFeedbacks.gradeCategoric
|
||||||
|
var gradeNumeric by BaseFeedbacks.gradeNumeric
|
||||||
|
|
||||||
|
private val _forStudentIfSolo by Student via SoloFeedbacks
|
||||||
|
private val _forGroupIfGroup by Group via GroupFeedbacks
|
||||||
|
private val _forStudentIfPeer by Student via PeerEvaluationFeedbacks
|
||||||
|
|
||||||
|
val asSoloFeedback get() = _forStudentIfSolo.singleOrNull()
|
||||||
|
val asGroupFeedback get() = _forGroupIfGroup.singleOrNull()
|
||||||
|
val asPeerEvaluationFeedback get() = _forStudentIfPeer.singleOrNull()
|
||||||
|
|
||||||
|
val forStudentsOverrideIfGroup by StudentOverrideFeedback referrersOn StudentOverrideFeedbacks.overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
class StudentOverrideFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, StudentOverrideFeedback>(StudentOverrideFeedbacks)
|
||||||
|
|
||||||
|
var group by Group referencedOn StudentOverrideFeedbacks.groupId
|
||||||
|
var student by Student referencedOn StudentOverrideFeedbacks.studentId
|
||||||
|
var feedback by BaseFeedback referencedOn StudentOverrideFeedbacks.feedbackId
|
||||||
|
var overrides by BaseFeedback referencedOn StudentOverrideFeedbacks.overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
class PeerEvaluationS2G(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, PeerEvaluationS2G>(PeerEvaluationS2GEvaluations)
|
||||||
|
|
||||||
|
var peerEvaluation by PeerEvaluation referencedOn PeerEvaluationS2GEvaluations.peerEvalId
|
||||||
|
var student by Student referencedOn PeerEvaluationS2GEvaluations.studentId
|
||||||
|
var group by Group referencedOn PeerEvaluationS2GEvaluations.groupId
|
||||||
|
var evaluation by BaseFeedback referencedOn PeerEvaluationS2GEvaluations.evaluationId
|
||||||
|
}
|
||||||
|
|
||||||
|
class PeerEvaluationS2S(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||||
|
companion object : EntityClass<UUID, PeerEvaluationS2S>(PeerEvaluationS2SEvaluations)
|
||||||
|
|
||||||
|
var peerEvaluation by PeerEvaluation referencedOn PeerEvaluationS2SEvaluations.peerEvalId
|
||||||
|
var student by Student referencedOn PeerEvaluationS2SEvaluations.studentId
|
||||||
|
var evaluatedStudent by Student referencedOn PeerEvaluationS2SEvaluations.evaluatedStudentId
|
||||||
|
var evaluation by BaseFeedback referencedOn PeerEvaluationS2SEvaluations.evaluationId
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -2,7 +2,6 @@ package com.jaytux.grader
|
|||||||
|
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import com.jaytux.grader.App
|
|
||||||
import com.jaytux.grader.data.Database
|
import com.jaytux.grader.data.Database
|
||||||
import io.github.vinceglb.filekit.FileKit
|
import io.github.vinceglb.filekit.FileKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,721 +0,0 @@
|
|||||||
package com.jaytux.grader.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.jaytux.grader.data.GroupAssignmentCriterion
|
|
||||||
import com.jaytux.grader.data.SoloAssignmentCriterion
|
|
||||||
import com.jaytux.grader.data.Student
|
|
||||||
import com.jaytux.grader.data.exportTo
|
|
||||||
import com.jaytux.grader.maxN
|
|
||||||
import com.jaytux.grader.viewmodel.GroupAssignmentState
|
|
||||||
import com.jaytux.grader.viewmodel.PeerEvaluationState
|
|
||||||
import com.jaytux.grader.viewmodel.SoloAssignmentState
|
|
||||||
import com.mohamedrejeb.richeditor.model.rememberRichTextState
|
|
||||||
import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor
|
|
||||||
import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupAssignmentView(state: GroupAssignmentState) {
|
|
||||||
val task by state.task
|
|
||||||
val deadline by state.deadline
|
|
||||||
val allFeedback by state.feedback.entities
|
|
||||||
val criteria by state.criteria.entities
|
|
||||||
|
|
||||||
var idx by remember(state) { mutableStateOf(0) }
|
|
||||||
|
|
||||||
Column(Modifier.padding(10.dp)) {
|
|
||||||
if(allFeedback.any { it.second.feedback == null }) {
|
|
||||||
Text("Groups in bold have no feedback yet.", fontStyle = FontStyle.Italic)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Text("All groups have feedback.", fontStyle = FontStyle.Italic)
|
|
||||||
}
|
|
||||||
|
|
||||||
TabRow(idx) {
|
|
||||||
Tab(idx == 0, { idx = 0 }) { Text("Task and Criteria") }
|
|
||||||
allFeedback.forEachIndexed { i, it ->
|
|
||||||
val (group, feedback) = it
|
|
||||||
Tab(idx == i + 1, { idx = i + 1 }) {
|
|
||||||
Text(group.name, fontWeight = feedback.feedback?.let { FontWeight.Normal } ?: FontWeight.Bold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(idx == 0) {
|
|
||||||
groupTaskWidget(
|
|
||||||
task, deadline, criteria,
|
|
||||||
onSetTask = { state.updateTask(it) },
|
|
||||||
onSetDeadline = { state.updateDeadline(it) },
|
|
||||||
onAddCriterion = { state.addCriterion(it) },
|
|
||||||
onModCriterion = { c, n, d -> state.updateCriterion(c, n, d) },
|
|
||||||
onRmCriterion = { state.deleteCriterion(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
groupFeedback(state, allFeedback[idx - 1].second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun groupTaskWidget(
|
|
||||||
taskMD: String,
|
|
||||||
deadline: LocalDateTime,
|
|
||||||
criteria: List<GroupAssignmentCriterion>,
|
|
||||||
onSetTask: (String) -> Unit,
|
|
||||||
onSetDeadline: (LocalDateTime) -> Unit,
|
|
||||||
onAddCriterion: (name: String) -> Unit,
|
|
||||||
onModCriterion: (cr: GroupAssignmentCriterion, name: String, desc: String) -> Unit,
|
|
||||||
onRmCriterion: (cr: GroupAssignmentCriterion) -> Unit
|
|
||||||
) {
|
|
||||||
var critIdx by remember { mutableStateOf(0) }
|
|
||||||
var adding by remember { mutableStateOf(false) }
|
|
||||||
var confirming by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) {
|
|
||||||
Column(Modifier.padding(10.dp)) {
|
|
||||||
LazyColumn(Modifier.weight(1f)) {
|
|
||||||
item {
|
|
||||||
Surface(
|
|
||||||
Modifier.fillMaxWidth().clickable { critIdx = 0 },
|
|
||||||
tonalElevation = if (critIdx == 0) 50.dp else 0.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Text("Assignment", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsIndexed(criteria) { i, crit ->
|
|
||||||
Surface(
|
|
||||||
Modifier.fillMaxWidth().clickable { critIdx = i + 1 },
|
|
||||||
tonalElevation = if (critIdx == i + 1) 50.dp else 0.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Text(crit.name, Modifier.padding(5.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button({ adding = true }, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) {
|
|
||||||
Text("Add evaluation criterion")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(Modifier.weight(0.75f).padding(10.dp)) {
|
|
||||||
if (critIdx == 0) {
|
|
||||||
val updTask = rememberRichTextState()
|
|
||||||
|
|
||||||
LaunchedEffect(taskMD) { updTask.setMarkdown(taskMD) }
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Row {
|
|
||||||
DateTimePicker(deadline, onSetDeadline)
|
|
||||||
}
|
|
||||||
RichTextField(updTask, Modifier.fillMaxWidth().weight(1f)) { Text("Task") }
|
|
||||||
CancelSaveRow(
|
|
||||||
true,
|
|
||||||
{ updTask.setMarkdown(taskMD) },
|
|
||||||
"Reset",
|
|
||||||
"Update"
|
|
||||||
) { onSetTask(updTask.toMarkdown()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
val crit = criteria[critIdx - 1]
|
|
||||||
var name by remember(crit) { mutableStateOf(crit.name) }
|
|
||||||
var desc by remember(crit) { mutableStateOf(crit.description) }
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Row {
|
|
||||||
OutlinedTextField(name, { name = it }, Modifier.weight(0.8f))
|
|
||||||
Spacer(Modifier.weight(0.1f))
|
|
||||||
Button({ onModCriterion(crit, name, desc) }, Modifier.weight(0.1f)) {
|
|
||||||
Text("Update")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OutlinedTextField(
|
|
||||||
desc, { desc = it }, Modifier.fillMaxWidth().weight(1f),
|
|
||||||
label = { Text("Description") },
|
|
||||||
singleLine = false,
|
|
||||||
minLines = 5
|
|
||||||
)
|
|
||||||
Button({ confirming = true }, Modifier.fillMaxWidth()) {
|
|
||||||
Text("Remove criterion")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(adding) {
|
|
||||||
AddStringDialog(
|
|
||||||
"Evaluation criterion name", criteria.map{ it.name }, { adding = false }
|
|
||||||
) { onAddCriterion(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if(confirming && critIdx != 0) {
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
"an evaluation criterion",
|
|
||||||
{ confirming = false }, { onRmCriterion(criteria[critIdx - 1]); critIdx = 0 }
|
|
||||||
) {
|
|
||||||
Text(criteria[critIdx - 1].name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalGFeedback) {
|
|
||||||
val (group, feedback, individual) = fdbk
|
|
||||||
var studentIdx by remember(fdbk) { mutableStateOf(0) }
|
|
||||||
var critIdx by remember(fdbk) { mutableStateOf(0) }
|
|
||||||
val criteria by state.criteria.entities
|
|
||||||
val suggestions by state.autofill.entities
|
|
||||||
val exporting by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val onSave = { grade: String, fdbk: String ->
|
|
||||||
when {
|
|
||||||
studentIdx == 0 && critIdx == 0 -> state.upsertGroupFeedback(group, fdbk, grade)
|
|
||||||
studentIdx == 0 && critIdx != 0 -> state.upsertGroupFeedback(group, fdbk, grade, criteria[critIdx - 1])
|
|
||||||
studentIdx != 0 && critIdx == 0 -> state.upsertIndividualFeedback(individual[studentIdx - 1].first, group, fdbk, grade)
|
|
||||||
else -> state.upsertIndividualFeedback(individual[studentIdx - 1].first, group, fdbk, grade, criteria[critIdx - 1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val exporter = rememberFileSaverLauncher { file ->
|
|
||||||
file?.let {
|
|
||||||
scope.launch { fdbk.exportTo(it, state.assignment) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val critGrade: (Int) -> String? = { crit: Int ->
|
|
||||||
when {
|
|
||||||
studentIdx == 0 && crit == 0 -> feedback.global?.grade?.ifBlank { null }
|
|
||||||
studentIdx == 0 && crit != 0 -> feedback.byCriterion[crit - 1].entry?.grade?.ifBlank { null }
|
|
||||||
studentIdx != 0 && crit == 0 -> individual[studentIdx - 1].second.second.global?.grade?.ifBlank { null }
|
|
||||||
else -> individual[studentIdx - 1].second.second.byCriterion[crit - 1].entry?.grade?.ifBlank { null }
|
|
||||||
}.also { println("Mapping criterion #${crit} to grade ${it}") }
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) {
|
|
||||||
Column(Modifier.padding(10.dp)) {
|
|
||||||
LazyColumn(Modifier.weight(1f)) {
|
|
||||||
item {
|
|
||||||
Surface(
|
|
||||||
Modifier.fillMaxWidth().clickable { studentIdx = 0 },
|
|
||||||
tonalElevation = if (studentIdx == 0) 50.dp else 0.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Text("Group feedback", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsIndexed(individual.toList()) { i, (student, details) ->
|
|
||||||
val (role, _) = details
|
|
||||||
Surface(
|
|
||||||
Modifier.fillMaxWidth().clickable { studentIdx = i + 1 },
|
|
||||||
tonalElevation = if (studentIdx == i + 1) 50.dp else 0.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Text("${student.name} (${role ?: "no role"})", Modifier.padding(5.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
{ exporter.launch("${state.assignment.name} (${fdbk.group.name})", "md") },
|
|
||||||
Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text("Export group feedback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.weight(0.75f).padding(10.dp)) {
|
|
||||||
TabRow(critIdx) {
|
|
||||||
Tab(critIdx == 0, { critIdx = 0 }) {
|
|
||||||
Text(
|
|
||||||
"General feedback",
|
|
||||||
Modifier.padding(5.dp),
|
|
||||||
fontStyle = FontStyle.Italic
|
|
||||||
)
|
|
||||||
}
|
|
||||||
criteria.forEachIndexed { i, c ->
|
|
||||||
Tab(critIdx == i + 1, { critIdx = i + 1 }) {
|
|
||||||
Text(
|
|
||||||
c.name,
|
|
||||||
Modifier.padding(5.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(5.dp))
|
|
||||||
|
|
||||||
groupFeedbackPane(
|
|
||||||
criteria, critIdx, { critIdx = it },
|
|
||||||
when {
|
|
||||||
studentIdx == 0 && critIdx == 0 -> feedback.global
|
|
||||||
studentIdx == 0 && critIdx != 0 -> feedback.byCriterion[critIdx - 1].entry
|
|
||||||
studentIdx != 0 && critIdx == 0 -> individual[studentIdx - 1].second.second.global
|
|
||||||
else -> individual[studentIdx - 1].second.second.byCriterion[critIdx - 1].entry
|
|
||||||
},
|
|
||||||
suggestions, onSave,
|
|
||||||
if(critIdx == 0 && criteria.isNotEmpty()) criteria.mapIndexed { idx, it -> it.name to critGrade(idx + 1) } else null,
|
|
||||||
key = studentIdx to critIdx
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun groupFeedbackPane(
|
|
||||||
criteria: List<GroupAssignmentCriterion>,
|
|
||||||
currentCriterion: Int,
|
|
||||||
onSelectCriterion: (Int) -> Unit,
|
|
||||||
rawFeedback: GroupAssignmentState.FeedbackEntry?,
|
|
||||||
autofill: List<String>,
|
|
||||||
onSave: (String, String) -> Unit,
|
|
||||||
critGrades: List<Pair<String, String?>>? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
key: Any? = null
|
|
||||||
) {
|
|
||||||
var grade by remember(rawFeedback, key) { mutableStateOf(rawFeedback?.grade ?: "") }
|
|
||||||
val feedback = rememberRichTextState()
|
|
||||||
|
|
||||||
LaunchedEffect(currentCriterion, criteria, rawFeedback, key) {
|
|
||||||
feedback.setMarkdown(rawFeedback?.feedback ?: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier) {
|
|
||||||
Row {
|
|
||||||
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
|
|
||||||
OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f))
|
|
||||||
Spacer(Modifier.weight(0.6f))
|
|
||||||
Button(
|
|
||||||
{ onSave(grade, feedback.toMarkdown()) },
|
|
||||||
Modifier.weight(0.2f).align(Alignment.CenterVertically)
|
|
||||||
) {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(5.dp))
|
|
||||||
Row {
|
|
||||||
RichTextField(feedback, outerModifier = Modifier.weight(0.7f).fillMaxHeight()) { Text("Feedback") }
|
|
||||||
critGrades?.let { grades ->
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
LazyColumn(Modifier.weight(0.3f)) {
|
|
||||||
item {
|
|
||||||
Text("Criteria grades", Modifier.padding(5.dp), style = MaterialTheme.typography.headlineMedium)
|
|
||||||
}
|
|
||||||
items(grades) { (crit, grade) ->
|
|
||||||
Column {
|
|
||||||
Text(crit, Modifier.padding(5.dp), fontWeight = FontWeight.Bold)
|
|
||||||
Row {
|
|
||||||
Spacer(Modifier.width(5.dp))
|
|
||||||
if(grade == null) Text("(no grade yet)", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
|
|
||||||
else Text(grade, Modifier.padding(5.dp))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SoloAssignmentView(state: SoloAssignmentState) {
|
|
||||||
val task by state.task
|
|
||||||
val deadline by state.deadline
|
|
||||||
val suggestions by state.autofill.entities
|
|
||||||
val grades by state.feedback.entities
|
|
||||||
val criteria by state.criteria.entities
|
|
||||||
|
|
||||||
var tab by remember(state) { mutableStateOf(0) }
|
|
||||||
var idx by remember(state, tab) { mutableStateOf(0) }
|
|
||||||
var critIdx by remember(state, tab, idx) { mutableStateOf(0) }
|
|
||||||
var adding by remember(state, tab) { mutableStateOf(false) }
|
|
||||||
var confirming by remember(state, tab) { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val updateGrade = { grade: String ->
|
|
||||||
state.upsertFeedback(
|
|
||||||
grades[idx].first,
|
|
||||||
if(critIdx == 0) grades[idx].second.global?.feedback else grades[idx].second.byCriterion[critIdx - 1].second?.feedback,
|
|
||||||
grade,
|
|
||||||
if(critIdx == 0) null else criteria[critIdx - 1]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val updateFeedback = { feedback: String ->
|
|
||||||
state.upsertFeedback(
|
|
||||||
grades[idx].first,
|
|
||||||
feedback,
|
|
||||||
if(critIdx == 0) grades[idx].second.global?.grade else grades[idx].second.byCriterion[critIdx - 1].second?.grade,
|
|
||||||
if(critIdx == 0) null else criteria[critIdx - 1]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.padding(10.dp)) {
|
|
||||||
Row {
|
|
||||||
Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) {
|
|
||||||
Column(Modifier.padding(10.dp)) {
|
|
||||||
TabRow(tab) {
|
|
||||||
Tab(tab == 0, { tab = 0 }) { Text("Task/Criteria") }
|
|
||||||
Tab(tab == 1, { tab = 1 }) { Text("Students") }
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(Modifier.weight(1f)) {
|
|
||||||
if (tab == 0) {
|
|
||||||
item {
|
|
||||||
Surface(
|
|
||||||
Modifier.fillMaxWidth().clickable { idx = 0 },
|
|
||||||
tonalElevation = if (idx == 0) 50.dp else 0.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Text("Assignment", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsIndexed(criteria) { i, crit ->
|
|
||||||
Surface(
|
|
||||||
Modifier.fillMaxWidth().clickable { idx = i + 1 },
|
|
||||||
tonalElevation = if (idx == i + 1) 50.dp else 0.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Text(crit.name, Modifier.padding(5.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
itemsIndexed(grades.toList()) { i, (student, _) ->
|
|
||||||
Surface(
|
|
||||||
Modifier.fillMaxWidth().clickable { idx = i },
|
|
||||||
tonalElevation = if (idx == i) 50.dp else 0.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Text(student.name, Modifier.padding(5.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab == 0) {
|
|
||||||
Button({ adding = true }, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) {
|
|
||||||
Text("Add evaluation criterion")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.weight(0.75f).padding(10.dp)) {
|
|
||||||
if(tab == 0) {
|
|
||||||
if (idx == 0) {
|
|
||||||
val updTask = rememberRichTextState()
|
|
||||||
|
|
||||||
LaunchedEffect(task) { updTask.setMarkdown(task) }
|
|
||||||
|
|
||||||
Row {
|
|
||||||
DateTimePicker(deadline, { state.updateDeadline(it) })
|
|
||||||
}
|
|
||||||
RichTextStyleRow(state = updTask)
|
|
||||||
OutlinedRichTextEditor(
|
|
||||||
state = updTask,
|
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
|
||||||
singleLine = false,
|
|
||||||
minLines = 5,
|
|
||||||
label = { Text("Task") }
|
|
||||||
)
|
|
||||||
CancelSaveRow(
|
|
||||||
true,
|
|
||||||
{ updTask.setMarkdown(task) },
|
|
||||||
"Reset",
|
|
||||||
"Update"
|
|
||||||
) { state.updateTask(updTask.toMarkdown()) }
|
|
||||||
} else {
|
|
||||||
val crit = criteria[idx - 1]
|
|
||||||
var name by remember(crit) { mutableStateOf(crit.name) }
|
|
||||||
var desc by remember(crit) { mutableStateOf(crit.description) }
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Row {
|
|
||||||
OutlinedTextField(name, { name = it }, Modifier.weight(0.8f))
|
|
||||||
Spacer(Modifier.weight(0.1f))
|
|
||||||
Button({ state.updateCriterion(crit, name, desc) }, Modifier.weight(0.1f)) {
|
|
||||||
Text("Update")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OutlinedTextField(
|
|
||||||
desc, { desc = it }, Modifier.fillMaxWidth().weight(1f),
|
|
||||||
label = { Text("Description") },
|
|
||||||
singleLine = false,
|
|
||||||
minLines = 5
|
|
||||||
)
|
|
||||||
Button({ confirming = true }, Modifier.fillMaxWidth()) {
|
|
||||||
Text("Remove criterion")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
soloFeedbackPane(
|
|
||||||
criteria, critIdx, { critIdx = it }, grades[idx].second.global,
|
|
||||||
if(critIdx == 0) grades[idx].second.global else grades[idx].second.byCriterion[critIdx - 1].second,
|
|
||||||
suggestions, updateGrade, updateFeedback,
|
|
||||||
key = tab to idx
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(adding) {
|
|
||||||
AddStringDialog(
|
|
||||||
"Evaluation criterion name", criteria.map{ it.name }, { adding = false }
|
|
||||||
) { state.addCriterion(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if(confirming && idx != 0) {
|
|
||||||
ConfirmDeleteDialog(
|
|
||||||
"an evaluation criterion",
|
|
||||||
{ confirming = false }, { state.deleteCriterion(criteria[idx - 1]); idx = 0 }
|
|
||||||
) {
|
|
||||||
Text(criteria[idx - 1].name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun soloFeedbackPane(
|
|
||||||
criteria: List<SoloAssignmentCriterion>,
|
|
||||||
currentCriterion: Int,
|
|
||||||
onSelectCriterion: (Int) -> Unit,
|
|
||||||
globFeedback: SoloAssignmentState.LocalFeedback?,
|
|
||||||
criterionFeedback: SoloAssignmentState.LocalFeedback?,
|
|
||||||
autofill: List<String>,
|
|
||||||
onSetGrade: (String) -> Unit,
|
|
||||||
onSetFeedback: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
key: Any? = null
|
|
||||||
) {
|
|
||||||
var grade by remember(globFeedback, key) { mutableStateOf(globFeedback?.grade ?: "") }
|
|
||||||
val feedback = rememberRichTextState()
|
|
||||||
|
|
||||||
LaunchedEffect(currentCriterion, criteria, criterionFeedback, key) {
|
|
||||||
feedback.setMarkdown(criterionFeedback?.feedback ?: "")
|
|
||||||
}
|
|
||||||
Column(modifier) {
|
|
||||||
Row {
|
|
||||||
Text("Overall grade: ", Modifier.align(Alignment.CenterVertically))
|
|
||||||
OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f))
|
|
||||||
Spacer(Modifier.weight(0.6f))
|
|
||||||
Button(
|
|
||||||
{ onSetGrade(grade); onSetFeedback(feedback.toMarkdown()) },
|
|
||||||
Modifier.weight(0.2f).align(Alignment.CenterVertically)
|
|
||||||
) {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TabRow(currentCriterion) {
|
|
||||||
Tab(currentCriterion == 0, { onSelectCriterion(0) }) { Text("General feedback", fontStyle = FontStyle.Italic) }
|
|
||||||
criteria.forEachIndexed { i, c ->
|
|
||||||
Tab(currentCriterion == i + 1, { onSelectCriterion(i + 1) }) { Text(c.name) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(5.dp))
|
|
||||||
RichTextField(feedback, Modifier.fillMaxWidth().weight(1f)) { Text("Feedback") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PeerEvaluationView(state: PeerEvaluationState) {
|
|
||||||
val contents by state.contents.entities
|
|
||||||
var idx by remember(state) { mutableStateOf(0) }
|
|
||||||
var editing by remember(state) { mutableStateOf<Triple<Student, Student?, PeerEvaluationState.Student2StudentEntry?>?>(null) }
|
|
||||||
val measure = rememberTextMeasurer()
|
|
||||||
|
|
||||||
val isSelected = { from: Student, to: Student? ->
|
|
||||||
editing?.let { (f, t, _) -> f == from && t == to } ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.padding(10.dp)) {
|
|
||||||
TabRow(idx) {
|
|
||||||
contents.forEachIndexed { i, it ->
|
|
||||||
Tab(idx == i, { idx = i; editing = null }) { Text(it.group.name) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(10.dp))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
val current = contents[idx]
|
|
||||||
val horScroll = rememberLazyListState()
|
|
||||||
val style = LocalTextStyle.current
|
|
||||||
val textLenMeasured = remember(state, idx) {
|
|
||||||
current.students.maxOf { (s, _) ->
|
|
||||||
measure.measure(s.name, style).size.width
|
|
||||||
} + 10
|
|
||||||
}
|
|
||||||
val cellSize = 75.dp
|
|
||||||
|
|
||||||
Column(Modifier.weight(0.5f)) {
|
|
||||||
Row {
|
|
||||||
Box { FromTo(textLenMeasured.dp) }
|
|
||||||
LazyRow(Modifier.height(textLenMeasured.dp), state = horScroll) {
|
|
||||||
item { VLine() }
|
|
||||||
items(current.students) { (s, _) ->
|
|
||||||
Box(
|
|
||||||
Modifier.width(cellSize).height(textLenMeasured.dp),
|
|
||||||
contentAlignment = Alignment.TopCenter
|
|
||||||
) {
|
|
||||||
var _h: Int = 0
|
|
||||||
Text(s.name, Modifier.layout{ m, c ->
|
|
||||||
val p = m.measure(c.copy(minWidth = c.maxWidth, maxWidth = Constraints.Infinity))
|
|
||||||
_h = p.height
|
|
||||||
layout(p.height, p.width) { p.place(0, 0) }
|
|
||||||
}.graphicsLayer {
|
|
||||||
rotationZ = -90f
|
|
||||||
transformOrigin = TransformOrigin(0f, 0.5f)
|
|
||||||
translationX = _h.toFloat() / 2f
|
|
||||||
translationY = textLenMeasured.dp.value - 15f
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { VLine() }
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
Modifier.width(cellSize).height(textLenMeasured.dp),
|
|
||||||
contentAlignment = Alignment.TopCenter
|
|
||||||
) {
|
|
||||||
var _h: Int = 0
|
|
||||||
Text("Group Rating", Modifier.layout{ m, c ->
|
|
||||||
val p = m.measure(c.copy(minWidth = c.maxWidth, maxWidth = Constraints.Infinity))
|
|
||||||
_h = p.height
|
|
||||||
layout(p.height, p.width) { p.place(0, 0) }
|
|
||||||
}.graphicsLayer {
|
|
||||||
rotationZ = -90f
|
|
||||||
transformOrigin = TransformOrigin(0f, 0.5f)
|
|
||||||
translationX = _h.toFloat() / 2f
|
|
||||||
translationY = textLenMeasured.dp.value - 15f
|
|
||||||
}, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { VLine() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MeasuredLazyColumn(key = idx) {
|
|
||||||
measuredItem { HLine() }
|
|
||||||
items(current.students) { (from, role, glob, map) ->
|
|
||||||
Row(Modifier.height(cellSize)) {
|
|
||||||
Column(Modifier.width(textLenMeasured.dp).align(Alignment.CenterVertically)) {
|
|
||||||
Text(from.name, Modifier.width(textLenMeasured.dp))
|
|
||||||
role?.let { r ->
|
|
||||||
Row {
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Text(
|
|
||||||
r,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontStyle = FontStyle.Italic
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LazyRow(state = horScroll) {
|
|
||||||
item { VLine() }
|
|
||||||
items(map) { (to, entry) ->
|
|
||||||
PEGradeWidget(entry,
|
|
||||||
{ editing = Triple(from, to, entry) }, { editing = null },
|
|
||||||
isSelected(from, to), Modifier.size(cellSize, cellSize)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item { VLine() }
|
|
||||||
item {
|
|
||||||
PEGradeWidget(glob,
|
|
||||||
{ editing = Triple(from, null, glob) }, { editing = null },
|
|
||||||
isSelected(from, null), Modifier.size(cellSize, cellSize))
|
|
||||||
}
|
|
||||||
item { VLine() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
measuredItem { HLine() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.weight(0.5f)) {
|
|
||||||
var groupLevel by remember(state, idx) { mutableStateOf(contents[idx].content) }
|
|
||||||
editing?.let {
|
|
||||||
Column(Modifier.weight(0.5f)) {
|
|
||||||
val (from, to, data) = it
|
|
||||||
|
|
||||||
var sGrade by remember(editing) { mutableStateOf(data?.grade ?: "") }
|
|
||||||
var sMsg by remember(editing) { mutableStateOf(data?.feedback ?: "") }
|
|
||||||
|
|
||||||
Box(Modifier.padding(5.dp)) {
|
|
||||||
to?.let { s2 ->
|
|
||||||
if(from == s2)
|
|
||||||
Text("Self-evaluation by ${from.name}", fontWeight = FontWeight.Bold)
|
|
||||||
else
|
|
||||||
Text("Evaluation of ${s2.name} by ${from.name}", fontWeight = FontWeight.Bold)
|
|
||||||
} ?: Text("Group-level evaluation by ${from.name}", fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
|
|
||||||
OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f))
|
|
||||||
Spacer(Modifier.weight(0.6f))
|
|
||||||
Button(
|
|
||||||
{ state.upsertIndividualFeedback(from, to, sGrade, sMsg); editing = null },
|
|
||||||
Modifier.weight(0.2f).align(Alignment.CenterVertically),
|
|
||||||
enabled = sGrade.isNotBlank() || sMsg.isNotBlank()
|
|
||||||
) {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f),
|
|
||||||
label = { Text("Feedback") },
|
|
||||||
singleLine = false,
|
|
||||||
minLines = 5
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.weight(0.5f)) {
|
|
||||||
Row {
|
|
||||||
Text("Group-level notes", Modifier.weight(1f).align(Alignment.CenterVertically), fontWeight = FontWeight.Bold)
|
|
||||||
Button(
|
|
||||||
{ state.upsertGroupFeedback(current.group, groupLevel); editing = null },
|
|
||||||
enabled = groupLevel != contents[idx].content
|
|
||||||
) { Text("Update") }
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
groupLevel, { groupLevel = it }, Modifier.fillMaxWidth().weight(1f),
|
|
||||||
label = { Text("Group-level notes") },
|
|
||||||
singleLine = false,
|
|
||||||
minLines = 5
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.TimeInput
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.*
|
||||||
|
import com.jaytux.grader.GroupGrading
|
||||||
|
import com.jaytux.grader.PeerEvalGrading
|
||||||
|
import com.jaytux.grader.SoloGrading
|
||||||
|
import com.jaytux.grader.data.v2.AssignmentType
|
||||||
|
import com.jaytux.grader.viewmodel.EditionVM
|
||||||
|
import com.jaytux.grader.viewmodel.Navigator
|
||||||
|
import com.jaytux.grader.viewmodel.UiGradeType
|
||||||
|
import com.mohamedrejeb.richeditor.model.rememberRichTextState
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.*
|
||||||
|
import kotlinx.datetime.format.MonthNames
|
||||||
|
import kotlinx.datetime.format.char
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import org.jetbrains.jewel.ui.component.*
|
||||||
|
import org.jetbrains.jewel.ui.typography
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AssignmentsView(vm: EditionVM, token: Navigator.NavToken) = Row(Modifier.fillMaxSize()) {
|
||||||
|
val assignments by vm.assignmentList.entities
|
||||||
|
val focus by vm.focusIndex
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val descRtf = rememberRichTextState()
|
||||||
|
val assignment = remember(assignments, focus) {
|
||||||
|
assignments.getOrNull(focus)?.also {
|
||||||
|
scope.launch { descRtf.setMarkdown(it.global.criterion.desc) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatingDeadline by remember { mutableStateOf(false) }
|
||||||
|
var addingRubric by remember { mutableStateOf(false) }
|
||||||
|
var editingRubric by remember { mutableStateOf(-1) }
|
||||||
|
var updatingGrade by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val navToGrading = lambda@{
|
||||||
|
if(assignment == null) return@lambda
|
||||||
|
when(assignment.assignment.type) {
|
||||||
|
AssignmentType.GROUP -> token.navTo(GroupGrading(vm.course, vm.edition, assignment.assignment))
|
||||||
|
AssignmentType.SOLO -> token.navTo(SoloGrading(vm.course, vm.edition, assignment.assignment))
|
||||||
|
AssignmentType.PEER_EVALUATION -> token.navTo(PeerEvalGrading(vm.course, vm.edition, assignment.assignment))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
|
||||||
|
ListOrEmpty(assignments, { Text("No groups yet.") }) { idx, it ->
|
||||||
|
QuickAssignment(idx, it, vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||||
|
if (assignment == null) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Text("Select an assignment to see details.", Modifier.padding(10.dp).align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
val peerEvalData by vm.asPeerEvaluation.entity
|
||||||
|
var updatingPeerEvalGrade by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Text(assignment.assignment.name, style = JewelTheme.typography.h2TextStyle)
|
||||||
|
Text("Deadline: ${assignment.assignment.deadline.format(fmt)}", Modifier.padding(top = 5.dp).clickable { updatingDeadline = true }, fontStyle = FontStyle.Italic)
|
||||||
|
Row {
|
||||||
|
Text("${assignment.assignment.type.display} using grading ", Modifier.align(Alignment.CenterVertically))
|
||||||
|
Surface(shape = JewelTheme.shapes.small) {
|
||||||
|
Box(Modifier.clickable { updatingGrade = true }.padding(3.dp)) {
|
||||||
|
Text(when(val t = assignment.global.gradeType){
|
||||||
|
is UiGradeType.Categoric -> t.grade.name
|
||||||
|
UiGradeType.FreeText -> "by free-form grades"
|
||||||
|
is UiGradeType.Numeric -> t.grade.name
|
||||||
|
UiGradeType.Percentage -> "by percentages"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peerEvalData?.let { pe ->
|
||||||
|
Row {
|
||||||
|
Text("Students are reviewing each other using ", Modifier.align(Alignment.CenterVertically))
|
||||||
|
Surface(shape = JewelTheme.shapes.small) {
|
||||||
|
Box(Modifier.clickable { updatingPeerEvalGrade = true }.padding(3.dp)) {
|
||||||
|
Text(
|
||||||
|
when (val t = pe.second) {
|
||||||
|
is UiGradeType.Categoric -> t.grade.name
|
||||||
|
UiGradeType.FreeText -> "by free-form grades"
|
||||||
|
is UiGradeType.Numeric -> t.grade.name
|
||||||
|
UiGradeType.Percentage -> "by percentages"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(updatingPeerEvalGrade) {
|
||||||
|
SetGradingDialog("${assignment.assignment.name} (peer review grade)", pe.second, vm, { updatingPeerEvalGrade = false }) { type ->
|
||||||
|
vm.setPEGrade(pe.first, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
Column(Modifier.weight(0.75f)) {
|
||||||
|
Row {
|
||||||
|
Text("Description:", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(top = 10.dp).weight(1f))
|
||||||
|
Button({ vm.setDesc(assignment, descRtf.toMarkdown()) }) {
|
||||||
|
Text("Update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
RichTextField(descRtf)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Surface(Modifier.weight(0.25f), color = Color.White) {
|
||||||
|
Column(Modifier.padding(15.dp)) {
|
||||||
|
Row {
|
||||||
|
Text("Grading Rubrics", Modifier.weight(1f), style = JewelTheme.typography.h2TextStyle)
|
||||||
|
IconButton({ addingRubric = true }) {
|
||||||
|
Icon(Icons.CirclePlus, "Add grading rubric")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
LazyColumn(Modifier.weight(1f)) {
|
||||||
|
itemsIndexed(assignment.criteria) { idx, it ->
|
||||||
|
Row(Modifier.padding(5.dp)) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(it.criterion.name)
|
||||||
|
Text(it.criterion.desc, Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
|
||||||
|
}
|
||||||
|
IconButton({ editingRubric = idx }, Modifier.align(Alignment.Top)) {
|
||||||
|
Icon(Icons.Edit, "Edit grading rubric")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Button({ navToGrading() }, Modifier.fillMaxWidth()) {
|
||||||
|
Text("Go to grading")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(updatingDeadline) {
|
||||||
|
if(assignment == null) updatingDeadline = false
|
||||||
|
else {
|
||||||
|
DeadlinePicker(assignment.assignment.deadline, { updatingDeadline = false }) {
|
||||||
|
vm.modAssignment(assignment.assignment, null, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(addingRubric) {
|
||||||
|
if(assignment == null) addingRubric = false
|
||||||
|
else {
|
||||||
|
AddCriterionDialog(null, vm, assignment.criteria.map { it.criterion.name }, { addingRubric = false }) { name, desc, type ->
|
||||||
|
vm.mkCriterion(assignment.assignment, name, desc, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(editingRubric != -1) {
|
||||||
|
if(assignment == null) editingRubric = -1
|
||||||
|
else {
|
||||||
|
AddCriterionDialog(assignment.criteria[editingRubric], vm, assignment.criteria.map { it.criterion.name }, { editingRubric = -1 }) { name, desc, type ->
|
||||||
|
vm.modCriterion(assignment.criteria[editingRubric].criterion, name, desc, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(updatingGrade) {
|
||||||
|
if(assignment == null) updatingGrade = false
|
||||||
|
else {
|
||||||
|
SetGradingDialog(assignment.assignment.name, assignment.global.gradeType, vm, { updatingGrade = false }) { type ->
|
||||||
|
vm.modCriterion(assignment.global.criterion, null, null, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fmt = LocalDateTime.Format {
|
||||||
|
date(LocalDate.Format {
|
||||||
|
day(); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED); char(' '); year()
|
||||||
|
})
|
||||||
|
char(' ')
|
||||||
|
time(LocalTime.Format {
|
||||||
|
amPmHour(); char(':'); minute(); char(' '); amPmMarker("AM", "PM")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QuickAssignment(idx: Int, assignment: EditionVM.AssignmentData, vm: EditionVM) {
|
||||||
|
val focus by vm.focusIndex
|
||||||
|
Surface(markFocused = focus == idx, shape = JewelTheme.shapes.small) {
|
||||||
|
Column(Modifier.fillMaxWidth().clickable { vm.focus(idx) }.padding(10.dp)) {
|
||||||
|
Text(assignment.assignment.name, fontWeight = FontWeight.Bold)
|
||||||
|
Text("Deadline: ${assignment.assignment.deadline.format(fmt)}", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddAssignmentDialog(label: String, taken: List<String>, onClose: () -> Unit, current: String = "", onSave: (String, AssignmentType) -> Unit) = DialogWindow(
|
||||||
|
onCloseRequest = onClose,
|
||||||
|
state = rememberDialogState(size = DpSize(750.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||||
|
) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||||
|
var type by remember { mutableStateOf(AssignmentType.SOLO) }
|
||||||
|
var name by remember(current) { mutableStateOf(current) }
|
||||||
|
Column(Modifier.align(Alignment.Center)) {
|
||||||
|
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||||
|
AssignmentType.entries.forEachIndexed { idx, it ->
|
||||||
|
SegmentedButton(
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(idx, AssignmentType.entries.size),
|
||||||
|
selected = type == it,
|
||||||
|
onClick = { type = it }
|
||||||
|
) { Text(it.display) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text(label) }, isError = name in taken)
|
||||||
|
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
||||||
|
onSave(name, type)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DeadlinePicker(deadline: LocalDateTime, onDismiss: () -> Unit, onSave: (LocalDateTime) -> Unit) {
|
||||||
|
val state = rememberDatePickerState(deadline.date.toJavaLocalDate())
|
||||||
|
val (h, m) = deadline.time.let { it.hour to it.minute }
|
||||||
|
val time = rememberTimePickerState(h, m)
|
||||||
|
|
||||||
|
val reconstruct = {
|
||||||
|
val inst = Instant.fromEpochMilliseconds(state.selectedDateMillis!!)
|
||||||
|
val date = inst.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
LocalDateTime(date.date, LocalTime(time.hour, time.minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(onDismiss, DialogProperties()) {
|
||||||
|
Surface(shape = JewelTheme.shapes.large) {
|
||||||
|
Column(Modifier.padding(15.dp)) {
|
||||||
|
DatePicker(state, Modifier.fillMaxWidth())
|
||||||
|
TimeInput(time, Modifier.fillMaxWidth())
|
||||||
|
Row {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Button(onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Button({ onSave(reconstruct()); onDismiss() }) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddCriterionDialog(current: EditionVM.CriterionData?, vm: EditionVM, taken: List<String>, onClose: () -> Unit, onSave: (name: String, desc: String, type: UiGradeType) -> Unit) = DialogWindow(
|
||||||
|
onCloseRequest = onClose,
|
||||||
|
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
|
||||||
|
) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
var type by remember(current) { mutableStateOf(current?.gradeType ?: UiGradeType.FreeText) }
|
||||||
|
var name by remember(current) { mutableStateOf(current?.criterion?.name ?: "") }
|
||||||
|
var desc by remember(current) { mutableStateOf(current?.criterion?.desc ?: "") }
|
||||||
|
val categories by vm.categoricGrades.entities
|
||||||
|
val numeric by vm.numericGrades.entities
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||||
|
Column(Modifier.align(Alignment.Center)) {
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text("Criterion Name") }, isError = name in taken, singleLine = true)
|
||||||
|
OutlinedTextField(desc, { desc = it }, Modifier.fillMaxWidth(), label = { Text("Short Description") }, singleLine = true)
|
||||||
|
Surface(shape = JewelTheme.shapes.small, color = Color.White, modifier = Modifier.fillMaxWidth().padding(5.dp)) {
|
||||||
|
Column {
|
||||||
|
GradeTypePicker(type, categories, numeric, { n, o -> vm.mkScale(n, o) }, { n, m -> vm.mkNumericScale(n, m) }, Modifier.weight(1f)) { type = it }
|
||||||
|
|
||||||
|
CancelSaveRow(name.isNotBlank() && (name !in taken || name == current?.criterion?.name), onClose) {
|
||||||
|
onSave(name, desc, type)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(current) { focus.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SetGradingDialog(name: String, current: UiGradeType, vm: EditionVM, onClose: () -> Unit, onSave: (type: UiGradeType) -> Unit) = DialogWindow(
|
||||||
|
onCloseRequest = onClose,
|
||||||
|
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
|
||||||
|
) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
val categories by vm.categoricGrades.entities
|
||||||
|
val numeric by vm.numericGrades.entities
|
||||||
|
var type by remember(current) { mutableStateOf(current) }
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||||
|
Column(Modifier.align(Alignment.Center)) {
|
||||||
|
Text("Select a grading scale for $name", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(bottom = 10.dp))
|
||||||
|
Surface(shape = JewelTheme.shapes.small, color = Color.White, modifier = Modifier.fillMaxWidth().padding(5.dp)) {
|
||||||
|
Column {
|
||||||
|
GradeTypePicker(type, categories, numeric, { n, o -> vm.mkScale(n, o) }, { n, m -> vm.mkNumericScale(n, m) }, Modifier.weight(1f)) { type = it }
|
||||||
|
|
||||||
|
CancelSaveRow(true, onClose) {
|
||||||
|
onSave(type)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(current) { focus.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GradeTypePicker(
|
||||||
|
type: UiGradeType, categories: List<UiGradeType.Categoric>, numeric: List<UiGradeType.Numeric>,
|
||||||
|
mkCat: (String, List<String>) -> Unit, mkNum: (String, Double) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onUpdate: (UiGradeType) -> Unit
|
||||||
|
) = Column(modifier) {
|
||||||
|
var selectedCategory by remember(categories) {
|
||||||
|
mutableStateOf(
|
||||||
|
if(type is UiGradeType.Categoric) categories.indexOfFirst { it.grade.id == type.grade.id }
|
||||||
|
else -1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var selectedNumeric by remember(numeric) {
|
||||||
|
mutableStateOf(
|
||||||
|
if(type is UiGradeType.Numeric) numeric.indexOfFirst { it.grade.id == type.grade.id }
|
||||||
|
else -1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var adding by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||||
|
SegmentedButton(
|
||||||
|
type is UiGradeType.FreeText, onClick = { onUpdate(UiGradeType.FreeText) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(0, 4)
|
||||||
|
) {
|
||||||
|
Text("Free-form grade")
|
||||||
|
}
|
||||||
|
SegmentedButton(
|
||||||
|
type is UiGradeType.Percentage, onClick = { onUpdate(UiGradeType.Percentage) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(1, 4)
|
||||||
|
) {
|
||||||
|
Text("Percentage")
|
||||||
|
}
|
||||||
|
SegmentedButton(
|
||||||
|
type is UiGradeType.Categoric, onClick = { onUpdate(categories[maxOf(selectedCategory, 0)]) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(2, 4)
|
||||||
|
) {
|
||||||
|
Text("Grading System")
|
||||||
|
}
|
||||||
|
SegmentedButton(
|
||||||
|
type is UiGradeType.Numeric, onClick = { onUpdate(numeric[maxOf(selectedNumeric, 0)]) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(3, 4)
|
||||||
|
) {
|
||||||
|
Text("Numeric Grade")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(type as? UiGradeType.Categoric)?.let {
|
||||||
|
LazyColumn(Modifier.weight(1f)) {
|
||||||
|
itemsIndexed(categories) { idx, it ->
|
||||||
|
Surface(
|
||||||
|
markFocused = selectedCategory == idx,
|
||||||
|
shape = JewelTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().clickable { selectedCategory = idx; onUpdate(it) }.padding(10.dp)) {
|
||||||
|
Text(it.grade.name, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"(${it.options.size} options)",
|
||||||
|
Modifier.padding(start = 10.dp),
|
||||||
|
fontStyle = FontStyle.Italic
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Button({ adding = true }, Modifier.fillMaxWidth()) {
|
||||||
|
Text("Add grading system")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: (type as? UiGradeType.Numeric)?.let {
|
||||||
|
LazyColumn(Modifier.weight(1f)) {
|
||||||
|
itemsIndexed(numeric) { idx, it ->
|
||||||
|
Surface(
|
||||||
|
markFocused = selectedNumeric == idx,
|
||||||
|
shape = JewelTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().clickable { selectedNumeric = idx; onUpdate(it) }.padding(10.dp)) {
|
||||||
|
Text(it.grade.name, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"(graded as X/${it.grade.max})",
|
||||||
|
Modifier.padding(start = 10.dp),
|
||||||
|
fontStyle = FontStyle.Italic
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Button({ adding = true }, Modifier.fillMaxWidth()) {
|
||||||
|
Text("Add numeric system")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
if(adding) {
|
||||||
|
when(type) {
|
||||||
|
is UiGradeType.Categoric -> AddCatScaleDialog(categories.map { it.grade.name }, { adding = false }) { name, options ->
|
||||||
|
mkCat(name, options)
|
||||||
|
}
|
||||||
|
is UiGradeType.Numeric -> AddNumScaleDialog(numeric.map { it.grade.name }, { adding = false }) { name, max ->
|
||||||
|
mkNum(name, max)
|
||||||
|
}
|
||||||
|
else -> adding = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddCatScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String, List<String>) -> Unit) = DialogWindow(
|
||||||
|
onCloseRequest = onClose,
|
||||||
|
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
|
||||||
|
) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var options by remember { mutableStateOf(listOf<String>()) }
|
||||||
|
var adding by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||||
|
Column(Modifier.align(Alignment.Center)) {
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text("Grading system name") }, isError = name in taken, singleLine = true)
|
||||||
|
Text("Grade options:", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(top = 10.dp))
|
||||||
|
LazyColumn(Modifier.weight(1f)) {
|
||||||
|
itemsIndexed(options) { idx, it ->
|
||||||
|
Row(Modifier.fillMaxWidth().padding(5.dp)) {
|
||||||
|
Text(it, Modifier.weight(1f))
|
||||||
|
IconButton({ options = options.filterNot { o -> o == it } }) {
|
||||||
|
Icon(Icons.Delete, "Delete grading option")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Row {
|
||||||
|
OutlinedTextField(adding, { adding = it }, Modifier.weight(1f).align(Alignment.CenterVertically).padding(5.dp), label = { Text("New option") }, isError = adding in options, singleLine = true)
|
||||||
|
Button({ options = options + adding; adding = "" }, Modifier.align(Alignment.CenterVertically).padding(5.dp), enabled = adding.isNotBlank() && adding !in options) {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
||||||
|
onSave(name, options)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddNumScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String, Double) -> Unit) = DialogWindow(
|
||||||
|
onCloseRequest = onClose,
|
||||||
|
state = rememberDialogState(size = DpSize(750.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||||
|
) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var maxStr by remember { mutableStateOf("0") }
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||||
|
Column(Modifier.align(Alignment.Center)) {
|
||||||
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text("Grading system name") }, isError = name in taken, singleLine = true)
|
||||||
|
OutlinedTextField(maxStr, { maxStr = it.toDoubleOrNull()?.let { _ -> it } ?: "0" }, Modifier.fillMaxWidth(), label = { Text("Maximum grade") }, singleLine = true)
|
||||||
|
|
||||||
|
CancelSaveRow(name.isNotBlank() && name !in taken && (maxStr.toDoubleOrNull() ?: 0.0) > 0.0, onClose) {
|
||||||
|
onSave(name, maxStr.toDoubleOrNull()!!)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package com.jaytux.grader.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.jaytux.grader.UiRoute
|
|
||||||
import com.jaytux.grader.data.Edition
|
|
||||||
import com.jaytux.grader.viewmodel.CourseListState
|
|
||||||
import com.jaytux.grader.viewmodel.EditionListState
|
|
||||||
import com.jaytux.grader.viewmodel.EditionState
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) {
|
|
||||||
val data by state.courses.entities
|
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Box(Modifier.padding(15.dp)) {
|
|
||||||
ListOrEmpty(
|
|
||||||
data,
|
|
||||||
{ Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) },
|
|
||||||
{ Text("Add a course") },
|
|
||||||
{ showDialog = true },
|
|
||||||
addAfterLazy = false
|
|
||||||
) { _, it ->
|
|
||||||
CourseWidget(state.getEditions(it), { state.delete(it) }, push)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CourseWidget(state: EditionListState, onDelete: () -> Unit, push: (UiRoute) -> Unit) {
|
|
||||||
val editions by state.editions.entities
|
|
||||||
var isOpened by remember { mutableStateOf(false) }
|
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val callback = { it: Edition ->
|
|
||||||
val s = EditionState(it)
|
|
||||||
val route = UiRoute("${state.course.name}: ${it.name}") {
|
|
||||||
EditionView(s)
|
|
||||||
}
|
|
||||||
push(route)
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 10.dp).clickable { isOpened = !isOpened }, shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 2.dp) {
|
|
||||||
Row {
|
|
||||||
Column(Modifier.weight(1f).padding(5.dp)) {
|
|
||||||
Row {
|
|
||||||
Icon(
|
|
||||||
if (isOpened) ChevronDown else ChevronRight, "Toggle editions",
|
|
||||||
Modifier.size(MaterialTheme.typography.headlineMedium.fontSize.toDp())
|
|
||||||
.align(Alignment.CenterVertically)
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(state.course.name, style = MaterialTheme.typography.headlineMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row {
|
|
||||||
Spacer(Modifier.width(25.dp))
|
|
||||||
Text(
|
|
||||||
"${editions.size} edition(s)",
|
|
||||||
fontStyle = FontStyle.Italic,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if(isOpened) {
|
|
||||||
Row {
|
|
||||||
Spacer(Modifier.width(25.dp))
|
|
||||||
Column {
|
|
||||||
editions.forEach { EditionWidget(it, { callback(it) }) { state.delete(it) } }
|
|
||||||
Button({ showDialog = true }, Modifier.fillMaxWidth()) { Text("Add edition") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column {
|
|
||||||
IconButton({ onDelete() }) { Icon(Icons.Default.Delete, "Remove") }
|
|
||||||
IconButton({ TODO() }, enabled = false) { Icon(Icons.Default.Edit, "Edit") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(showDialog) AddStringDialog("Edition name", editions.map { it.name }, { showDialog = false }) { state.new(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EditionWidget(edition: Edition, onOpen: () -> Unit, onDelete: () -> Unit) {
|
|
||||||
Surface(Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 10.dp).clickable { onOpen() }, shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 2.dp) {
|
|
||||||
Row(Modifier.padding(5.dp)) {
|
|
||||||
Text(edition.name, Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall)
|
|
||||||
IconButton({ onDelete() }) { Icon(Icons.Default.Delete, "Remove") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.jaytux.grader.EditionDetail
|
||||||
|
import com.jaytux.grader.viewmodel.EditionVM
|
||||||
|
import com.jaytux.grader.viewmodel.Navigator
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import org.jetbrains.jewel.ui.component.*
|
||||||
|
import org.jetbrains.jewel.ui.typography
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditionTitle(data: EditionDetail) = Text("Courses / ${data.course.name} / ${data.ed.name}")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditionView(data: EditionDetail, token: Navigator.NavToken) {
|
||||||
|
val vm = viewModel<EditionVM>(key = data.ed.id.toString()) { EditionVM(data.ed, data.course) }
|
||||||
|
val tab by vm.selectedTab
|
||||||
|
var adding by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val groups by vm.groupList.entities
|
||||||
|
val assignments by vm.assignmentList.entities
|
||||||
|
|
||||||
|
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Row {
|
||||||
|
Text("${vm.course.name} - ${vm.edition.name}", Modifier.weight(1f), style = JewelTheme.typography.h2TextStyle)
|
||||||
|
IconButton({ adding = true }) {
|
||||||
|
Icon(Icons.CirclePlus, "Add ${tab.addText}")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Add ${tab.addText}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
PrimaryScrollableTabRow(tab.ordinal, edgePadding = 10.dp) {
|
||||||
|
EditionVM.Tab.entries.forEach {
|
||||||
|
Tab(tab == it, onClick = { vm.switchTo(it) }, modifier = Modifier.padding(horizontal = 5.dp)) { it.renderTab() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
when (tab) {
|
||||||
|
EditionVM.Tab.STUDENTS -> StudentsView(vm)
|
||||||
|
EditionVM.Tab.GROUPS -> GroupsView(vm)
|
||||||
|
EditionVM.Tab.ASSIGNMENTS -> AssignmentsView(vm, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(adding) {
|
||||||
|
when(tab) {
|
||||||
|
EditionVM.Tab.STUDENTS ->
|
||||||
|
AddStringDialog("Student Name", listOf(), { adding = false }, "") { vm.mkStudent(it, "", "") }
|
||||||
|
EditionVM.Tab.GROUPS ->
|
||||||
|
AddStringDialog("Group Name", groups.map { it.group.name }, { adding = false }, "") { vm.mkGroup(it) }
|
||||||
|
EditionVM.Tab.ASSIGNMENTS ->
|
||||||
|
AddAssignmentDialog("Assignment Name", assignments.map { it.assignment.name }, { adding = false }, "") { name, type -> vm.mkAssignment(name, type) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StudentsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
|
||||||
|
Icon(Icons.UserIcon, "Students")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Students")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
|
||||||
|
Icon(Icons.UserGroupIcon, "Groups")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AssignmentsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
|
||||||
|
Icon(Icons.AssignmentIcon, "Assignments")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Assignments")
|
||||||
|
}
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
package com.jaytux.grader.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.DpSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.DialogWindow
|
|
||||||
import androidx.compose.ui.window.WindowPosition
|
|
||||||
import androidx.compose.ui.window.rememberDialogState
|
|
||||||
import com.jaytux.grader.data.Course
|
|
||||||
import com.jaytux.grader.data.Edition
|
|
||||||
import com.jaytux.grader.data.Group
|
|
||||||
import com.jaytux.grader.data.Student
|
|
||||||
import com.jaytux.grader.viewmodel.*
|
|
||||||
|
|
||||||
data class Navigators(
|
|
||||||
val student: (Student) -> Unit,
|
|
||||||
val group: (Group) -> Unit,
|
|
||||||
val assignment: (Assignment) -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) {
|
|
||||||
val course = state.course; val edition = state.edition
|
|
||||||
val students by state.students.entities
|
|
||||||
val availableStudents by state.availableStudents.entities
|
|
||||||
val groups by state.groups.entities
|
|
||||||
val solo by state.solo.entities
|
|
||||||
val groupAs by state.groupAs.entities
|
|
||||||
val peers by state.peer.entities
|
|
||||||
val mergedAssignments by remember(solo, groupAs, peers) { mutableStateOf(Assignment.merge(groupAs, solo, peers)) }
|
|
||||||
val hist by state.history
|
|
||||||
|
|
||||||
val navs = Navigators(
|
|
||||||
student = { state.navTo(OpenPanel.Student, students.indexOfFirst{ s -> s.id == it.id }) },
|
|
||||||
group = { state.navTo(OpenPanel.Group, groups.indexOfFirst { g -> g.id == it.id }) },
|
|
||||||
assignment = { state.navTo(OpenPanel.Assignment, mergedAssignments.indexOfFirst { a -> a.id() == it.id() }) }
|
|
||||||
)
|
|
||||||
|
|
||||||
val (id, tab) = hist.last()
|
|
||||||
Surface(Modifier.weight(0.25f), tonalElevation = 5.dp) {
|
|
||||||
TabLayout(
|
|
||||||
OpenPanel.entries,
|
|
||||||
tab.ordinal,
|
|
||||||
{ state.navTo(OpenPanel.entries[it]) },
|
|
||||||
{ Text(it.tabName) }
|
|
||||||
) {
|
|
||||||
when(tab) {
|
|
||||||
OpenPanel.Student -> StudentPanel(
|
|
||||||
course, edition, students, availableStudents, id,
|
|
||||||
{ state.navTo(it) },
|
|
||||||
{ name, note, contact, add -> state.newStudent(name, contact, note, add) },
|
|
||||||
{ students -> state.addToCourse(students) },
|
|
||||||
{ s, name -> state.setStudentName(s, name) }
|
|
||||||
) { s, 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.jaytux.grader.GroupGrading
|
||||||
|
import com.jaytux.grader.app
|
||||||
|
import com.jaytux.grader.data.v2.Group
|
||||||
|
import com.jaytux.grader.viewmodel.GroupsGradingVM
|
||||||
|
import com.jaytux.grader.viewmodel.Navigator
|
||||||
|
import java.util.*
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import org.jetbrains.jewel.ui.component.*
|
||||||
|
import org.jetbrains.jewel.ui.typography
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupsGradingTitle(data: GroupGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Group Assignments / ${data.assignment.name} / Grading")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupsGradingView(data: GroupGrading, token: Navigator.NavToken) {
|
||||||
|
val vm = viewModel<GroupsGradingVM>(key = data.assignment.id.toString()) {
|
||||||
|
GroupsGradingVM(data.course, data.edition, data.assignment)
|
||||||
|
}
|
||||||
|
val groups by vm.groupList.entities
|
||||||
|
val focus by vm.focus
|
||||||
|
|
||||||
|
val selectedGroup = remember(focus, groups) { groups.getOrNull(focus) }
|
||||||
|
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Text("Grading ${vm.base.name}", style = JewelTheme.typography.h2TextStyle)
|
||||||
|
Text("Group assignment in ${vm.course.name} - ${vm.edition.name}")
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
|
||||||
|
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
|
||||||
|
QuickAGroup(idx == focus, { vm.focusGroup(idx) }, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||||
|
if (focus == -1 || selectedGroup == null) {
|
||||||
|
Box(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||||
|
Text("Select a group to start grading.", Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(Modifier.weight(0.75f).padding(15.dp)) {
|
||||||
|
Row {
|
||||||
|
IconButton({ vm.focusPrev() }, Modifier.align(Alignment.CenterVertically), enabled = focus > 0) {
|
||||||
|
Icon(Icons.DoubleBack, "Previous group")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(selectedGroup.group.name, Modifier.align(Alignment.CenterVertically), style = JewelTheme.typography.h2TextStyle)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
IconButton({ vm.focusNext() }, Modifier.align(Alignment.CenterVertically), enabled = focus < groups.size - 1) {
|
||||||
|
Icon(Icons.DoubleForward, "Next group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
val global by vm.globalGrade.entity
|
||||||
|
val byCriteria by vm.gradeList.entities
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize(), color = Color.White, shape = JewelTheme.shapes.medium) {
|
||||||
|
LazyColumn {
|
||||||
|
items(byCriteria ?: listOf()) { (crit, fdbk) ->
|
||||||
|
var isOpen by remember(selectedGroup) { mutableStateOf(false) }
|
||||||
|
Column(Modifier.padding(5.dp)) {
|
||||||
|
GFWidget(crit, selectedGroup.group, fdbk, vm, global to byCriteria, isOpen, showDesc = true) { isOpen = !isOpen }
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
global?.let { fdbk ->
|
||||||
|
item {
|
||||||
|
Box(Modifier.padding(5.dp)) {
|
||||||
|
GFWidget(
|
||||||
|
vm.global, selectedGroup.group, fdbk, vm, global to byCriteria, true,
|
||||||
|
markOverridden = (byCriteria ?: listOf()).flatMap { (_, it) ->
|
||||||
|
it.overrides.mapNotNull { o ->
|
||||||
|
o.second?.let { _ -> o.first.id.value }
|
||||||
|
}
|
||||||
|
}.toSet(), overrideName = "Global grade"
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QuickAGroup(isFocus: Boolean, onFocus: () -> Unit, group: GroupsGradingVM.GroupData) {
|
||||||
|
Surface(markFocused = isFocus, shape = JewelTheme.shapes.small) {
|
||||||
|
Column(Modifier.fillMaxWidth().clickable { onFocus() }.padding(10.dp)) {
|
||||||
|
Text(group.group.name, fontWeight = FontWeight.Bold)
|
||||||
|
Text("${group.students.size} student(s)", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GFWidget(
|
||||||
|
crit: CritData, gr: Group, feedback: GroupsGradingVM.FeedbackData, vm: GroupsGradingVM, key: Any,
|
||||||
|
isOpen: Boolean, showDesc: Boolean = false, overrideName: String? = null, markOverridden: Set<UUID> = setOf(),
|
||||||
|
onToggle: () -> Unit
|
||||||
|
) = Surface(Modifier.fillMaxWidth(), shape = JewelTheme.shapes.medium) {
|
||||||
|
Column {
|
||||||
|
Surface {
|
||||||
|
Row(Modifier.fillMaxWidth().clickable { onToggle() }.padding(10.dp)) {
|
||||||
|
Icon(if(isOpen) Icons.ChevronDown else Icons.ChevronRight, "Toggle criterion detail grading", Modifier.align(Alignment.CenterVertically))
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Column(Modifier.align(Alignment.CenterVertically)) {
|
||||||
|
Row {
|
||||||
|
Text(overrideName ?: crit.criterion.name, style = JewelTheme.typography.h4TextStyle)
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
feedback.groupLevel?.grade?.let {
|
||||||
|
Row(Modifier.align(Alignment.Bottom)) {
|
||||||
|
// ProvideTextStyle(JewelTheme.typography.small) {
|
||||||
|
Text("(Grade: ")
|
||||||
|
it.render()
|
||||||
|
Text(")")
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(showDesc) {
|
||||||
|
Text(crit.criterion.desc, fontStyle = FontStyle.Italic, modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp).fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isOpen) {
|
||||||
|
Row(Modifier.padding(10.dp)) {
|
||||||
|
var grade by remember(key, feedback) { mutableStateOf(gradeState(crit, feedback.groupLevel?.grade)) }
|
||||||
|
var text by remember(key, feedback) { mutableStateOf(feedback.groupLevel?.feedback ?: "") }
|
||||||
|
Column(Modifier.weight(0.5f).height(IntrinsicSize.Min)) {
|
||||||
|
|
||||||
|
GradePicker(grade, key = crit to gr) { grade = it }
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 5, modifier = Modifier.fillMaxWidth().weight(1f))
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
DefaultButton({ vm.modGroupFeedback(crit.criterion, gr, grade, text) }, Modifier.padding(horizontal = 20.dp).fillMaxWidth()) {
|
||||||
|
Text("Save grade and feedback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feedback.groupLevel?.let { groupLevel ->
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.5f).height(IntrinsicSize.Min), shape = JewelTheme.shapes.small) {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Text("Individual overrides", style = JewelTheme.typography.h4TextStyle)
|
||||||
|
feedback.overrides.forEach { (student, it) ->
|
||||||
|
var enable by remember(key, it) { mutableStateOf(it != null) }
|
||||||
|
var maybeRemoving by remember(key, it) { mutableStateOf(false) }
|
||||||
|
var sGrade by remember(key, it) { mutableStateOf(gradeState(crit, it?.grade ?: grade)) }
|
||||||
|
var sText by remember(key, it) { mutableStateOf(it?.feedback ?: "") }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Row {
|
||||||
|
Checkbox(enable, { if(it) { enable = true } else { maybeRemoving = true } })
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text(student.name, Modifier.align(Alignment.CenterVertically))
|
||||||
|
if(student.id.value in markOverridden) {
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("(Overridden)", Modifier.align(Alignment.CenterVertically), style = JewelTheme.typography.small, fontStyle = FontStyle.Italic, color = Color.Red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(enable) Row {
|
||||||
|
Spacer(Modifier.width(15.dp))
|
||||||
|
Surface(color = Color.White, shape = JewelTheme.shapes.small) {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
GradePicker(sGrade, key = crit to gr app student) { sGrade = it }
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
OutlinedTextField(sText, { sText = it }, label = { Text("Feedback") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
DefaultButton({ vm.modOverrideFeedback(crit.criterion, gr, student, groupLevel, sGrade, sText) }) {
|
||||||
|
Text("Save override")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(maybeRemoving) {
|
||||||
|
ConfirmDeleteDialog("the individual grade for ${student.name}", { maybeRemoving = false }, {
|
||||||
|
maybeRemoving = false
|
||||||
|
enable = false
|
||||||
|
vm.rmOverrideFeedback(crit.criterion, gr, student)
|
||||||
|
}) {
|
||||||
|
Column {
|
||||||
|
Row {
|
||||||
|
Text("Grade:")
|
||||||
|
sGrade.render()
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
Text("Feedback:")
|
||||||
|
if(sText.isBlank()) Text("No feedback", fontStyle = FontStyle.Italic)
|
||||||
|
else Text(sText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.draganddrop.dragAndDropSource
|
||||||
|
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropTransferAction
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropTransferData
|
||||||
|
import androidx.compose.ui.draganddrop.DragAndDropTransferable
|
||||||
|
import androidx.compose.ui.draganddrop.awtTransferable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.jaytux.grader.data.v2.Group
|
||||||
|
import com.jaytux.grader.data.v2.Student
|
||||||
|
import com.jaytux.grader.startEmail
|
||||||
|
import com.jaytux.grader.viewmodel.EditionVM
|
||||||
|
import com.jaytux.grader.viewmodel.SnackVM
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import java.awt.datatransfer.DataFlavor
|
||||||
|
import java.awt.datatransfer.StringSelection
|
||||||
|
import java.awt.datatransfer.Transferable
|
||||||
|
import java.util.UUID
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import org.jetbrains.jewel.foundation.theme.LocalTextStyle
|
||||||
|
import org.jetbrains.jewel.ui.component.*
|
||||||
|
import org.jetbrains.jewel.ui.typography
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
|
||||||
|
val groups by vm.groupList.entities
|
||||||
|
val focus by vm.focusIndex
|
||||||
|
var swappingRole by remember { mutableStateOf(-1) }
|
||||||
|
|
||||||
|
val group = remember(groups, focus) { if(focus != -1) groups[focus] else null }
|
||||||
|
val grades by vm.groupGrades.entities
|
||||||
|
val snacks = viewModel<SnackVM> { SnackVM() }
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
|
||||||
|
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
|
||||||
|
QuickGroup(idx, it, vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||||
|
if(group == null) {
|
||||||
|
Box(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||||
|
Text("Select a group to view details.", Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(group.group.name, style = JewelTheme.typography.h2TextStyle)
|
||||||
|
if (group.members.any { it.first.contact.isNotBlank() }) {
|
||||||
|
IconButton({ startEmail(group.members.mapNotNull { it.first.contact.ifBlank { null } }) { snacks.show(it) } }) {
|
||||||
|
Icon(Icons.Mail, "Send email", Modifier.fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
Row(Modifier.padding(5.dp)) {
|
||||||
|
var showTargetBorder by remember { mutableStateOf(false) }
|
||||||
|
val ddTarget = remember {
|
||||||
|
DDTarget({ showTargetBorder = true }, { showTargetBorder = false }, { DDTarget.extractStudent(it) }) {
|
||||||
|
vm.addStudentToGroup(it, group.group, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.weight(0.75f)) {
|
||||||
|
Surface(
|
||||||
|
Modifier.weight(0.5f).then(if(showTargetBorder) Modifier.border(BorderStroke(3.dp, Color.Black)) else Modifier)
|
||||||
|
.dragAndDropTarget({ true }, target = ddTarget),
|
||||||
|
shape = JewelTheme.shapes.medium, color = Color.White) {
|
||||||
|
LazyColumn {
|
||||||
|
item {
|
||||||
|
Surface {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(10.dp)) {
|
||||||
|
Text("Members", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(10.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsIndexed(group.members) { idx, (student, role) ->
|
||||||
|
Row(Modifier.clickable { vm.focus(student) }.padding(10.dp)) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(student.name, fontWeight = FontWeight.Bold)
|
||||||
|
if(student.contact.isEmpty())
|
||||||
|
Text("No contact info.", fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
else Text(student.contact)
|
||||||
|
}
|
||||||
|
if(role != null) {
|
||||||
|
Surface(Modifier.align(Alignment.CenterVertically), shape = JewelTheme.shapes.small) {
|
||||||
|
Box(Modifier.clickable { swappingRole = -1 }.clickable { swappingRole = idx }) {
|
||||||
|
Text(role, Modifier.padding(horizontal = 5.dp, vertical = 2.dp), style = JewelTheme.typography.regular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Text("No role", Modifier.align(Alignment.CenterVertically).clickable { swappingRole = idx }, fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
IconButton({ vm.rmStudentFromGroup(student, group.group) }, Modifier.align(Alignment.CenterVertically)) {
|
||||||
|
Icon(Icons.PersonMinus, "Remove ${student.name} from group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(group.members.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||||
|
Text("No members yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Column(Modifier.weight(0.5f)) {
|
||||||
|
Text("Grade Summary: ", style = JewelTheme.typography.h2TextStyle)
|
||||||
|
Surface(shape = JewelTheme.shapes.medium, color = Color.White) {
|
||||||
|
LazyColumn(Modifier.fillMaxHeight()) {
|
||||||
|
item {
|
||||||
|
Surface {
|
||||||
|
Row(Modifier.padding(10.dp)) {
|
||||||
|
Text("Assignment", Modifier.weight(0.66f))
|
||||||
|
Text("Grade", Modifier.weight(0.33f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(grades ?: listOf()) {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Row {
|
||||||
|
Text(it.first.name, Modifier.weight(0.66f))
|
||||||
|
it.second?.render(Modifier.weight(0.33f))
|
||||||
|
?: Text("---", Modifier.weight(0.33f), color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if((grades ?: listOf()).isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||||
|
Text("No grades yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
|
||||||
|
val available by vm.groupAvailableStudents.entities
|
||||||
|
Surface(Modifier.weight(0.25f), shape = JewelTheme.shapes.medium, color = Color.White) {
|
||||||
|
LazyColumn {
|
||||||
|
item {
|
||||||
|
Surface {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(10.dp)) {
|
||||||
|
Text("Available Students", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.padding(10.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(available ?: listOf()) { student ->
|
||||||
|
AvailableStudent(student, group.group, vm)
|
||||||
|
}
|
||||||
|
|
||||||
|
if((available ?: listOf()).isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||||
|
Text("No available students.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(swappingRole != -1) {
|
||||||
|
if(group != null) {
|
||||||
|
val roles by vm.usedRoles.entities
|
||||||
|
RolePicker(roles, group.members[swappingRole].second, { swappingRole = -1 }) { upd ->
|
||||||
|
vm.setStudentRole(group.members[swappingRole].first, group.group, upd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
swappingRole = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DDTarget<T>(val onStart: () -> Unit, val onEnd: () -> Unit, val validator: (Transferable) -> T?, val handle: (T) -> Unit) : DragAndDropTarget {
|
||||||
|
override fun onStarted(event: DragAndDropEvent) {
|
||||||
|
onStart()
|
||||||
|
super.onStarted(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEnded(event: DragAndDropEvent) {
|
||||||
|
onEnd()
|
||||||
|
super.onEnded(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||||
|
println("Action at the target: ${event.action}")
|
||||||
|
|
||||||
|
return validator(event.awtTransferable)?.let {
|
||||||
|
handle(it)
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
fun mkStudentTransferable(student: Student) = DragAndDropTransferable(StringSelection("com.jaytux.grader:student:${student.id.value}"))
|
||||||
|
|
||||||
|
fun extractStudent(transf: Transferable): Student? {
|
||||||
|
if(transf.isDataFlavorSupported(DataFlavor.stringFlavor)) {
|
||||||
|
val raw = transf.getTransferData(DataFlavor.stringFlavor) as String
|
||||||
|
val prefix = "com.jaytux.grader:student:"
|
||||||
|
if(raw.startsWith(prefix)) {
|
||||||
|
val id = UUID.fromString(raw.removePrefix(prefix))
|
||||||
|
return transaction { Student.findById(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QuickGroup(idx: Int, group: EditionVM.GroupData, vm: EditionVM) {
|
||||||
|
val focus by vm.focusIndex
|
||||||
|
Surface(markFocused = focus == idx, shape = JewelTheme.shapes.small) {
|
||||||
|
Column(Modifier.fillMaxWidth().clickable { vm.focus(idx) }.padding(10.dp)) {
|
||||||
|
Text(group.group.name, fontWeight = FontWeight.Bold)
|
||||||
|
Text("${group.members.size} member(s)", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AvailableStudent(student: Student, group: Group, vm: EditionVM) {
|
||||||
|
Row(Modifier.padding(10.dp).dragAndDropSource(
|
||||||
|
drawDragDecoration = {},
|
||||||
|
) {
|
||||||
|
DragAndDropTransferData(
|
||||||
|
transferable = DDTarget.mkStudentTransferable(student),
|
||||||
|
supportedActions = listOf(DragAndDropTransferAction.Move),
|
||||||
|
dragDecorationOffset = it,
|
||||||
|
onTransferCompleted = { act -> println("Source action: $act") }
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Text(student.name, Modifier.align(Alignment.CenterVertically).weight(1f), fontWeight = FontWeight.Bold)
|
||||||
|
IconButton({ vm.addStudentToGroup(student, group, null) }) {
|
||||||
|
Icon(Icons.CirclePlus, "Add ${student.name} to group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Button(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) = DefaultButton(onClick, modifier, enabled) {
|
||||||
|
Row { content() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.jaytux.grader.EditionDetail
|
||||||
|
import com.jaytux.grader.data.v2.Edition
|
||||||
|
import com.jaytux.grader.viewmodel.HomeVM
|
||||||
|
import com.jaytux.grader.viewmodel.Navigator
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import org.jetbrains.jewel.ui.component.*
|
||||||
|
import org.jetbrains.jewel.ui.typography
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeTitle() = Text("Grader")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeView(token: Navigator.NavToken) {
|
||||||
|
val vm = viewModel<HomeVM> { HomeVM() }
|
||||||
|
val courses by vm.courses.entities
|
||||||
|
var addingCourse by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LazyColumn(Modifier.padding(15.dp)) {
|
||||||
|
item {
|
||||||
|
Row {
|
||||||
|
Text("Courses Overview", Modifier.weight(0.8f), style = JewelTheme.typography.h2TextStyle)
|
||||||
|
DefaultButton({ addingCourse = true }) {
|
||||||
|
Icon(Icons.CirclePlus, "Add course")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Add course")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(courses) {
|
||||||
|
CourseCard(it, vm) { e -> token.navTo(EditionDetail(e, it.course)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(addingCourse) {
|
||||||
|
AddStringDialog("Course Name", courses.map { it.course.name }, { addingCourse = false }, "") {
|
||||||
|
vm.mkCourse(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CourseCard(course: HomeVM.CourseData, vm: HomeVM, onOpenEdition: (Edition) -> Unit) {
|
||||||
|
var addingEdition by remember { mutableStateOf(false) }
|
||||||
|
var deleting by remember { mutableStateOf(false) }
|
||||||
|
Surface(shape = JewelTheme.shapes.medium, modifier = Modifier.fillMaxWidth().padding(10.dp)) {
|
||||||
|
Column(Modifier.padding(8.dp)) {
|
||||||
|
Row {
|
||||||
|
Text(course.course.name, style = JewelTheme.typography.h2TextStyle, modifier = Modifier.weight(1f))
|
||||||
|
IconButton({ deleting = true }) { Icon(Icons.Delete, "Delete course") }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
Text("Editions", style = JewelTheme.typography.h2TextStyle, modifier = Modifier.weight(1f))
|
||||||
|
DefaultButton({ addingEdition = true }) {
|
||||||
|
Icon(Icons.CirclePlus, "Add edition")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Add edition")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
course.editions.forEach { EditionCard(course.course.name, it, vm, onOpenEdition) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if(course.archived.isNotEmpty()) {
|
||||||
|
Text("Archived editions", style = JewelTheme.typography.h2TextStyle)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
course.archived.forEach { EditionCard(course.course.name, it, vm, onOpenEdition) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(addingEdition) {
|
||||||
|
AddStringDialog("Edition Name (in ${course.course.name})", course.editions.map { it.edition.name }, { addingEdition = false }, "") {
|
||||||
|
vm.mkEdition(course.course, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(deleting) {
|
||||||
|
ConfirmDeleteDialog("a course", { deleting = false }, { vm.rmCourse(course.course) }) {
|
||||||
|
Text(course.course.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditionCard(courseName: String, edition: HomeVM.EditionData, vm: HomeVM, onOpen: (Edition) -> Unit) {
|
||||||
|
val type = if(edition.edition.archived) "Archived" else "Active"
|
||||||
|
var deleting by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Surface(shape = JewelTheme.shapes.medium, modifier = Modifier.padding(10.dp).clickable { onOpen(edition.edition) }) {
|
||||||
|
Column(Modifier.padding(10.dp).width(IntrinsicSize.Min)) {
|
||||||
|
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||||
|
Text(edition.edition.name, style = JewelTheme.typography.h2TextStyle)
|
||||||
|
Text(
|
||||||
|
"$type\n${edition.students.size} student(s) • ${edition.groups.size} group(s) • ${edition.assignments.size} assignment(s)",
|
||||||
|
style = JewelTheme.typography.regular
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
Row {
|
||||||
|
if(edition.edition.archived) {
|
||||||
|
DefaultButton({ vm.unarchiveEdition(edition.edition) }, Modifier.weight(0.5f)) {
|
||||||
|
Icon(Icons.Unarchive, "Unarchive edition")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Unarchive edition")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
DefaultButton({ vm.archiveEdition(edition.edition) }, Modifier.weight(0.5f)) {
|
||||||
|
Icon(Icons.Archive, "Archive edition")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Archive edition")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
DefaultButton({ deleting = true }, Modifier.weight(0.5f)) {
|
||||||
|
Icon(Icons.Delete, "Archive edition")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text("Delete edition")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(deleting) {
|
||||||
|
ConfirmDeleteDialog("an edition", { deleting = false }, { vm.rmEdition(edition.edition) }) {
|
||||||
|
Column {
|
||||||
|
Text(edition.edition.name, Modifier.align(Alignment.CenterHorizontally))
|
||||||
|
Text("Edition in course $courseName", Modifier.align(Alignment.CenterHorizontally))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import org.jetbrains.jewel.foundation.theme.LocalContentColor
|
||||||
|
|
||||||
|
interface IconData {
|
||||||
|
val ChevronRight: ImageVector
|
||||||
|
val ChevronDown: ImageVector
|
||||||
|
val ChevronLeft: ImageVector
|
||||||
|
val Delete: ImageVector
|
||||||
|
val CirclePlus: ImageVector
|
||||||
|
val LibraryPlus: ImageVector
|
||||||
|
val Archive: ImageVector
|
||||||
|
val Unarchive: ImageVector
|
||||||
|
val FormatSize: ImageVector
|
||||||
|
val CircleFilled: ImageVector
|
||||||
|
val CircleOutline: ImageVector
|
||||||
|
val FormatListBullet: ImageVector
|
||||||
|
val FormatListNumber: ImageVector
|
||||||
|
val FormatCode: ImageVector
|
||||||
|
val ContentCopy: ImageVector
|
||||||
|
val ContentPaste: ImageVector
|
||||||
|
val FormatItalic: ImageVector
|
||||||
|
val FormatBold: ImageVector
|
||||||
|
val FormatUnderline: ImageVector
|
||||||
|
val FormatStrikethrough: ImageVector
|
||||||
|
val UserIcon: ImageVector
|
||||||
|
val UserGroupIcon: ImageVector
|
||||||
|
val AssignmentIcon: ImageVector
|
||||||
|
val Edit: ImageVector
|
||||||
|
val Check: ImageVector
|
||||||
|
val Close: ImageVector
|
||||||
|
val PersonMinus: ImageVector
|
||||||
|
val DoubleBack: ImageVector
|
||||||
|
val DoubleForward: ImageVector
|
||||||
|
val Mail: ImageVector
|
||||||
|
|
||||||
|
private class Impl(val color: Color) : IconData {
|
||||||
|
override val ChevronRight: ImageVector by lazy { ChevronRight(color) }
|
||||||
|
override val ChevronDown: ImageVector by lazy { ChevronDown(color) }
|
||||||
|
override val ChevronLeft: ImageVector by lazy { ChevronLeft(color) }
|
||||||
|
override val Delete: ImageVector by lazy { Delete(color) }
|
||||||
|
override val CirclePlus: ImageVector by lazy { CirclePlus(color) }
|
||||||
|
override val LibraryPlus: ImageVector by lazy { LibraryPlus(color) }
|
||||||
|
override val Archive: ImageVector by lazy { Archive(color) }
|
||||||
|
override val Unarchive: ImageVector by lazy { Unarchive(color) }
|
||||||
|
override val FormatSize: ImageVector by lazy { FormatSize(color) }
|
||||||
|
override val CircleFilled: ImageVector by lazy { CircleFilled(color) }
|
||||||
|
override val CircleOutline: ImageVector by lazy { CircleOutline(color) }
|
||||||
|
override val FormatListBullet: ImageVector by lazy { FormatListBullet(color) }
|
||||||
|
override val FormatListNumber: ImageVector by lazy { FormatListNumber(color) }
|
||||||
|
override val FormatCode: ImageVector by lazy { FormatCode(color) }
|
||||||
|
override val ContentCopy: ImageVector by lazy { ContentCopy(color) }
|
||||||
|
override val ContentPaste: ImageVector by lazy { ContentPaste(color) }
|
||||||
|
override val FormatItalic: ImageVector by lazy { FormatItalic(color) }
|
||||||
|
override val FormatBold: ImageVector by lazy { FormatBold(color) }
|
||||||
|
override val FormatUnderline: ImageVector by lazy { FormatUnderline(color) }
|
||||||
|
override val FormatStrikethrough: ImageVector by lazy { FormatStrikethrough(color) }
|
||||||
|
override val UserIcon: ImageVector by lazy { UserIcon(color) }
|
||||||
|
override val UserGroupIcon: ImageVector by lazy { UserGroupIcon(color) }
|
||||||
|
override val AssignmentIcon: ImageVector by lazy { AssignmentIcon(color) }
|
||||||
|
override val Edit: ImageVector by lazy { Edit(color) }
|
||||||
|
override val Check: ImageVector by lazy { Check(color) }
|
||||||
|
override val Close: ImageVector by lazy { Close(color) }
|
||||||
|
override val PersonMinus: ImageVector by lazy { PersonMinus(color) }
|
||||||
|
override val DoubleBack: ImageVector by lazy { DoubleBack(color) }
|
||||||
|
override val DoubleForward: ImageVector by lazy { DoubleForward(color) }
|
||||||
|
override val Mail: ImageVector by lazy { Mail(color) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val _cache = mutableMapOf<Color, Impl>()
|
||||||
|
operator fun get(color: Color): IconData = _cache.getOrPut(color) { Impl(color) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Composable
|
||||||
|
val Icons: IconData
|
||||||
|
get() = IconData[LocalContentColor.current]
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package com.jaytux.grader.ui
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.PathFillType
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.graphics.StrokeJoin
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.graphics.vector.path
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
val ChevronRight: ImageVector by lazy {
|
|
||||||
ImageVector.Builder(
|
|
||||||
name = "ChevronRight",
|
|
||||||
defaultWidth = 24.dp,
|
|
||||||
defaultHeight = 24.dp,
|
|
||||||
viewportWidth = 24f,
|
|
||||||
viewportHeight = 24f
|
|
||||||
).apply {
|
|
||||||
path(
|
|
||||||
fill = null,
|
|
||||||
fillAlpha = 1.0f,
|
|
||||||
stroke = SolidColor(Color(0xFF000000)),
|
|
||||||
strokeAlpha = 1.0f,
|
|
||||||
strokeLineWidth = 2f,
|
|
||||||
strokeLineCap = StrokeCap.Round,
|
|
||||||
strokeLineJoin = StrokeJoin.Round,
|
|
||||||
strokeLineMiter = 1.0f,
|
|
||||||
pathFillType = PathFillType.NonZero
|
|
||||||
) {
|
|
||||||
moveTo(9f, 18f)
|
|
||||||
lineToRelative(6f, -6f)
|
|
||||||
lineToRelative(-6f, -6f)
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
val ChevronDown: ImageVector by lazy {
|
|
||||||
ImageVector.Builder(
|
|
||||||
name = "ChevronDown",
|
|
||||||
defaultWidth = 24.dp,
|
|
||||||
defaultHeight = 24.dp,
|
|
||||||
viewportWidth = 24f,
|
|
||||||
viewportHeight = 24f
|
|
||||||
).apply {
|
|
||||||
path(
|
|
||||||
fill = null,
|
|
||||||
fillAlpha = 1.0f,
|
|
||||||
stroke = SolidColor(Color(0xFF000000)),
|
|
||||||
strokeAlpha = 1.0f,
|
|
||||||
strokeLineWidth = 2f,
|
|
||||||
strokeLineCap = StrokeCap.Round,
|
|
||||||
strokeLineJoin = StrokeJoin.Round,
|
|
||||||
strokeLineMiter = 1.0f,
|
|
||||||
pathFillType = PathFillType.NonZero
|
|
||||||
) {
|
|
||||||
moveTo(6f, 9f)
|
|
||||||
lineToRelative(6f, 6f)
|
|
||||||
lineToRelative(6f, -6f)
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
public val ChevronLeft: ImageVector by lazy {
|
|
||||||
ImageVector.Builder(
|
|
||||||
name = "ChevronLeft",
|
|
||||||
defaultWidth = 24.dp,
|
|
||||||
defaultHeight = 24.dp,
|
|
||||||
viewportWidth = 24f,
|
|
||||||
viewportHeight = 24f
|
|
||||||
).apply {
|
|
||||||
path(
|
|
||||||
fill = null,
|
|
||||||
fillAlpha = 1.0f,
|
|
||||||
stroke = SolidColor(Color(0xFF000000)),
|
|
||||||
strokeAlpha = 1.0f,
|
|
||||||
strokeLineWidth = 2f,
|
|
||||||
strokeLineCap = StrokeCap.Round,
|
|
||||||
strokeLineJoin = StrokeJoin.Round,
|
|
||||||
strokeLineMiter = 1.0f,
|
|
||||||
pathFillType = PathFillType.NonZero
|
|
||||||
) {
|
|
||||||
moveTo(15f, 18f)
|
|
||||||
lineToRelative(-6f, -6f)
|
|
||||||
lineToRelative(6f, -6f)
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.jaytux.grader.PeerEvalGrading
|
||||||
|
import com.jaytux.grader.app
|
||||||
|
import com.jaytux.grader.data.v2.Group
|
||||||
|
import com.jaytux.grader.data.v2.Student
|
||||||
|
import com.jaytux.grader.viewmodel.Grade
|
||||||
|
import com.jaytux.grader.viewmodel.Navigator
|
||||||
|
import com.jaytux.grader.viewmodel.PeerEvalsGradingVM
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import org.jetbrains.jewel.foundation.theme.LocalTextStyle
|
||||||
|
import org.jetbrains.jewel.ui.component.*
|
||||||
|
import org.jetbrains.jewel.ui.theme.colorPalette
|
||||||
|
import org.jetbrains.jewel.ui.typography
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PeerEvalsGradingTitle(data: PeerEvalGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Peer Evaluations / ${data.assignment.name} / Grading")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PeerEvalsGradingView(data: PeerEvalGrading, token: Navigator.NavToken) {
|
||||||
|
val vm = viewModel<PeerEvalsGradingVM>(key = data.assignment.id.toString()) {
|
||||||
|
PeerEvalsGradingVM(data.course, data.edition, data.assignment)
|
||||||
|
}
|
||||||
|
val groups by vm.groupList.entities
|
||||||
|
val focus by vm.focus
|
||||||
|
|
||||||
|
val selectedGroup = remember(focus, groups) { groups.getOrNull(focus) }
|
||||||
|
|
||||||
|
val students by vm.students.entities
|
||||||
|
val matrix by vm.evaluationMatrix.entities
|
||||||
|
val studentGrades by vm.studentGrades.entities
|
||||||
|
var selectedStudent by remember(selectedGroup, studentGrades) {
|
||||||
|
mutableStateOf(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Text("Grading ${vm.base.name}", style = JewelTheme.typography.h2TextStyle)
|
||||||
|
Text("Group assignment in ${vm.course.name} - ${vm.edition.name}")
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
|
||||||
|
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
|
||||||
|
QuickAGroup(idx == focus, { vm.focusGroup(idx) }, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||||
|
if (focus == -1 || selectedGroup == null) {
|
||||||
|
Box(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||||
|
Text("Select a group to start grading.", Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(Modifier.weight(0.75f).padding(15.dp)) {
|
||||||
|
Row {
|
||||||
|
IconButton({ vm.focusPrev() }, Modifier.align(Alignment.CenterVertically), enabled = focus > 0) {
|
||||||
|
Icon(Icons.DoubleBack, "Previous group")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(selectedGroup.group.name, Modifier.align(Alignment.CenterVertically), style = JewelTheme.typography.h2TextStyle)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
IconButton({ vm.focusNext() }, Modifier.align(Alignment.CenterVertically), enabled = focus < groups.size - 1) {
|
||||||
|
Icon(Icons.DoubleForward, "Next group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
matrix?.let { mat ->
|
||||||
|
students?.let { stu ->
|
||||||
|
Box(Modifier.weight(0.66f)) {
|
||||||
|
GradeTable(mat, stu, selectedGroup.group, vm.studentCriterion, vm::setEvaluation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: Box(Modifier.weight(0.66f).fillMaxWidth()) {
|
||||||
|
Text("Error: could not load evaluations for this group.", Modifier.align(Alignment.Center), color = JewelTheme.globalColors.text.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.weight(0.33f)) {
|
||||||
|
studentGrades?.let { sgs ->
|
||||||
|
val currentStudent = sgs[selectedStudent]
|
||||||
|
|
||||||
|
PrimaryScrollableTabRow(selectedStudent, Modifier.fillMaxWidth()) {
|
||||||
|
sgs.forEachIndexed { idx, st ->
|
||||||
|
Tab(idx == selectedStudent, { selectedStudent = idx }) {
|
||||||
|
Row {
|
||||||
|
Icon(Icons.UserIcon, "")
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text(st.first.name, Modifier.align(Alignment.CenterVertically))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SingleStudentGrade(currentStudent.first.name, currentStudent.second, vm.global) { grade, feedback ->
|
||||||
|
vm.setStudentGrade(currentStudent.first, grade, feedback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GradeTable(
|
||||||
|
matrix: List<PeerEvalsGradingVM.Evaluation>, students: List<Student>, group: Group,
|
||||||
|
egData: CritData, onSet: (evaluator: Student, evaluatee: Student?, group: Group, grade: Grade, feedback: String) -> Unit
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
val horScroll = rememberLazyListState()
|
||||||
|
val style = LocalTextStyle.current
|
||||||
|
val measure = rememberTextMeasurer()
|
||||||
|
val textLenMeasured = remember(matrix, students) {
|
||||||
|
students.maxOf { s ->
|
||||||
|
measure.measure(s.name, style).size.width
|
||||||
|
} + 10
|
||||||
|
}
|
||||||
|
val cellSize = 75.dp
|
||||||
|
var idx by remember(matrix, students) { mutableStateOf(0) }
|
||||||
|
var editing by remember(matrix, students) { mutableStateOf<Triple<Student, Student?, FeedbackItem?>?>(null) }
|
||||||
|
|
||||||
|
val isSelected = { from: Student, to: Student? ->
|
||||||
|
editing?.let { (f, t, _) -> f == from && t == to } ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.weight(0.66f).padding(10.dp)) {
|
||||||
|
Row {
|
||||||
|
Box { FromTo(textLenMeasured.dp) }
|
||||||
|
LazyRow(Modifier.height(textLenMeasured.dp), state = horScroll) {
|
||||||
|
item { VLine() }
|
||||||
|
items(students) { s ->
|
||||||
|
Box(
|
||||||
|
Modifier.width(cellSize).height(textLenMeasured.dp),
|
||||||
|
contentAlignment = Alignment.TopCenter
|
||||||
|
) {
|
||||||
|
var _h: Int = 0
|
||||||
|
Text(s.name, Modifier.layout { m, c ->
|
||||||
|
val p = m.measure(c.copy(minWidth = c.maxWidth, maxWidth = Constraints.Infinity))
|
||||||
|
_h = p.height
|
||||||
|
layout(p.height, p.width) { p.place(0, 0) }
|
||||||
|
}.graphicsLayer {
|
||||||
|
rotationZ = -90f
|
||||||
|
transformOrigin = TransformOrigin(0f, 0.5f)
|
||||||
|
translationX = _h.toFloat() / 2f
|
||||||
|
translationY = textLenMeasured.dp.value - 15f
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { VLine() }
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
Modifier.width(cellSize).height(textLenMeasured.dp),
|
||||||
|
contentAlignment = Alignment.TopCenter
|
||||||
|
) {
|
||||||
|
var _h: Int = 0
|
||||||
|
Text("Group Rating", Modifier.layout { m, c ->
|
||||||
|
val p = m.measure(c.copy(minWidth = c.maxWidth, maxWidth = Constraints.Infinity))
|
||||||
|
_h = p.height
|
||||||
|
layout(p.height, p.width) { p.place(0, 0) }
|
||||||
|
}.graphicsLayer {
|
||||||
|
rotationZ = -90f
|
||||||
|
transformOrigin = TransformOrigin(0f, 0.5f)
|
||||||
|
translationX = _h.toFloat() / 2f
|
||||||
|
translationY = textLenMeasured.dp.value - 15f
|
||||||
|
}, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { VLine() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MeasuredLazyColumn(key = idx) {
|
||||||
|
measuredItem { HLine() }
|
||||||
|
items(matrix) { (evaluator, groupLevel, s2s) ->
|
||||||
|
Row(Modifier.height(cellSize)) {
|
||||||
|
Column(Modifier.width(textLenMeasured.dp).align(Alignment.CenterVertically)) {
|
||||||
|
Text(evaluator.name, Modifier.width(textLenMeasured.dp))
|
||||||
|
}
|
||||||
|
LazyRow(state = horScroll) {
|
||||||
|
item { VLine() }
|
||||||
|
items(s2s) { (evaluatee, entry) ->
|
||||||
|
PEGradeWidget(
|
||||||
|
entry,
|
||||||
|
{ editing = evaluator to evaluatee app entry }, { editing = null },
|
||||||
|
isSelected(evaluator, evaluatee), Modifier.size(cellSize, cellSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { VLine() }
|
||||||
|
item {
|
||||||
|
PEGradeWidget(
|
||||||
|
groupLevel,
|
||||||
|
{ editing = evaluator to null app groupLevel }, { editing = null },
|
||||||
|
isSelected(evaluator, null), Modifier.size(cellSize, cellSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { VLine() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
measuredItem { HLine() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editing?.let {
|
||||||
|
Surface(Modifier.weight(0.33f), shape = JewelTheme.shapes.medium) {
|
||||||
|
val (evaluator, evaluatee, data) = it
|
||||||
|
EditS2SOrS2G(evaluator.name, evaluatee?.name ?: group.name, data, egData) { grade, feedback ->
|
||||||
|
onSet(evaluator, evaluatee, group, grade, feedback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: Box(Modifier.weight(0.33f)) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditS2SOrS2G(evaluator: String, evaluatee: String, current: FeedbackItem?, critData: CritData, onUpdate: (Grade, String) -> Unit) =
|
||||||
|
Column(Modifier.padding(10.dp).fillMaxHeight()) {
|
||||||
|
println("Recomposing editor for $evaluator -> $evaluatee with current ${current?.grade}")
|
||||||
|
var grade by remember(evaluator, evaluatee, current) { mutableStateOf(gradeState(critData, current?.grade)) }
|
||||||
|
var text by remember(evaluator, evaluatee, current) { mutableStateOf(current?.feedback ?: "") }
|
||||||
|
|
||||||
|
Text(evaluatee, style = JewelTheme.typography.h2TextStyle)
|
||||||
|
Text("Evaluated by $evaluator", style = JewelTheme.typography.regular, fontStyle = FontStyle.Italic)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
GradePicker(grade, key = evaluator to evaluatee to current) { grade = it }
|
||||||
|
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 10, modifier = Modifier.fillMaxWidth())
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
DefaultButton({ onUpdate(grade, text) }, Modifier.padding(horizontal = 20.dp).fillMaxWidth()) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SingleStudentGrade(name: String, current: FeedbackItem?, critData: CritData, onUpdate: (Grade, String) -> Unit) = Column {
|
||||||
|
var grade by remember(name, critData) { mutableStateOf(gradeState(critData, current?.grade)) }
|
||||||
|
var text by remember(name) { mutableStateOf(current?.feedback ?: "") }
|
||||||
|
|
||||||
|
GradePicker(grade, key = critData to current app name) { grade = it }
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 5, modifier = Modifier.fillMaxWidth().weight(1f))
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
DefaultButton({ onUpdate(grade, text) }, Modifier.padding(horizontal = 20.dp).fillMaxWidth()) {
|
||||||
|
Text("Save grade and feedback")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
|
|
||||||
import androidx.compose.material.icons.filled.Circle
|
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
|
||||||
import androidx.compose.material.icons.filled.ContentPaste
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -29,13 +22,19 @@ import com.jaytux.grader.loadClipboard
|
|||||||
import com.jaytux.grader.toClipboard
|
import com.jaytux.grader.toClipboard
|
||||||
import com.mohamedrejeb.richeditor.model.RichTextState
|
import com.mohamedrejeb.richeditor.model.RichTextState
|
||||||
import com.mohamedrejeb.richeditor.ui.material.OutlinedRichTextEditor
|
import com.mohamedrejeb.richeditor.ui.material.OutlinedRichTextEditor
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import org.jetbrains.jewel.foundation.theme.LocalContentColor
|
||||||
|
import org.jetbrains.jewel.ui.component.*
|
||||||
|
import org.jetbrains.jewel.ui.component.styling.IconButtonStyle
|
||||||
|
import org.jetbrains.jewel.ui.theme.iconButtonStyle
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RichTextStyleRow(
|
fun RichTextStyleRow(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
state: RichTextState,
|
state: RichTextState,
|
||||||
) {
|
) {
|
||||||
val clip = LocalClipboardManager.current
|
val clip = LocalClipboardManager.current // I know this is deprecated, but I won't figure out the Clipboard API now
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Row(modifier.fillMaxWidth()) {
|
Row(modifier.fillMaxWidth()) {
|
||||||
@@ -53,7 +52,7 @@ fun RichTextStyleRow(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold,
|
isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold,
|
||||||
icon = Icons.Outlined.FormatBold
|
icon = Icons.FormatBold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +66,7 @@ fun RichTextStyleRow(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic,
|
isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic,
|
||||||
icon = Icons.Outlined.FormatItalic
|
icon = Icons.FormatItalic
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +80,7 @@ fun RichTextStyleRow(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true,
|
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true,
|
||||||
icon = Icons.Outlined.FormatUnderlined
|
icon = Icons.FormatUnderline
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +94,7 @@ fun RichTextStyleRow(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true,
|
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true,
|
||||||
icon = Icons.Outlined.FormatStrikethrough
|
icon = Icons.FormatStrikethrough
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ fun RichTextStyleRow(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
isSelected = state.currentSpanStyle.fontSize == 28.sp,
|
isSelected = state.currentSpanStyle.fontSize == 28.sp,
|
||||||
icon = Icons.Outlined.FormatSize
|
icon = Icons.FormatSize
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +122,7 @@ fun RichTextStyleRow(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
isSelected = state.currentSpanStyle.color == Color.Red,
|
isSelected = state.currentSpanStyle.color == Color.Red,
|
||||||
icon = Icons.Filled.Circle,
|
icon = Icons.CircleFilled,
|
||||||
tint = Color.Red
|
tint = Color.Red
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -138,7 +137,7 @@ fun RichTextStyleRow(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
isSelected = state.currentSpanStyle.background == Color.Yellow,
|
isSelected = state.currentSpanStyle.background == Color.Yellow,
|
||||||
icon = Icons.Outlined.Circle,
|
icon = Icons.CircleOutline,
|
||||||
tint = Color.Yellow
|
tint = Color.Yellow
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -158,7 +157,7 @@ fun RichTextStyleRow(
|
|||||||
state.toggleUnorderedList()
|
state.toggleUnorderedList()
|
||||||
},
|
},
|
||||||
isSelected = state.isUnorderedList,
|
isSelected = state.isUnorderedList,
|
||||||
icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
|
icon = Icons.FormatListBullet,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +167,7 @@ fun RichTextStyleRow(
|
|||||||
state.toggleOrderedList()
|
state.toggleOrderedList()
|
||||||
},
|
},
|
||||||
isSelected = state.isOrderedList,
|
isSelected = state.isOrderedList,
|
||||||
icon = Icons.Outlined.FormatListNumbered,
|
icon = Icons.FormatListNumber,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,16 +186,16 @@ fun RichTextStyleRow(
|
|||||||
state.toggleCodeSpan()
|
state.toggleCodeSpan()
|
||||||
},
|
},
|
||||||
isSelected = state.isCodeSpan,
|
isSelected = state.isCodeSpan,
|
||||||
icon = Icons.Outlined.Code,
|
icon = Icons.FormatCode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton({ state.toClipboard(clip) }) {
|
IconButton({ scope.launch { state.toClipboard(clip) } }) {
|
||||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy markdown")
|
Icon(Icons.ContentCopy, contentDescription = "Copy markdown")
|
||||||
}
|
}
|
||||||
IconButton({ state.loadClipboard(clip, scope) }) {
|
IconButton({ scope.launch { state.loadClipboard(clip, scope) } }) {
|
||||||
Icon(Icons.Default.ContentPaste, contentDescription = "Paste markdown")
|
Icon(Icons.ContentPaste, contentDescription = "Paste markdown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,13 +214,7 @@ fun RichTextStyleButton(
|
|||||||
// (Happens only on Desktop)
|
// (Happens only on Desktop)
|
||||||
.focusProperties { canFocus = false },
|
.focusProperties { canFocus = false },
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
colors = IconButtonDefaults.iconButtonColors(
|
style = IconButtonStyle(JewelTheme.iconButtonStyle.colors, JewelTheme.iconButtonStyle.metrics) // TODO: color swapping depending on isSelected
|
||||||
contentColor = if (isSelected) {
|
|
||||||
MaterialTheme.colorScheme.onPrimary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onBackground
|
|
||||||
},
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
@@ -230,7 +223,7 @@ fun RichTextStyleButton(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(
|
.background(
|
||||||
color = if (isSelected) {
|
color = if (isSelected) {
|
||||||
MaterialTheme.colorScheme.primary
|
JewelTheme.globalColors.text.disabledSelected
|
||||||
} else {
|
} else {
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.jaytux.grader.GroupGrading
|
||||||
|
import com.jaytux.grader.SoloGrading
|
||||||
|
import com.jaytux.grader.viewmodel.GroupsGradingVM
|
||||||
|
import com.jaytux.grader.viewmodel.Navigator
|
||||||
|
import com.jaytux.grader.viewmodel.SolosGradingVM
|
||||||
|
import org.jetbrains.jewel.ui.component.Text
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SolosGradingTitle(data: SoloGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Individual Assignments / ${data.assignment.name} / Grading")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SolosGradingView(data: SoloGrading, token: Navigator.NavToken) {
|
||||||
|
val vm = viewModel<SolosGradingVM>(key = data.assignment.id.toString()) {
|
||||||
|
SolosGradingVM(data.course, data.edition, data.assignment)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.jaytux.grader.data.v2.Student
|
||||||
|
import com.jaytux.grader.startEmail
|
||||||
|
import com.jaytux.grader.viewmodel.EditionVM
|
||||||
|
import com.jaytux.grader.viewmodel.SnackVM
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import org.jetbrains.jewel.foundation.theme.LocalTextStyle
|
||||||
|
import org.jetbrains.jewel.ui.component.*
|
||||||
|
import org.jetbrains.jewel.ui.typography
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
|
||||||
|
val students by vm.studentList.entities
|
||||||
|
val focus by vm.focusIndex
|
||||||
|
val snacks = viewModel<SnackVM> { SnackVM() }
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.25f).fillMaxHeight()) {
|
||||||
|
ListOrEmpty(students, { Text("No students yet.") }) { idx, it ->
|
||||||
|
QuickStudent(idx, it, vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||||
|
if(focus == -1) {
|
||||||
|
Box(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||||
|
Text("Select a student to view details.", Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val groups by vm.studentGroups.entities
|
||||||
|
val grades by vm.studentGrades.entities
|
||||||
|
|
||||||
|
Column(Modifier.weight(0.75f).padding(15.dp)) {
|
||||||
|
Surface(Modifier.padding(10.dp).fillMaxWidth(), shape = JewelTheme.shapes.medium) {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(students[focus].name, style = JewelTheme.typography.h2TextStyle)
|
||||||
|
if(students[focus].contact.isNotBlank()) {
|
||||||
|
IconButton({ startEmail(listOf(students[focus].contact)) { snacks.show(it) } }) {
|
||||||
|
Icon(Icons.Mail, "Send email", Modifier.fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
var editing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Text("Contact: ", Modifier.align(Alignment.CenterVertically).padding(start = 15.dp))
|
||||||
|
if(!editing) {
|
||||||
|
if (students[focus].contact.isBlank()) {
|
||||||
|
Text(
|
||||||
|
"No contact info.",
|
||||||
|
Modifier.padding(start = 5.dp),
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
color = LocalTextStyle.current.color.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Text(students[focus].contact, Modifier.padding(start = 5.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Icon(Icons.Edit, "Edit contact info", Modifier.clickable { editing = true })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var mod by remember(focus, students[focus].contact, students[focus].id.value) { mutableStateOf(students[focus].contact) }
|
||||||
|
OutlinedTextField(mod, { mod = it })
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Icon(Icons.Check, "Confirm edit", Modifier.align(Alignment.CenterVertically).clickable {
|
||||||
|
vm.modStudent(students[focus], null, mod, null)
|
||||||
|
editing = false
|
||||||
|
})
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Icon(Icons.Close, "Cancel edit", Modifier.align(Alignment.CenterVertically).clickable { editing = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text("Groups:", style = JewelTheme.typography.h2TextStyle)
|
||||||
|
groups?.let { gList ->
|
||||||
|
if(gList.isEmpty()) null
|
||||||
|
else {
|
||||||
|
FlowRow(Modifier.padding(start = 10.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
gList.forEach { group ->
|
||||||
|
Surface(shape = JewelTheme.shapes.small) {
|
||||||
|
Box(Modifier.padding(5.dp).clickable { vm.focus(group.first) }) {
|
||||||
|
Text("${group.first.name} (${group.second ?: "no role"})", Modifier.padding(5.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: Text("Not a member of any group.", Modifier.padding(start = 15.dp), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
Column(Modifier.weight(0.33f)) {
|
||||||
|
var mod by remember(focus, students[focus].note, students[focus].id.value) { mutableStateOf(students[focus].note) }
|
||||||
|
|
||||||
|
Text("Internal Note:")
|
||||||
|
OutlinedTextField(
|
||||||
|
mod,
|
||||||
|
{ mod = it },
|
||||||
|
singleLine = false,
|
||||||
|
minLines = 5,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
if(mod != students[focus].note) {
|
||||||
|
Row {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
DefaultButton({ vm.modStudent(students[focus], null, null, mod) }) {
|
||||||
|
Text("Update note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Column(Modifier.weight(0.66f)) {
|
||||||
|
Text("Grade Summary: ", style = JewelTheme.typography.h2TextStyle)
|
||||||
|
Surface(shape = JewelTheme.shapes.medium, color = Color.White) {
|
||||||
|
LazyColumn {
|
||||||
|
item {
|
||||||
|
Surface {
|
||||||
|
Row(Modifier.padding(10.dp)) {
|
||||||
|
Text("Assignment", Modifier.weight(0.66f))
|
||||||
|
Text("Grade", Modifier.weight(0.33f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(grades ?: listOf()) {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Row {
|
||||||
|
Text(it.assignment.name, Modifier.weight(0.66f))
|
||||||
|
it.grade?.render(Modifier.weight(0.33f))
|
||||||
|
?: Text("---", Modifier.weight(0.33f), color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
|
||||||
|
it.asMember?.let { g ->
|
||||||
|
Row(Modifier.padding(start = 10.dp)) {
|
||||||
|
Text("As member of ${g.name}", fontStyle = FontStyle.Italic)
|
||||||
|
if (it.overridden) Text(" (overridden)", fontStyle = FontStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if((grades ?: listOf()).isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||||
|
Text("No grades yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QuickStudent(idx: Int, student: Student, vm: EditionVM) {
|
||||||
|
val focus by vm.focusIndex
|
||||||
|
Surface(markFocused = focus == idx, shape = JewelTheme.shapes.small) {
|
||||||
|
Column(Modifier.fillMaxWidth().clickable { vm.focus(idx) }.padding(10.dp)) {
|
||||||
|
Text(student.name, fontWeight = FontWeight.Bold)
|
||||||
|
if(student.contact.isBlank())
|
||||||
|
Text("No contact info.", fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||||
|
else Text(student.contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,72 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import com.jaytux.grader.data.v2.BaseFeedback
|
||||||
|
import com.jaytux.grader.data.v2.CategoricGrade
|
||||||
|
import com.jaytux.grader.data.v2.Criterion
|
||||||
|
import com.jaytux.grader.data.v2.GradeType
|
||||||
|
import com.jaytux.grader.data.v2.NumericGrade
|
||||||
|
import com.jaytux.grader.viewmodel.Grade
|
||||||
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextUnit.toDp(): Dp = with(LocalDensity.current) { value.toDp() }
|
fun TextUnit.toDp(): Dp = with(LocalDensity.current) { value.toDp() }
|
||||||
|
|
||||||
|
data class CritData(val criterion: Criterion, val cat: CategoricGrade?, val num: NumericGrade?) {
|
||||||
|
companion object {
|
||||||
|
context(trns: Transaction)
|
||||||
|
fun fromDb(c: Criterion) = CritData(c, c.categoricGrade, c.numericGrade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FeedbackItem(val base: BaseFeedback, val grade: Grade, val feedback: String) {
|
||||||
|
companion object {
|
||||||
|
context(trns: Transaction)
|
||||||
|
fun fromDb(f: BaseFeedback): FeedbackItem = when(f.criterion.gradeType) {
|
||||||
|
GradeType.CATEGORIC -> {
|
||||||
|
val categoric = f.criterion.categoricGrade!!
|
||||||
|
val options = categoric.options.toList()
|
||||||
|
Grade.Categoric(f.gradeCategoric ?: options.first(), options, categoric)
|
||||||
|
}
|
||||||
|
GradeType.NUMERIC -> Grade.Numeric(f.gradeNumeric ?: 0.0, f.criterion.numericGrade!!)
|
||||||
|
GradeType.PERCENTAGE -> Grade.Percentage(f.gradeNumeric ?: 0.0)
|
||||||
|
GradeType.NONE -> Grade.FreeText(f.gradeFreeText ?: "")
|
||||||
|
}.let { FeedbackItem(f, it, f.feedback) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gradeState(type: GradeType, categoric: CategoricGrade?, numeric: NumericGrade?, current: Grade?): Grade = transaction {
|
||||||
|
if(current == null) {
|
||||||
|
println("gradeState: current is null, defaulting")
|
||||||
|
Grade.default(type, categoric, numeric)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
when(type) {
|
||||||
|
GradeType.CATEGORIC ->
|
||||||
|
if(current is Grade.Categoric && current.grade.id == categoric?.id) {
|
||||||
|
println("gradeState: current categoric grade is valid, keeping")
|
||||||
|
current
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
println("gradeState: current categoric grade is invalid, defaulting [${current is Grade.Categoric} (${current::class.java.simpleName}), ${(current as? Grade.Categoric)?.grade?.name} == ${categoric?.name}]")
|
||||||
|
Grade.default(GradeType.CATEGORIC, categoric, numeric)
|
||||||
|
}
|
||||||
|
GradeType.NUMERIC ->
|
||||||
|
if(current is Grade.Numeric && current.grade.id == numeric?.id) {
|
||||||
|
println("gradeState: current numeric grade is valid, keeping")
|
||||||
|
current
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
println("gradeState: current numeric grade is invalid, defaulting [${current is Grade.Numeric}, ${(current as? Grade.Numeric)?.grade?.id == numeric?.id}]")
|
||||||
|
Grade.default(GradeType.NUMERIC, categoric, numeric)
|
||||||
|
}
|
||||||
|
GradeType.PERCENTAGE ->
|
||||||
|
current as? Grade.Percentage ?: Grade.default(GradeType.PERCENTAGE, categoric, numeric)
|
||||||
|
GradeType.NONE ->
|
||||||
|
current as? Grade.FreeText ?: Grade.default(GradeType.NONE, categoric, numeric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gradeState(crit: CritData, current: Grade?): Grade = gradeState(crit.criterion.gradeType, crit.cat, crit.num, current)
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package com.jaytux.grader.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.DpSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.DialogWindow
|
|
||||||
import androidx.compose.ui.window.WindowPosition
|
|
||||||
import androidx.compose.ui.window.rememberDialogState
|
|
||||||
import com.jaytux.grader.maxN
|
|
||||||
import com.jaytux.grader.viewmodel.GroupState
|
|
||||||
import com.jaytux.grader.viewmodel.StudentState
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StudentView(state: StudentState, nav: Navigators) {
|
|
||||||
val groups by state.groups.entities
|
|
||||||
val courses by state.courseEditions.entities
|
|
||||||
val groupGrades by state.groupGrades.entities
|
|
||||||
val soloGrades by state.soloGrades.entities
|
|
||||||
|
|
||||||
Column(Modifier.padding(10.dp)) {
|
|
||||||
Row {
|
|
||||||
Column(Modifier.weight(0.45f)) {
|
|
||||||
Column(Modifier.padding(10.dp).weight(0.35f)) {
|
|
||||||
Spacer(Modifier.height(10.dp))
|
|
||||||
InteractToEdit(state.student.name, { state.update { this.name = it } }, "Name")
|
|
||||||
InteractToEdit(state.student.contact, { state.update { this.contact = it } }, "Contact")
|
|
||||||
InteractToEdit(state.student.note, { state.update { this.note = it } }, "Note", singleLine = false)
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(0.20f)) {
|
|
||||||
Text("Courses", style = MaterialTheme.typography.headlineSmall)
|
|
||||||
ListOrEmpty(courses, { Text("Not a member of any course") }) { _, it ->
|
|
||||||
val (ed, course) = it
|
|
||||||
Text("${course.name} (${ed.name})", style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(0.45f)) {
|
|
||||||
Text("Groups", style = MaterialTheme.typography.headlineSmall)
|
|
||||||
ListOrEmpty(groups, { Text("Not a member of any group") }) { _, it ->
|
|
||||||
val (group, c) = it
|
|
||||||
val (course, ed) = c
|
|
||||||
Row(Modifier.clickable { nav.group(group) }) {
|
|
||||||
Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Spacer(Modifier.width(5.dp))
|
|
||||||
Text(
|
|
||||||
"(in course $course ($ed))",
|
|
||||||
Modifier.align(Alignment.Bottom),
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(0.55f)) {
|
|
||||||
Text("Courses", style = MaterialTheme.typography.headlineSmall)
|
|
||||||
LazyColumn {
|
|
||||||
item {
|
|
||||||
Text("As group member", fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
items(groupGrades) {
|
|
||||||
groupGradeWidget(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
Text("Solo assignments", fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
items(soloGrades) {
|
|
||||||
soloGradeWidget(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun groupGradeWidget(gg: StudentState.LocalGroupGrade) {
|
|
||||||
val (group, assignment, gGrade, iGrade) = gg
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
Row(Modifier.padding(5.dp)) {
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Surface(
|
|
||||||
Modifier.clickable { expanded = !expanded }.fillMaxWidth(),
|
|
||||||
tonalElevation = 5.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(5.dp)) {
|
|
||||||
Text("${assignment.maxN(25)} (${iGrade ?: gGrade ?: "no grade yet"})")
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
Row {
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Column {
|
|
||||||
ItalicAndNormal("Assignment: ", assignment)
|
|
||||||
ItalicAndNormal("Group name: ", group)
|
|
||||||
ItalicAndNormal("Group grade: ", gGrade ?: "no grade yet")
|
|
||||||
ItalicAndNormal("Individual grade: ", iGrade ?: "no individual grade")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun soloGradeWidget(sg: StudentState.LocalSoloGrade) {
|
|
||||||
val (assignment, grade) = sg
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
Row(Modifier.padding(5.dp)) {
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Surface(
|
|
||||||
Modifier.clickable { expanded = !expanded }.fillMaxWidth(),
|
|
||||||
tonalElevation = 5.dp,
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(5.dp)) {
|
|
||||||
Text("${assignment.maxN(25)} (${grade ?: "no grade yet"})")
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
Row {
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Column {
|
|
||||||
ItalicAndNormal("Assignment: ", assignment)
|
|
||||||
ItalicAndNormal("Individual grade: ", grade ?: "no grade yet")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupView(state: GroupState, nav: Navigators) {
|
|
||||||
val members by state.members.entities
|
|
||||||
val available by state.availableStudents.entities
|
|
||||||
val allRoles by state.roles.entities
|
|
||||||
|
|
||||||
var pickRole: Pair<String?, (String?) -> Unit>? by remember { mutableStateOf(null) }
|
|
||||||
|
|
||||||
Column(Modifier.padding(10.dp)) {
|
|
||||||
Row {
|
|
||||||
Column(Modifier.weight(0.5f)) {
|
|
||||||
Text("Students", style = MaterialTheme.typography.headlineSmall)
|
|
||||||
ListOrEmpty(members, { Text("No students in this group") }) { _, it ->
|
|
||||||
val (student, role) = it
|
|
||||||
Row(Modifier.clickable { nav.student(student) }) {
|
|
||||||
Text(
|
|
||||||
"${student.name} (${role ?: "no role"})",
|
|
||||||
Modifier.weight(0.75f).align(Alignment.CenterVertically),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
IconButton({ pickRole = role to { r -> state.updateRole(student, r) } }, Modifier.weight(0.12f)) {
|
|
||||||
Icon(Icons.Default.Edit, "Change role")
|
|
||||||
}
|
|
||||||
IconButton({ state.removeStudent(student) }, Modifier.weight(0.12f)) {
|
|
||||||
Icon(Icons.Default.Delete, "Remove student")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(0.5f)) {
|
|
||||||
Text("Available students", style = MaterialTheme.typography.headlineSmall)
|
|
||||||
ListOrEmpty(available, { Text("No students available") }) { _, it ->
|
|
||||||
Row(Modifier.padding(5.dp).clickable { nav.student(it) }) {
|
|
||||||
IconButton({ state.addStudent(it) }) {
|
|
||||||
Icon(ChevronLeft, "Add student")
|
|
||||||
}
|
|
||||||
Text(it.name, Modifier.weight(0.75f).align(Alignment.CenterVertically), style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pickRole?.let {
|
|
||||||
val (curr, onPick) = it
|
|
||||||
RolePicker(allRoles, curr, { pickRole = null }, { role -> onPick(role); pickRole = null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RolePicker(used: List<String>, curr: String?, onClose: () -> Unit, onSave: (String?) -> Unit) = DialogWindow(
|
|
||||||
onCloseRequest = onClose,
|
|
||||||
state = rememberDialogState(size = DpSize(400.dp, 500.dp), position = WindowPosition(Alignment.Center))
|
|
||||||
) {
|
|
||||||
Surface(Modifier.fillMaxSize().padding(10.dp)) {
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
var role by remember { mutableStateOf(curr ?: "") }
|
|
||||||
Column {
|
|
||||||
Text("Used roles:")
|
|
||||||
LazyColumn(Modifier.weight(1.0f).padding(5.dp)) {
|
|
||||||
items(used) {
|
|
||||||
Surface(Modifier.fillMaxWidth().clickable { role = it }, tonalElevation = 5.dp) {
|
|
||||||
Text(it, Modifier.padding(5.dp))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(5.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OutlinedTextField(role, { role = it }, Modifier.fillMaxWidth())
|
|
||||||
CancelSaveRow(true, onClose) {
|
|
||||||
onSave(role.ifBlank { null })
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +1,76 @@
|
|||||||
package com.jaytux.grader.ui
|
package com.jaytux.grader.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
|
||||||
import androidx.compose.ui.input.key.key
|
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
|
||||||
import androidx.compose.ui.layout.SubcomposeLayout
|
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.text.TextRange
|
|
||||||
import androidx.compose.ui.text.capitalize
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.text.intl.Locale
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.*
|
import androidx.compose.ui.window.*
|
||||||
import com.jaytux.grader.data.Course
|
import com.jaytux.grader.maxN
|
||||||
import com.jaytux.grader.data.Edition
|
import com.jaytux.grader.viewmodel.Grade
|
||||||
import com.jaytux.grader.viewmodel.PeerEvaluationState
|
|
||||||
import com.mohamedrejeb.richeditor.model.RichTextState
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.*
|
import kotlinx.datetime.*
|
||||||
import kotlinx.datetime.TimeZone
|
import org.jetbrains.jewel.foundation.Stroke
|
||||||
|
import org.jetbrains.jewel.foundation.modifier.border
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import org.jetbrains.jewel.foundation.theme.LocalTextStyle
|
||||||
|
import org.jetbrains.jewel.ui.Outline
|
||||||
|
import org.jetbrains.jewel.ui.component.*
|
||||||
|
import org.jetbrains.jewel.ui.theme.colorPalette
|
||||||
|
import org.jetbrains.jewel.ui.theme.iconData
|
||||||
|
import org.jetbrains.jewel.ui.typography
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "Cancel", saveText: String = "Save", onSave: () -> Unit) {
|
fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "Cancel", saveText: String = "Save", onSave: () -> Unit) {
|
||||||
Row {
|
Row {
|
||||||
Button({ onCancel() }, Modifier.weight(0.45f)) { Text(cancelText) }
|
DefaultButton({ onCancel() }, Modifier.weight(0.45f)) { Text(cancelText) }
|
||||||
Spacer(Modifier.weight(0.1f))
|
Spacer(Modifier.weight(0.1f))
|
||||||
Button({ onSave() }, Modifier.weight(0.45f), enabled = canSave) { Text(saveText) }
|
DefaultButton({ onSave() }, Modifier.weight(0.45f), enabled = canSave) { Text(saveText) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun <T> TabLayout(
|
|
||||||
options: List<T>,
|
|
||||||
currentIndex: Int,
|
|
||||||
onSelect: (Int) -> Unit,
|
|
||||||
optionContent: @Composable (T) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) = Column(modifier) {
|
|
||||||
TabRow(currentIndex) {
|
|
||||||
options.forEachIndexed { idx, it ->
|
|
||||||
Tab(
|
|
||||||
selected = idx == currentIndex,
|
|
||||||
onClick = { onSelect(idx) },
|
|
||||||
text = { optionContent(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, current: String = "", onSave: (String) -> Unit) = DialogWindow(
|
fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, current: String = "", onSave: (String) -> Unit) = DialogWindow(
|
||||||
onCloseRequest = onClose,
|
onCloseRequest = onClose,
|
||||||
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||||
) {
|
) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
|
||||||
Surface(Modifier.fillMaxSize()) {
|
Surface(Modifier.fillMaxSize()) {
|
||||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||||
var name by remember(current) { mutableStateOf(current) }
|
var name by remember(current) { mutableStateOf(current) }
|
||||||
Column(Modifier.align(Alignment.Center)) {
|
Column(Modifier.align(Alignment.Center)) {
|
||||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text(label) }, isError = name in taken)
|
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text(label) }, isError = name in taken)
|
||||||
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
||||||
onSave(name)
|
onSave(name)
|
||||||
onClose()
|
onClose()
|
||||||
@@ -88,6 +78,8 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -100,7 +92,7 @@ fun ConfirmDeleteDialog(
|
|||||||
onCloseRequest = onExit,
|
onCloseRequest = onExit,
|
||||||
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||||
) {
|
) {
|
||||||
Surface(Modifier.width(400.dp).height(300.dp), tonalElevation = 5.dp) {
|
Surface(Modifier.width(400.dp).height(300.dp)) {
|
||||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||||
Column(Modifier.align(Alignment.Center)) {
|
Column(Modifier.align(Alignment.Center)) {
|
||||||
Text("You are about to delete $deleteAWhat.", Modifier.padding(10.dp))
|
Text("You are about to delete $deleteAWhat.", Modifier.padding(10.dp))
|
||||||
@@ -118,299 +110,25 @@ fun ConfirmDeleteDialog(
|
|||||||
fun <T> ListOrEmpty(
|
fun <T> ListOrEmpty(
|
||||||
data: List<T>,
|
data: List<T>,
|
||||||
onEmpty: @Composable ColumnScope.() -> Unit,
|
onEmpty: @Composable ColumnScope.() -> Unit,
|
||||||
addOptions: @Composable ColumnScope.() -> Unit,
|
modifier: Modifier = Modifier,
|
||||||
addAfterLazy: Boolean = true,
|
|
||||||
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
|
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
|
||||||
) {
|
) {
|
||||||
if(data.isEmpty()) {
|
if(data.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(modifier) {
|
||||||
Column(Modifier.align(Alignment.Center)) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
onEmpty()
|
Column(Modifier.align(Alignment.Center)) {
|
||||||
addOptions()
|
onEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Column {
|
Column(modifier) {
|
||||||
LazyColumn(Modifier.weight(1f)) {
|
LazyColumn(Modifier.weight(1f)) {
|
||||||
itemsIndexed(data) { idx, it ->
|
itemsIndexed(data) { idx, it ->
|
||||||
item(idx, it)
|
item(idx, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!addAfterLazy) item { addOptions() }
|
|
||||||
}
|
}
|
||||||
if(addAfterLazy) addOptions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun <T> ListOrEmpty(
|
|
||||||
data: List<T>,
|
|
||||||
emptyText: @Composable ColumnScope.() -> Unit,
|
|
||||||
addText: @Composable RowScope.() -> Unit,
|
|
||||||
onAdd: () -> Unit,
|
|
||||||
addAfterLazy: Boolean = true,
|
|
||||||
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
|
|
||||||
) = ListOrEmpty(
|
|
||||||
data, emptyText,
|
|
||||||
{ Button(onAdd, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) { addText() } },
|
|
||||||
addAfterLazy,
|
|
||||||
item
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun <T> ListOrEmpty(
|
|
||||||
data: List<T>,
|
|
||||||
emptyText: @Composable ColumnScope.() -> Unit,
|
|
||||||
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
|
|
||||||
) {
|
|
||||||
if(data.isEmpty()) {
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
|
||||||
Column(Modifier.align(Alignment.Center)) {
|
|
||||||
emptyText()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Column {
|
|
||||||
LazyColumn(Modifier.padding(5.dp).weight(1f)) {
|
|
||||||
itemsIndexed(data) { idx, it ->
|
|
||||||
item(idx, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InteractToEdit(
|
|
||||||
content: String, onSave: (String) -> Unit, pre: String, modifier: Modifier = Modifier,
|
|
||||||
w1: Float = 0.75f, w2: Float = 0.25f,
|
|
||||||
singleLine: Boolean = true
|
|
||||||
) {
|
|
||||||
var text by remember(content) { mutableStateOf(content) }
|
|
||||||
|
|
||||||
Row(modifier.padding(5.dp)) {
|
|
||||||
val base = if(singleLine) Modifier.align(Alignment.CenterVertically) else Modifier
|
|
||||||
OutlinedTextField(
|
|
||||||
text, { text = it }, base.weight(w1), label = { Text(pre) },
|
|
||||||
singleLine = singleLine, minLines = if(singleLine) 1 else 5
|
|
||||||
)
|
|
||||||
IconButton({ onSave(text) }, base.weight(w2)) { Icon(Icons.Default.Check, "Save") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PaneHeader(name: String, type: String, course: Course, edition: Edition) = Column {
|
|
||||||
Text(name, style = MaterialTheme.typography.headlineMedium)
|
|
||||||
Text("${type.capitalize(Locale.current)} in ${course.name} (${edition.name})", fontStyle = FontStyle.Italic)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PaneHeader(name: String, type: String, courseEdition: Pair<Course, Edition>) = PaneHeader(name, type, courseEdition.first, courseEdition.second)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun AutocompleteLineField__(
|
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,7 +146,7 @@ fun FromTo(size: Dp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
Text("Evaluated", Modifier.graphicsLayer {
|
Text("Evaluatee", Modifier.graphicsLayer {
|
||||||
rotationZ = -90f
|
rotationZ = -90f
|
||||||
translationX = w - 15f
|
translationX = w - 15f
|
||||||
translationY = h - 15f
|
translationY = h - 15f
|
||||||
@@ -438,16 +156,34 @@ fun FromTo(size: Dp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Selectable(
|
||||||
|
isSelected: Boolean,
|
||||||
|
onSelect: () -> Unit, onDeselect: () -> Unit,
|
||||||
|
unselectedElevation: Dp = 0.dp, selectedElevation: Dp = 50.dp,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
Modifier.fillMaxWidth().clickable { if(isSelected) onDeselect() else onSelect() },
|
||||||
|
markFocused = isSelected,
|
||||||
|
shape = JewelTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PEGradeWidget(
|
fun PEGradeWidget(
|
||||||
grade: PeerEvaluationState.Student2StudentEntry?,
|
feedback: FeedbackItem?,
|
||||||
onSelect: () -> Unit, onDeselect: () -> Unit,
|
onSelect: () -> Unit, onDeselect: () -> Unit,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) = Box(modifier.padding(2.dp)) {
|
) = Box(modifier.padding(2.dp)) {
|
||||||
Selectable(isSelected, onSelect, onDeselect) {
|
Selectable(isSelected, onSelect, onDeselect) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(grade?.let { if(it.grade.isNotBlank()) it.grade else if(it.feedback.isNotBlank()) "(other)" else null } ?: "none")
|
feedback?.grade?.render() ?: Text("(none)", fontStyle = FontStyle.Italic)
|
||||||
|
// Text(grade?.let { if(it.grade.isNotBlank()) it.grade else if(it.feedback.isNotBlank()) "(other)" else null } ?: "none")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,4 +195,232 @@ fun VLine(width: Dp = 1.dp, color: Color = Color.Black) = Spacer(Modifier.fillMa
|
|||||||
fun MeasuredLazyItemScope.HLine(height: Dp = 1.dp, color: Color = Color.Black) {
|
fun MeasuredLazyItemScope.HLine(height: Dp = 1.dp, color: Color = Color.Black) {
|
||||||
val width by measuredWidth()
|
val width by measuredWidth()
|
||||||
Spacer(Modifier.width(width).height(height).background(color))
|
Spacer(Modifier.width(width).height(height).background(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RolePicker(used: List<String>, curr: String?, onClose: () -> Unit, onSave: (String?) -> Unit) = DialogWindow(
|
||||||
|
onCloseRequest = onClose,
|
||||||
|
state = rememberDialogState(size = DpSize(400.dp, 500.dp), position = WindowPosition(Alignment.Center))
|
||||||
|
) {
|
||||||
|
Surface(Modifier.fillMaxSize().padding(10.dp)) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
var role by remember { mutableStateOf(curr ?: "") }
|
||||||
|
Column {
|
||||||
|
Text("Used roles:")
|
||||||
|
LazyColumn(Modifier.weight(1.0f).padding(5.dp)) {
|
||||||
|
items(used) {
|
||||||
|
Surface(Modifier.fillMaxWidth().clickable { role = it }) {
|
||||||
|
Text(it, Modifier.padding(5.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(role, { role = it }, Modifier.fillMaxWidth())
|
||||||
|
CancelSaveRow(true, onClose) {
|
||||||
|
onSave(role.ifBlank { null })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GradePicker(grade: Grade, modifier: Modifier = Modifier, key: Any = Unit, onUpdate: (Grade) -> Unit) = Row(modifier) { // TODO: fix UI to remove save-buttons (instead wait fo end of editing)
|
||||||
|
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
|
||||||
|
|
||||||
|
when(grade) {
|
||||||
|
is Grade.Categoric -> {
|
||||||
|
if(grade.options.size <= 5) {
|
||||||
|
Column {
|
||||||
|
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||||
|
grade.options.forEachIndexed { idx, opt ->
|
||||||
|
println("Rendering opt ${opt.option} (index $idx) ~ current value: ${grade.value.option})")
|
||||||
|
SegmentedButton(
|
||||||
|
grade.value.option == opt.option, { onUpdate(Grade.Categoric(opt, grade.options, grade.grade)) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(idx, grade.options.size)
|
||||||
|
) { Text(opt.option.maxN(15)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(grade.value.option, fontStyle = FontStyle.Italic, style = JewelTheme.typography.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var slider by remember(grade, key) { mutableStateOf(maxOf(0, grade.options.indexOfFirst { it.option == grade.value.option })) }
|
||||||
|
Row {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Slider(
|
||||||
|
slider.toFloat(),
|
||||||
|
onValueChange = { onUpdate(grade.copy(value = grade.options[slider])) },
|
||||||
|
steps = grade.options.size,
|
||||||
|
valueRange = 0f..(grade.options.size - 1).toFloat()
|
||||||
|
)
|
||||||
|
Row {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(grade.options[slider].option, fontStyle = FontStyle.Italic, style = JewelTheme.typography.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Grade.FreeText -> {
|
||||||
|
var text by remember(grade, key) { mutableStateOf(grade.text) }
|
||||||
|
|
||||||
|
OutlinedTextField(grade.text, { onUpdate(grade.copy(text = it)) }, Modifier.weight(1f), singleLine = true)
|
||||||
|
DefaultButton({ onUpdate(Grade.FreeText(text)) }, enabled = text != grade.text) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Grade.Numeric -> {
|
||||||
|
var num by remember(grade, key) { mutableStateOf(grade.value.toString()) }
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
num, { num = it.filter { c -> c.isDigit() || c == '.' || c == ',' }.ifEmpty { "0" } },
|
||||||
|
Modifier.weight(1f), singleLine = true, isError = (num.toDoubleOrNull() ?: 0.0) > grade.grade.max
|
||||||
|
)
|
||||||
|
DefaultButton({ onUpdate(Grade.Numeric(num.toDoubleOrNull() ?: 0.0, grade.grade)) }, enabled = (num.toDoubleOrNull() ?: 0.0) <= grade.grade.max) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Grade.Percentage -> {
|
||||||
|
var perc by remember(grade, key) { mutableStateOf(grade.percentage.toString()) }
|
||||||
|
|
||||||
|
OutlinedTextField("$perc%", { perc = it.filter { c -> c.isDigit() || c == '.' || c == ',' }.ifEmpty { "0" } }, Modifier.weight(1f), singleLine = true)
|
||||||
|
DefaultButton({ onUpdate(Grade.Percentage(perc.toDoubleOrNull() ?: 0.0)) }) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OutlinedTextField(value: String, onChange: (String) -> Unit, modifier: Modifier = Modifier, label: @Composable () -> Unit = {}, isError: Boolean = false, singleLine: Boolean = false, minLines: Int = 1) {
|
||||||
|
val state = remember { TextFieldState(value) }
|
||||||
|
|
||||||
|
LaunchedEffect(value) {
|
||||||
|
if (state.text.toString() != value) {
|
||||||
|
state.edit {
|
||||||
|
replace(0, length, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.text }.collect { newText ->
|
||||||
|
val newString = newText.toString()
|
||||||
|
if (newString != value) {
|
||||||
|
onChange(newString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(singleLine) {
|
||||||
|
TextField(state, modifier, outline = if(isError) Outline.Error else Outline.None, placeholder = label)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
TextArea(state, modifier, outline = if(isError) Outline.Error else Outline.None, placeholder = label /*, minLines = minLines*/)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LocalSurfaceLayer = compositionLocalOf { 0 }
|
||||||
|
|
||||||
|
interface ShapeCollection {
|
||||||
|
val xLarge: Shape
|
||||||
|
val large: Shape
|
||||||
|
val medium: Shape
|
||||||
|
val small: Shape
|
||||||
|
val xSmall: Shape
|
||||||
|
val none: Shape
|
||||||
|
}
|
||||||
|
|
||||||
|
val JewelTheme.Companion.shapes
|
||||||
|
get() = object : ShapeCollection {
|
||||||
|
override val xLarge = RoundedCornerShape(28.0.dp)
|
||||||
|
override val large = RoundedCornerShape(16.0.dp)
|
||||||
|
override val xSmall = RoundedCornerShape(4.0.dp)
|
||||||
|
override val medium = RoundedCornerShape(12.0.dp)
|
||||||
|
override val none = RectangleShape
|
||||||
|
override val small = RoundedCornerShape(8.0.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Surface(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
shape: Shape = RectangleShape,
|
||||||
|
color: Color = JewelTheme.globalColors.panelBackground,
|
||||||
|
markFocused: Boolean = false,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val currentLayer = LocalSurfaceLayer.current
|
||||||
|
|
||||||
|
// TODO: markFocused?
|
||||||
|
Box(modifier = modifier.background(color, shape).let {
|
||||||
|
if (currentLayer > 0) it.border(Stroke(1.dp, JewelTheme.globalColors.outlines.focused, Stroke.Alignment.Center), shape)
|
||||||
|
else it
|
||||||
|
}) {
|
||||||
|
CompositionLocalProvider(LocalSurfaceLayer provides currentLayer + 1) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Scaffold(topBar: @Composable () -> Unit, snackState: SnackbarHostState, content: @Composable () -> Unit) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.heightIn(max = 150.dp)) {
|
||||||
|
CompositionLocalProvider(LocalTextStyle provides JewelTheme.typography.h1TextStyle) {
|
||||||
|
topBar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NotificationHost(snackState)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationHost(host: SnackbarHostState) {
|
||||||
|
val currentData = host.currentSnackbarData
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
if (currentData != null) {
|
||||||
|
Notification(
|
||||||
|
message = currentData.visuals.message,
|
||||||
|
onDismiss = { currentData.dismiss() },
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Notification(message: String, onDismiss: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.widthIn(max = 300.dp).shadow(8.dp, RoundedCornerShape(8.dp)),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text = message, modifier = Modifier.weight(1f), style = JewelTheme.defaultTextStyle)
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Close, contentDescription = "Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TitleBar(modifier: Modifier = Modifier, title: @Composable () -> Unit, navigationIcon: (@Composable () -> Unit)? = null) {
|
||||||
|
Surface(modifier) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 15.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (navigationIcon != null) {
|
||||||
|
Box(Modifier.padding(end = 8.dp)) {
|
||||||
|
navigationIcon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,821 +3,64 @@ package com.jaytux.grader.viewmodel
|
|||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import com.jaytux.grader.data.*
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
import com.jaytux.grader.data.EditionStudents.editionId
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import com.jaytux.grader.data.EditionStudents.studentId
|
|
||||||
import com.jaytux.grader.viewmodel.GroupAssignmentState.*
|
|
||||||
import kotlinx.datetime.*
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import org.jetbrains.exposed.dao.id.EntityID
|
|
||||||
import org.jetbrains.exposed.sql.*
|
|
||||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
fun <T> MutableState<T>.immutable(): State<T> = this
|
fun <T> MutableState<T>.immutable(): State<T> = this
|
||||||
fun <T> SizedIterable<T>.sortAsc(vararg columns: Expression<*>) = this.orderBy(*(columns.map { it to SortOrder.ASC }.toTypedArray()))
|
|
||||||
|
|
||||||
enum class AssignmentType(val show: String) { Solo("Solo Assignment"), Group("Group Assignment"), Peer("Peer Evaluation") }
|
|
||||||
sealed class Assignment {
|
|
||||||
class GAssignment(val assignment: GroupAssignment) : Assignment() {
|
|
||||||
override fun name(): String = assignment.name
|
|
||||||
override fun id(): EntityID<UUID> = assignment.id
|
|
||||||
override fun index(): Int? = assignment.number
|
|
||||||
}
|
|
||||||
class SAssignment(val assignment: SoloAssignment) : Assignment() {
|
|
||||||
override fun name(): String = assignment.name
|
|
||||||
override fun id(): EntityID<UUID> = assignment.id
|
|
||||||
override fun index(): Int? = assignment.number
|
|
||||||
}
|
|
||||||
class PeerEval(val evaluation: com.jaytux.grader.data.PeerEvaluation) : Assignment() {
|
|
||||||
override fun name(): String = evaluation.name
|
|
||||||
override fun id(): EntityID<UUID> = evaluation.id
|
|
||||||
override fun index(): Int? = evaluation.number
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun name(): String
|
|
||||||
abstract fun id(): EntityID<UUID>
|
|
||||||
abstract fun index(): Int?
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun from(assignment: GroupAssignment) = GAssignment(assignment)
|
|
||||||
fun from(assignment: SoloAssignment) = SAssignment(assignment)
|
|
||||||
fun from(pEval: PeerEvaluation) = PeerEval(pEval)
|
|
||||||
|
|
||||||
fun merge(groups: List<GroupAssignment>, solos: List<SoloAssignment>, peers: List<PeerEvaluation>): List<Assignment> {
|
|
||||||
val g = groups.map { from(it) }
|
|
||||||
val s = solos.map { from(it) }
|
|
||||||
val p = peers.map { from(it) }
|
|
||||||
return (g + s + p).sortedWith(compareBy<Assignment> { it.index() }.thenBy { it.name() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RawDbState<T: Any>(private val loader: (Transaction.() -> List<T>)) {
|
class RawDbState<T: Any>(private val loader: (Transaction.() -> List<T>)) {
|
||||||
|
|
||||||
private val rawEntities by lazy {
|
private val rawEntities by lazy {
|
||||||
mutableStateOf(transaction { loader() })
|
mutableStateOf(transaction { loader() })
|
||||||
}
|
}
|
||||||
val entities = rawEntities.immutable()
|
|
||||||
|
|
||||||
|
val entities = rawEntities.immutable()
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
rawEntities.value = transaction { loader() }
|
rawEntities.value = transaction { loader() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CourseListState {
|
class RawDbFocusableSingleState<TIn, TOut: Any>(private val loader: (Transaction.(TIn) -> TOut?)) {
|
||||||
val courses = RawDbState { Course.all().sortAsc(Courses.name).toList() }
|
private var _input: TIn? = null
|
||||||
|
private val rawEntity by lazy {
|
||||||
fun new(name: String) {
|
mutableStateOf<TOut?>(null)
|
||||||
transaction { Course.new { this.name = name } }
|
|
||||||
courses.refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(course: Course) {
|
val entity: State<TOut?> = rawEntity.immutable()
|
||||||
transaction { course.delete() }
|
|
||||||
courses.refresh()
|
fun focus(input: TIn) {
|
||||||
|
_input = input
|
||||||
|
rawEntity.value = transaction { loader(input) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getEditions(course: Course) = EditionListState(course)
|
fun unfocus() {
|
||||||
}
|
_input = null
|
||||||
|
rawEntity.value = null
|
||||||
class EditionListState(val course: Course) {
|
|
||||||
val editions = RawDbState { Edition.find { Editions.courseId eq course.id }.sortAsc(Editions.name).toList() }
|
|
||||||
|
|
||||||
fun new(name: String) {
|
|
||||||
transaction { Edition.new { this.name = name; this.course = this@EditionListState.course } }
|
|
||||||
editions.refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(edition: Edition) {
|
fun refresh() {
|
||||||
transaction { edition.delete() }
|
rawEntity.value = transaction { _input?.let { loader(it) } }
|
||||||
editions.refresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class OpenPanel(val tabName: String) {
|
class RawDbFocusableState<TIn, TOut: Any>(private val loader: (Transaction.(TIn) -> List<TOut>)) {
|
||||||
Student("Students"), Group("Groups"), Assignment("Assignments")
|
private var _input: TIn? = null
|
||||||
}
|
private val rawEntities by lazy {
|
||||||
|
mutableStateOf<List<TOut>?>(null)
|
||||||
class EditionState(val edition: Edition) {
|
|
||||||
val course = transaction { edition.course }
|
|
||||||
val students = RawDbState { edition.soloStudents.sortAsc(Students.name).toList() }
|
|
||||||
val groups = RawDbState { edition.groups.sortAsc(Groups.name).toList() }
|
|
||||||
val solo = RawDbState { edition.soloAssignments.sortAsc(SoloAssignments.name).toList() }
|
|
||||||
val groupAs = RawDbState { edition.groupAssignments.sortAsc(GroupAssignments.name).toList() }
|
|
||||||
val peer = RawDbState { edition.peerEvaluations.sortAsc(PeerEvaluations.name).toList() }
|
|
||||||
private val _history = mutableStateOf(listOf(-1 to OpenPanel.Assignment))
|
|
||||||
val history = _history.immutable()
|
|
||||||
|
|
||||||
val availableStudents = RawDbState {
|
|
||||||
Student.find {
|
|
||||||
(Students.id notInList edition.soloStudents.map { it.id })
|
|
||||||
}.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newStudent(name: String, contact: String, note: String, addToEdition: Boolean) {
|
val entities: State<List<TOut>?> = rawEntities.immutable()
|
||||||
transaction {
|
|
||||||
val student = Student.new { this.name = name; this.contact = contact; this.note = note }
|
|
||||||
if(addToEdition) EditionStudents.insert {
|
|
||||||
it[editionId] = edition.id
|
|
||||||
it[studentId] = student.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(addToEdition) students.refresh()
|
fun focus(input: TIn) {
|
||||||
else availableStudents.refresh()
|
_input = input
|
||||||
}
|
rawEntities.value = transaction { loader(input) }
|
||||||
fun setStudentName(student: Student, name: String) {
|
|
||||||
transaction {
|
|
||||||
student.name = name
|
|
||||||
}
|
|
||||||
students.refresh()
|
|
||||||
}
|
|
||||||
fun addToCourse(students: List<Student>) {
|
|
||||||
transaction {
|
|
||||||
EditionStudents.batchInsert(students) {
|
|
||||||
this[editionId] = edition.id
|
|
||||||
this[studentId] = it.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
availableStudents.refresh()
|
|
||||||
this.students.refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newGroup(name: String) {
|
fun unfocus() {
|
||||||
transaction {
|
_input = null
|
||||||
Group.new { this.name = name; this.edition = this@EditionState.edition }
|
rawEntities.value = null
|
||||||
groups.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun setGroupName(group: Group, name: String) {
|
|
||||||
transaction {
|
|
||||||
group.name = name
|
|
||||||
}
|
|
||||||
groups.refresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun now(): LocalDateTime {
|
fun refresh() {
|
||||||
val instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
rawEntities.value = transaction { _input?.let { loader(it) } }
|
||||||
return instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private fun nextIdx(): Int = max(
|
|
||||||
solo.entities.value.maxOfOrNull { it.number ?: 0 } ?: 0,
|
|
||||||
groupAs.entities.value.maxOfOrNull { it.number ?: 0 } ?: 0
|
|
||||||
) + 1
|
|
||||||
|
|
||||||
fun newSoloAssignment(name: String) {
|
|
||||||
transaction {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
package com.jaytux.grader.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.jaytux.grader.app
|
||||||
|
import com.jaytux.grader.data.v2.*
|
||||||
|
import com.jaytux.grader.ui.AssignmentsTabHeader
|
||||||
|
import com.jaytux.grader.ui.GroupsTabHeader
|
||||||
|
import com.jaytux.grader.ui.StudentsTabHeader
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import org.jetbrains.exposed.v1.core.Expression
|
||||||
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
|
import org.jetbrains.exposed.v1.core.and
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
|
import org.jetbrains.exposed.v1.dao.with
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.select
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
|
||||||
|
data class GroupData(val group: Group, val members: List<Pair<Student, String?>>)
|
||||||
|
data class CriterionData(val criterion: Criterion, val gradeType: UiGradeType) {
|
||||||
|
companion object {
|
||||||
|
context(trns: Transaction)
|
||||||
|
fun from(c: Criterion) = CriterionData(c, UiGradeType.from(c.gradeType, c.categoricGrade, c.numericGrade))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data class AssignmentData(val assignment: BaseAssignment, val global: CriterionData, val criteria: List<CriterionData>)
|
||||||
|
data class GradeSummary(val assignment: BaseAssignment, val asMember: Group?, val overridden: Boolean, val grade: Grade?)
|
||||||
|
enum class Tab(val renderTab: @Composable () -> Unit, val addText: String) {
|
||||||
|
STUDENTS(::StudentsTabHeader, "Student"),
|
||||||
|
GROUPS(::GroupsTabHeader, "Group"),
|
||||||
|
ASSIGNMENTS(::AssignmentsTabHeader, "Assignment")
|
||||||
|
}
|
||||||
|
|
||||||
|
val studentList = RawDbState { edition.students.sortedBy { it.name }.toList() }
|
||||||
|
val groupList = RawDbState {
|
||||||
|
edition.groups.with(Group::students, GroupStudent::student).sortedBy { it.name }.map {
|
||||||
|
GroupData(it, it.students.map { gs -> gs.student to gs.role }.sortedBy { it.first.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val assignmentList = RawDbState {
|
||||||
|
edition.assignments.sortedBy { it.number }.map {
|
||||||
|
AssignmentData(it, CriterionData.from(it.globalCriterion), it.nonBaseCriteria.map { c ->
|
||||||
|
CriterionData.from(c)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val usedRoles = RawDbState {
|
||||||
|
GroupStudents.select(GroupStudents.role).mapNotNull { it[GroupStudents.role] }.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
val categoricGrades = RawDbState {
|
||||||
|
CategoricGrade.all().map {
|
||||||
|
UiGradeType.Categoric(it.options.toList(), it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val numericGrades = RawDbState {
|
||||||
|
NumericGrade.all().map { UiGradeType.Numeric(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val studentGrades = RawDbFocusableState { st: Student ->
|
||||||
|
val groupIds = st.groups.map { it.group.id }.toSet()
|
||||||
|
|
||||||
|
edition.assignments.map { asg ->
|
||||||
|
val (grade, memberOf, override) = when(asg.type) {
|
||||||
|
AssignmentType.GROUP -> {
|
||||||
|
val asGroup = asg.globalCriterion.feedbacks.find { it.asGroupFeedback?.id in groupIds }
|
||||||
|
val solo = asg.globalCriterion.feedbacks.find { it.forStudentsOverrideIfGroup.any { over -> over.student == st } }
|
||||||
|
val gr = (solo ?: asGroup)?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||||
|
gr to asGroup?.asGroupFeedback app (solo != null)
|
||||||
|
}
|
||||||
|
AssignmentType.SOLO -> {
|
||||||
|
val eval = asg.globalCriterion.feedbacks.find { it.asSoloFeedback == st }
|
||||||
|
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||||
|
eval to null app false
|
||||||
|
}
|
||||||
|
AssignmentType.PEER_EVALUATION -> {
|
||||||
|
val eval = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id == st.id }
|
||||||
|
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||||
|
eval to null app false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GradeSummary(asg, memberOf, override, grade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val studentGroups = RawDbFocusableState { st: Student ->
|
||||||
|
st.groups.map { it.group to it.role }
|
||||||
|
}
|
||||||
|
|
||||||
|
val groupAvailableStudents = RawDbFocusableState { grp: Group ->
|
||||||
|
val exclude = grp.students.map { it.student.id }.toSet()
|
||||||
|
edition.students.filterNot { it.id in exclude }
|
||||||
|
}
|
||||||
|
val groupGrades = RawDbFocusableState { g: Group ->
|
||||||
|
edition.assignments.filter{ it.type != AssignmentType.SOLO }.map { asg ->
|
||||||
|
val grade = when(asg.type) {
|
||||||
|
AssignmentType.GROUP -> {
|
||||||
|
val asGroup = asg.globalCriterion.feedbacks.find { it.asGroupFeedback?.id == g.id }
|
||||||
|
asGroup?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||||
|
}
|
||||||
|
AssignmentType.PEER_EVALUATION -> {
|
||||||
|
val asGroup = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id == g.id }
|
||||||
|
asGroup?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
asg to grade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val asPeerEvaluation = RawDbFocusableSingleState { asg: BaseAssignment ->
|
||||||
|
asg.asPeerEvaluation?.let { peer ->
|
||||||
|
val stuCrit = peer.studentCriterion
|
||||||
|
peer to UiGradeType.from(stuCrit.gradeType, stuCrit.categoricGrade, stuCrit.numericGrade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _selectedTab = mutableStateOf(Tab.STUDENTS)
|
||||||
|
private val _focusIndex = mutableStateOf(-1)
|
||||||
|
val selectedTab = _selectedTab.immutable()
|
||||||
|
val focusIndex = _focusIndex.immutable()
|
||||||
|
|
||||||
|
fun switchTo(tab: Tab) {
|
||||||
|
_selectedTab.value = tab
|
||||||
|
_focusIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focus(idx: Int) {
|
||||||
|
_focusIndex.value = idx
|
||||||
|
|
||||||
|
when(_selectedTab.value) {
|
||||||
|
Tab.STUDENTS -> {
|
||||||
|
val st = studentList.entities.value[idx]
|
||||||
|
studentGrades.focus(st)
|
||||||
|
studentGroups.focus(st)
|
||||||
|
}
|
||||||
|
Tab.GROUPS -> {
|
||||||
|
val grp = groupList.entities.value[idx].group
|
||||||
|
groupAvailableStudents.focus(grp)
|
||||||
|
groupGrades.focus(grp)
|
||||||
|
}
|
||||||
|
Tab.ASSIGNMENTS -> {
|
||||||
|
val asg = assignmentList.entities.value[idx].assignment
|
||||||
|
asPeerEvaluation.focus(asg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focus(group: Group) {
|
||||||
|
val idx = groupList.entities.value.indexOfFirst { it.group.id == group.id }
|
||||||
|
if(idx != -1) {
|
||||||
|
switchTo(Tab.GROUPS)
|
||||||
|
focus(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focus(student: Student) {
|
||||||
|
val idx = studentList.entities.value.indexOfFirst { it.id == student.id }
|
||||||
|
if(idx != -1) {
|
||||||
|
switchTo(Tab.STUDENTS)
|
||||||
|
focus(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unfocus() {
|
||||||
|
_focusIndex.value = -1
|
||||||
|
|
||||||
|
studentGrades.unfocus()
|
||||||
|
studentGroups.unfocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkStudent(name: String, contact: String, note: String) {
|
||||||
|
transaction {
|
||||||
|
val s = Student.new {
|
||||||
|
this.name = name
|
||||||
|
this.contact = contact
|
||||||
|
this.note = note
|
||||||
|
}
|
||||||
|
EditionStudents.insert {
|
||||||
|
it[EditionStudents.editionId] = edition.id
|
||||||
|
it[EditionStudents.studentId] = s.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unfocus()
|
||||||
|
studentList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun modStudent(student: Student, name: String?, contact: String?, note: String?) {
|
||||||
|
transaction {
|
||||||
|
student.name = name ?: student.name
|
||||||
|
student.contact = contact ?: student.contact
|
||||||
|
student.note = note ?: student.note
|
||||||
|
}
|
||||||
|
studentList.refresh()
|
||||||
|
studentGroups.refresh()
|
||||||
|
studentGrades.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rmStudent(student: Student) {
|
||||||
|
transaction {
|
||||||
|
student.delete()
|
||||||
|
}
|
||||||
|
unfocus()
|
||||||
|
studentList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkGroup(name: String) {
|
||||||
|
transaction {
|
||||||
|
Group.new {
|
||||||
|
this.name = name
|
||||||
|
this.edition = this@EditionVM.edition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unfocus()
|
||||||
|
groupList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun modGroup(group: Group, name: String?) {
|
||||||
|
transaction {
|
||||||
|
group.name = name ?: group.name
|
||||||
|
}
|
||||||
|
groupList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addStudentToGroup(student: Student, group: Group, role: String?) {
|
||||||
|
transaction {
|
||||||
|
GroupStudent.new {
|
||||||
|
this.student = student
|
||||||
|
this.group = group
|
||||||
|
this.role = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupList.refresh()
|
||||||
|
studentGroups.refresh()
|
||||||
|
groupAvailableStudents.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStudentRole(student: Student, group: Group, role: String?) {
|
||||||
|
transaction {
|
||||||
|
GroupStudent.find { (GroupStudents.studentId eq student.id) and (GroupStudents.groupId eq group.id) }.firstOrNull()?.let {
|
||||||
|
it.role = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupList.refresh()
|
||||||
|
groupAvailableStudents.refresh()
|
||||||
|
usedRoles.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rmStudentFromGroup(student: Student, group: Group) {
|
||||||
|
transaction {
|
||||||
|
GroupStudent.find { (GroupStudents.studentId eq student.id) and (GroupStudents.groupId eq group.id) }.firstOrNull()?.delete()
|
||||||
|
}
|
||||||
|
groupList.refresh()
|
||||||
|
groupAvailableStudents.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rmGroup(group: Group) {
|
||||||
|
transaction {
|
||||||
|
group.delete()
|
||||||
|
}
|
||||||
|
unfocus()
|
||||||
|
groupList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Transaction.mkBaseAssignment(name: String, type: AssignmentType): BaseAssignment {
|
||||||
|
val asg = BaseAssignment.new {
|
||||||
|
this.name = name
|
||||||
|
this.assignment = ""
|
||||||
|
this.deadline = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
this.number = assignmentList.entities.value.size
|
||||||
|
this.edition = this@EditionVM.edition
|
||||||
|
this.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
val crit = Criterion.new {
|
||||||
|
this.assignment = asg
|
||||||
|
this.name = "(Default Criterion)"
|
||||||
|
this.desc = "Default criterion for assignment ${asg.name}"
|
||||||
|
this.gradeType = GradeType.NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
asg.globalCriterion = crit
|
||||||
|
return asg
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postCreateAsg() {
|
||||||
|
assignmentList.refresh()
|
||||||
|
focus(assignmentList.entities.value.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkGroupAssignment(name: String) {
|
||||||
|
transaction {
|
||||||
|
val asg = mkBaseAssignment(name, AssignmentType.GROUP)
|
||||||
|
GroupAssignment.new { this.base = asg }
|
||||||
|
}
|
||||||
|
postCreateAsg()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkSoloAssignment(name: String) {
|
||||||
|
transaction {
|
||||||
|
val asg = mkBaseAssignment(name, AssignmentType.SOLO)
|
||||||
|
SoloAssignment.new { this.base = asg }
|
||||||
|
}
|
||||||
|
postCreateAsg()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkPeerEvaluation(name: String) {
|
||||||
|
transaction {
|
||||||
|
val asg = mkBaseAssignment(name, AssignmentType.PEER_EVALUATION)
|
||||||
|
val stCrit = Criterion.new {
|
||||||
|
this.assignment = asg
|
||||||
|
this.name = "@__internal"
|
||||||
|
this.desc = "INTERNAL ONLY: Criterion to store the grade type for peer evaluation assignments"
|
||||||
|
this.gradeType = GradeType.NONE
|
||||||
|
}
|
||||||
|
PeerEvaluation.new {
|
||||||
|
this.base = asg
|
||||||
|
this.studentCriterion = stCrit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
postCreateAsg()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkAssignment(name: String, type: AssignmentType) {
|
||||||
|
when(type) {
|
||||||
|
AssignmentType.GROUP -> mkGroupAssignment(name)
|
||||||
|
AssignmentType.SOLO -> mkSoloAssignment(name)
|
||||||
|
AssignmentType.PEER_EVALUATION -> mkPeerEvaluation(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun modAssignment(assignment: BaseAssignment, name: String?, deadline: LocalDateTime?) {
|
||||||
|
transaction {
|
||||||
|
assignment.name = name ?: assignment.name
|
||||||
|
assignment.deadline = deadline ?: assignment.deadline
|
||||||
|
}
|
||||||
|
assignmentList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDesc(assignment: AssignmentData, desc: String) {
|
||||||
|
transaction {
|
||||||
|
assignment.global.criterion.desc = desc
|
||||||
|
}
|
||||||
|
assignmentList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkCriterion(assignment: BaseAssignment, name: String, desc: String, gradeType: UiGradeType) {
|
||||||
|
transaction {
|
||||||
|
val crit = Criterion.new {
|
||||||
|
this.assignment = assignment
|
||||||
|
this.name = name
|
||||||
|
this.desc = desc
|
||||||
|
this.gradeType = when(gradeType) {
|
||||||
|
is UiGradeType.Categoric -> GradeType.CATEGORIC
|
||||||
|
is UiGradeType.Numeric -> GradeType.NUMERIC
|
||||||
|
UiGradeType.Percentage -> GradeType.PERCENTAGE
|
||||||
|
UiGradeType.FreeText -> GradeType.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when(gradeType) {
|
||||||
|
is UiGradeType.Categoric -> crit.categoricGrade = gradeType.grade
|
||||||
|
is UiGradeType.Numeric -> crit.numericGrade = gradeType.grade
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignmentList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun modCriterion(crit: Criterion, name: String?, desc: String?, gradeType: UiGradeType?) {
|
||||||
|
transaction {
|
||||||
|
crit.name = name ?: crit.name
|
||||||
|
crit.desc = desc ?: crit.desc
|
||||||
|
crit.gradeType = when(gradeType) {
|
||||||
|
null -> crit.gradeType
|
||||||
|
is UiGradeType.Categoric -> GradeType.CATEGORIC
|
||||||
|
is UiGradeType.Numeric -> GradeType.NUMERIC
|
||||||
|
UiGradeType.Percentage -> GradeType.PERCENTAGE
|
||||||
|
UiGradeType.FreeText -> GradeType.NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
when(gradeType) {
|
||||||
|
is UiGradeType.Categoric -> crit.categoricGrade = gradeType.grade
|
||||||
|
is UiGradeType.Numeric -> crit.numericGrade = gradeType.grade
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignmentList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkScale(name: String, options: List<String>) {
|
||||||
|
transaction {
|
||||||
|
val grade = CategoricGrade.new { this.name = name }
|
||||||
|
options.forEachIndexed { idx, opt ->
|
||||||
|
CategoricGradeOption.new {
|
||||||
|
this.grade = grade
|
||||||
|
this.option = opt
|
||||||
|
this.index = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
categoricGrades.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mkNumericScale(name: String, max: Double) {
|
||||||
|
transaction {
|
||||||
|
NumericGrade.new {
|
||||||
|
this.name = name
|
||||||
|
this.max = max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
numericGrades.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPEGrade(pe: PeerEvaluation, gradeType: UiGradeType) {
|
||||||
|
transaction {
|
||||||
|
pe.studentCriterion.gradeType = when (gradeType) {
|
||||||
|
is UiGradeType.Categoric -> GradeType.CATEGORIC
|
||||||
|
is UiGradeType.Numeric -> GradeType.NUMERIC
|
||||||
|
UiGradeType.Percentage -> GradeType.PERCENTAGE
|
||||||
|
UiGradeType.FreeText -> GradeType.NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
when (gradeType) {
|
||||||
|
is UiGradeType.Categoric -> pe.studentCriterion.categoricGrade = gradeType.grade
|
||||||
|
is UiGradeType.Numeric -> pe.studentCriterion.numericGrade = gradeType.grade
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
asPeerEvaluation.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rmAssignment(assignment: BaseAssignment) {
|
||||||
|
transaction {
|
||||||
|
assignment.delete()
|
||||||
|
(assignment.asPeerEvaluation ?: assignment.asGroupAssignment ?: assignment.asSoloAssignment)?.delete()
|
||||||
|
}
|
||||||
|
unfocus()
|
||||||
|
assignmentList.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.jaytux.grader.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.jaytux.grader.data.v2.BaseFeedback
|
||||||
|
import com.jaytux.grader.data.v2.CategoricGrade
|
||||||
|
import com.jaytux.grader.data.v2.CategoricGradeOption
|
||||||
|
import com.jaytux.grader.data.v2.CategoricGradeOptions
|
||||||
|
import com.jaytux.grader.data.v2.Criterion
|
||||||
|
import com.jaytux.grader.data.v2.GradeType
|
||||||
|
import com.jaytux.grader.data.v2.NumericGrade
|
||||||
|
import com.jaytux.grader.maxN
|
||||||
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
|
||||||
|
sealed class Grade {
|
||||||
|
data class FreeText(val text: String) : Grade() {
|
||||||
|
override fun toString(): String = "FreeText($text)"
|
||||||
|
}
|
||||||
|
data class Percentage(val percentage: Double) : Grade() {
|
||||||
|
override fun toString(): String = "Perc($percentage%)"
|
||||||
|
}
|
||||||
|
data class Numeric(val value: Double, val grade: NumericGrade) : Grade() {
|
||||||
|
override fun toString(): String = "Numeric($value / ${grade.max})"
|
||||||
|
}
|
||||||
|
data class Categoric(val value: CategoricGradeOption, val options: List<CategoricGradeOption>, val grade: CategoricGrade) : Grade() {
|
||||||
|
override fun toString(): String = "Categoric(${value.option})"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun render(modifier: Modifier = Modifier) = when(this) {
|
||||||
|
is FreeText -> Text(text.maxN(15), modifier)
|
||||||
|
is Categoric -> Text(value.option, modifier)
|
||||||
|
is Numeric -> Text("$value / ${grade.max}", modifier)
|
||||||
|
is Percentage -> Text("$percentage%", modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
context(trns: Transaction)
|
||||||
|
fun fromAssignment(asg: Criterion, fdb: BaseFeedback): Grade = when(asg.gradeType) {
|
||||||
|
GradeType.CATEGORIC ->
|
||||||
|
Categoric(fdb.gradeCategoric!!, asg.categoricGrade!!.options.toList(), asg.categoricGrade!!)
|
||||||
|
|
||||||
|
GradeType.NUMERIC -> Numeric(fdb.gradeNumeric!!, asg.numericGrade!!)
|
||||||
|
GradeType.PERCENTAGE -> Percentage(fdb.gradeNumeric!!)
|
||||||
|
GradeType.NONE -> FreeText(fdb.gradeFreeText!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun defaultFreeText() = FreeText("")
|
||||||
|
fun defaultPercentage() = Percentage(0.0)
|
||||||
|
fun defaultNumeric(grade: NumericGrade) = Numeric(0.0, grade)
|
||||||
|
fun defaultCategoric(grade: CategoricGrade, options: List<CategoricGradeOption>) = Categoric(options.first(), options, grade)
|
||||||
|
|
||||||
|
fun default(type: GradeType, cat: CategoricGrade?, num: NumericGrade?) = when(type) {
|
||||||
|
GradeType.CATEGORIC -> transaction {
|
||||||
|
cat!!
|
||||||
|
defaultCategoric(cat, cat.options.toList())
|
||||||
|
}
|
||||||
|
GradeType.NUMERIC -> defaultNumeric(num!!)
|
||||||
|
GradeType.PERCENTAGE -> defaultPercentage()
|
||||||
|
GradeType.NONE -> defaultFreeText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package com.jaytux.grader.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.jaytux.grader.data.v2.BaseAssignment
|
||||||
|
import com.jaytux.grader.data.v2.BaseFeedback
|
||||||
|
import com.jaytux.grader.data.v2.BaseFeedbacks
|
||||||
|
import com.jaytux.grader.data.v2.CategoricGrade
|
||||||
|
import com.jaytux.grader.data.v2.Course
|
||||||
|
import com.jaytux.grader.data.v2.Criterion
|
||||||
|
import com.jaytux.grader.data.v2.Edition
|
||||||
|
import com.jaytux.grader.data.v2.GradeType
|
||||||
|
import com.jaytux.grader.data.v2.Group
|
||||||
|
import com.jaytux.grader.data.v2.GroupAssignment
|
||||||
|
import com.jaytux.grader.data.v2.GroupFeedbacks
|
||||||
|
import com.jaytux.grader.data.v2.GroupStudent
|
||||||
|
import com.jaytux.grader.data.v2.NumericGrade
|
||||||
|
import com.jaytux.grader.data.v2.Student
|
||||||
|
import com.jaytux.grader.data.v2.StudentOverrideFeedback
|
||||||
|
import com.jaytux.grader.data.v2.StudentOverrideFeedbacks
|
||||||
|
import com.jaytux.grader.ui.CritData
|
||||||
|
import com.jaytux.grader.ui.FeedbackItem
|
||||||
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
|
import org.jetbrains.exposed.v1.core.and
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
|
import org.jetbrains.exposed.v1.dao.with
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.upsertReturning
|
||||||
|
|
||||||
|
class GroupsGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
|
||||||
|
data class GroupData(val group: Group, val students: List<Pair<Student, String?>>)
|
||||||
|
data class FeedbackData(val groupLevel: FeedbackItem?, val overrides: List<Pair<Student, FeedbackItem?>>)
|
||||||
|
|
||||||
|
private val _focus = mutableStateOf(-1)
|
||||||
|
val focus = _focus.immutable()
|
||||||
|
|
||||||
|
val asGroup = transaction { base.asGroupAssignment!! }
|
||||||
|
val global = transaction { CritData.fromDb(base.globalCriterion) }
|
||||||
|
val groupList = RawDbState {
|
||||||
|
edition.groups.with(Group::students, GroupStudent::student).map { group ->
|
||||||
|
GroupData(group, group.students.map { Pair(it.student, it.role) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val globalGrade = RawDbFocusableSingleState { group: Group ->
|
||||||
|
val g = base.globalCriterion.feedbacks.find { it.asGroupFeedback?.id == group.id }?.let { FeedbackItem.fromDb(it) }
|
||||||
|
val overrides = g?.let { gl -> getOverrides(group, gl.base) } ?: group.students.map { it.student to null }
|
||||||
|
FeedbackData(g, overrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
val gradeList = RawDbFocusableState { group: Group ->
|
||||||
|
base.nonBaseCriteria.map { crit ->
|
||||||
|
val groupLevel = crit.feedbacks.find { it.asGroupFeedback?.id == group.id }?.let { FeedbackItem.fromDb(it) }
|
||||||
|
val overrides = groupLevel?.let { gl -> getOverrides(group, gl.base) } ?: group.students.map { it.student to null }
|
||||||
|
|
||||||
|
CritData.fromDb(crit) to FeedbackData(groupLevel, overrides)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Transaction.getOverrides(group: Group, fd: BaseFeedback): List<Pair<Student, FeedbackItem?>> {
|
||||||
|
val feedbacks = fd.forStudentsOverrideIfGroup.filter { it.group.id == group.id }.associateBy { it.student.id }
|
||||||
|
|
||||||
|
return group.students.map {
|
||||||
|
it.student to feedbacks[it.student.id]?.let { sof -> FeedbackItem.fromDb(sof.feedback) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusGroup(idx: Int) {
|
||||||
|
_focus.value = idx
|
||||||
|
val group = groupList.entities.value[idx].group
|
||||||
|
globalGrade.focus(group)
|
||||||
|
gradeList.focus(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusPrev() {
|
||||||
|
if (focus.value > 0) {
|
||||||
|
focusGroup(focus.value - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusNext() {
|
||||||
|
if (focus.value < groupList.entities.value.size - 1) {
|
||||||
|
focusGroup(focus.value + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context(trns: Transaction)
|
||||||
|
private fun BaseFeedback.set(grade: Grade, feedback: String) {
|
||||||
|
this.feedback = feedback
|
||||||
|
when(grade) {
|
||||||
|
is Grade.Categoric -> this.gradeCategoric = grade.value
|
||||||
|
is Grade.FreeText -> this.gradeFreeText = grade.text
|
||||||
|
is Grade.Numeric -> this.gradeNumeric = grade.value
|
||||||
|
is Grade.Percentage -> this.gradeNumeric = grade.percentage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun modGroupFeedback(crit: Criterion, group: Group, grade: Grade, feedback: String) {
|
||||||
|
transaction {
|
||||||
|
val existing = group.feedbacks.find { f -> f.criterion.id == crit.id }
|
||||||
|
if(existing != null) {
|
||||||
|
existing.set(grade, feedback)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val fdb = BaseFeedback.new {
|
||||||
|
criterion = crit
|
||||||
|
set(grade, feedback)
|
||||||
|
}
|
||||||
|
GroupFeedbacks.insert {
|
||||||
|
it[GroupFeedbacks.feedbackId] = fdb.id
|
||||||
|
it[GroupFeedbacks.groupId] = group.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalGrade.refresh()
|
||||||
|
gradeList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun modOverrideFeedback(crit: Criterion, group: Group, student: Student, groupLevel: FeedbackItem, grade: Grade, feedback: String) {
|
||||||
|
transaction {
|
||||||
|
val existing = groupLevel.base.forStudentsOverrideIfGroup.find { it.student.id == student.id }
|
||||||
|
if(existing != null) {
|
||||||
|
existing.feedback.set(grade, feedback)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val fdb = BaseFeedback.new {
|
||||||
|
criterion = crit
|
||||||
|
set(grade, feedback)
|
||||||
|
}
|
||||||
|
StudentOverrideFeedback.new {
|
||||||
|
this.group = group
|
||||||
|
this.student = student
|
||||||
|
this.feedback = fdb
|
||||||
|
this.overrides = groupLevel.base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalGrade.refresh()
|
||||||
|
gradeList.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rmOverrideFeedback(crit: Criterion, group: Group, student: Student) {
|
||||||
|
transaction {
|
||||||
|
crit.feedbacks.find {
|
||||||
|
it.asGroupFeedback!!.id == group.id // find relevant group-level feedback
|
||||||
|
}?.forStudentsOverrideIfGroup?.find {
|
||||||
|
it.student.id == student.id // find override for the student
|
||||||
|
}?.delete()
|
||||||
|
}
|
||||||
|
globalGrade.refresh()
|
||||||
|
gradeList.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.jaytux.grader.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.backhandler.BackHandler
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.jaytux.grader.ui.ChevronLeft
|
||||||
|
import com.jaytux.grader.ui.Icons
|
||||||
|
import com.jaytux.grader.ui.Scaffold
|
||||||
|
import com.jaytux.grader.ui.Surface
|
||||||
|
import com.jaytux.grader.ui.TitleBar
|
||||||
|
import org.jetbrains.jewel.foundation.theme.JewelTheme
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
import org.jetbrains.jewel.ui.component.*;
|
||||||
|
|
||||||
|
class Navigator private constructor(
|
||||||
|
private var _start: IDestination,
|
||||||
|
private val _typeMap: Map<KClass<out IDestination>, RenderData>
|
||||||
|
) : ViewModel() {
|
||||||
|
interface IDestination
|
||||||
|
|
||||||
|
private data class Entry<T : IDestination>(val dest: T, val token: NavToken)
|
||||||
|
inner class NavToken {
|
||||||
|
fun navTo(target: IDestination) { this@Navigator.navTo(target) }
|
||||||
|
fun back() { this@Navigator.back() }
|
||||||
|
inline fun <reified T : IDestination> backTo() { this@Navigator.backTo<T>() }
|
||||||
|
fun rewriteHistory(t: IDestination) { this@Navigator.rewriteHistory(t) }
|
||||||
|
}
|
||||||
|
internal data class RenderData(
|
||||||
|
val header: @Composable (IDestination) -> Unit,
|
||||||
|
val renderer: @Composable (IDestination, NavToken) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _stack = mutableStateOf(listOf<Entry<*>>(Entry(_start, NavToken())))
|
||||||
|
|
||||||
|
fun navTo(target: IDestination) {
|
||||||
|
_stack.value += Entry(target, NavToken())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun back() {
|
||||||
|
if(_stack.value.size > 1) _stack.value = _stack.value.dropLast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : IDestination> backTo(cls: KClass<T>) {
|
||||||
|
val idx = _stack.value.indexOfLast { entry -> cls.isInstance(entry.dest) }
|
||||||
|
if(idx != -1 && idx != _stack.value.lastIndex) {
|
||||||
|
_stack.value = _stack.value.take(idx + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rewriteHistory(t: IDestination) {
|
||||||
|
_stack.value = listOf(Entry(t, NavToken()))
|
||||||
|
_start = t
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified T : IDestination> backTo() = backTo(T::class)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun DisplayScaffold() {
|
||||||
|
val state = remember { SnackbarHostState() }
|
||||||
|
val stack by _stack
|
||||||
|
val (top, render) = remember(stack) {
|
||||||
|
val top = stack.last()
|
||||||
|
val render = _typeMap[top.dest::class]
|
||||||
|
?: throw IllegalStateException("No renderer for destination of type ${top.dest::class.simpleName}")
|
||||||
|
top to render
|
||||||
|
}
|
||||||
|
val snackVM = viewModel<SnackVM> { SnackVM() }
|
||||||
|
snackVM.Launcher(state)
|
||||||
|
|
||||||
|
BackHandler { back() }
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TitleBar(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
title = { render.header(top.dest) },
|
||||||
|
) {
|
||||||
|
IconButton({ back() }, enabled = top != _start) {
|
||||||
|
Icon(Icons.ChevronLeft, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackState = state
|
||||||
|
) { //insets ->
|
||||||
|
Surface(/*Modifier.padding(insets),*/ color = JewelTheme.globalColors.panelBackground) {
|
||||||
|
render.renderer(top.dest, top.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DslMarker
|
||||||
|
annotation class NavigatorDslMarker
|
||||||
|
|
||||||
|
@NavigatorDslMarker
|
||||||
|
class Builder internal constructor(
|
||||||
|
private val _onBuild: (IDestination, Map<KClass<out IDestination>, RenderData>) -> Navigator
|
||||||
|
) {
|
||||||
|
private val _typeMap = mutableMapOf<KClass<out IDestination>, RenderData>()
|
||||||
|
private lateinit var _start: IDestination
|
||||||
|
|
||||||
|
fun <T : IDestination> composable(cls: KClass<T>, title: @Composable (T) -> Unit, renderer: @Composable (T, NavToken) -> Unit) {
|
||||||
|
_typeMap[cls]?.let {
|
||||||
|
throw IllegalArgumentException("Destination of type ${cls.simpleName} is already registered.")
|
||||||
|
} ?: run {
|
||||||
|
_typeMap[cls] = RenderData({
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
title(it as T)
|
||||||
|
}) { d, t ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
renderer(d as T, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified T : IDestination> composable(noinline title: @Composable (T) -> Unit, noinline renderer: @Composable (T, NavToken) -> Unit) {
|
||||||
|
composable(T::class, title, renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(start: IDestination) {
|
||||||
|
if(this::_start.isInitialized) throw IllegalStateException("Start destination is already set.")
|
||||||
|
_start = start
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun build(): Navigator {
|
||||||
|
if(!this::_start.isInitialized) throw IllegalStateException("Start destination is not set.")
|
||||||
|
return _onBuild(_start, _typeMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun build(block: Builder.() -> Unit): Navigator =
|
||||||
|
Builder { start, map -> Navigator(start, map) }
|
||||||
|
.apply { block() }.build()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NavHost(initial: IDestination, block: Builder.() -> Unit) {
|
||||||
|
val vm = viewModel {
|
||||||
|
build {
|
||||||
|
block()
|
||||||
|
start(initial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.DisplayScaffold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package com.jaytux.grader.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.jaytux.grader.app
|
||||||
|
import com.jaytux.grader.data.v2.BaseAssignment
|
||||||
|
import com.jaytux.grader.data.v2.BaseFeedback
|
||||||
|
import com.jaytux.grader.data.v2.BaseFeedbacks
|
||||||
|
import com.jaytux.grader.data.v2.Course
|
||||||
|
import com.jaytux.grader.data.v2.Edition
|
||||||
|
import com.jaytux.grader.data.v2.Group
|
||||||
|
import com.jaytux.grader.data.v2.GroupStudent
|
||||||
|
import com.jaytux.grader.data.v2.PeerEvaluationFeedbacks
|
||||||
|
import com.jaytux.grader.data.v2.PeerEvaluationS2G
|
||||||
|
import com.jaytux.grader.data.v2.PeerEvaluationS2GEvaluations
|
||||||
|
import com.jaytux.grader.data.v2.PeerEvaluationS2S
|
||||||
|
import com.jaytux.grader.data.v2.PeerEvaluationS2SEvaluations
|
||||||
|
import com.jaytux.grader.data.v2.Student
|
||||||
|
import com.jaytux.grader.ui.CritData
|
||||||
|
import com.jaytux.grader.ui.FeedbackItem
|
||||||
|
import com.jaytux.grader.viewmodel.GroupsGradingVM.GroupData
|
||||||
|
import org.jetbrains.exposed.v1.core.and
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
|
import org.jetbrains.exposed.v1.core.inList
|
||||||
|
import org.jetbrains.exposed.v1.dao.with
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
|
||||||
|
class PeerEvalsGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
|
||||||
|
data class S2S(val evaluatee: Student, val data: FeedbackItem?)
|
||||||
|
data class Evaluation(val evaluator: Student, val groupLevel: FeedbackItem?, val s2s: List<S2S>)
|
||||||
|
|
||||||
|
private val _focus = mutableStateOf(-1)
|
||||||
|
val focus = _focus.immutable()
|
||||||
|
|
||||||
|
val asPeer = transaction { base.asPeerEvaluation!! }
|
||||||
|
val global = transaction { CritData.fromDb(base.globalCriterion) }
|
||||||
|
val studentCriterion = transaction { CritData.fromDb(asPeer.studentCriterion) }
|
||||||
|
|
||||||
|
val groupList = RawDbState {
|
||||||
|
edition.groups.with(Group::students, GroupStudent::student).map { group ->
|
||||||
|
GroupData(group, group.students.map { Pair(it.student, it.role) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val evaluationMatrix = RawDbFocusableState { group: Group ->
|
||||||
|
val studentIds = group.students.map { it.student.id.value }
|
||||||
|
val s2gs = PeerEvaluationS2G.find {
|
||||||
|
(PeerEvaluationS2GEvaluations.peerEvalId eq asPeer.id) and
|
||||||
|
(PeerEvaluationS2GEvaluations.studentId inList studentIds)
|
||||||
|
}.also {
|
||||||
|
println("S2G for group ${group.name}:")
|
||||||
|
it.forEach { println(" ${it.student.name} -> ${it.evaluation.gradeCategoric ?: it.evaluation.gradeNumeric ?: it.evaluation.gradeFreeText}") }
|
||||||
|
}
|
||||||
|
val s2ss = PeerEvaluationS2S.find {
|
||||||
|
(PeerEvaluationS2SEvaluations.peerEvalId eq asPeer.id) and
|
||||||
|
(PeerEvaluationS2SEvaluations.studentId inList studentIds)
|
||||||
|
}
|
||||||
|
group.students.map { evaluator ->
|
||||||
|
val s2s = group.students.map { evaluatee ->
|
||||||
|
val item = s2ss.find { it.student.id == evaluator.student.id && it.evaluatedStudent.id == evaluatee.student.id }?.let {
|
||||||
|
FeedbackItem.fromDb(it.evaluation)
|
||||||
|
}
|
||||||
|
S2S(evaluatee.student, item)
|
||||||
|
}
|
||||||
|
val s2g = s2gs.find { it.student.id == evaluator.student.id }?.let { FeedbackItem.fromDb(it.evaluation) }
|
||||||
|
Evaluation(evaluator.student, s2g, s2s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val studentGrades = RawDbFocusableState { group: Group ->
|
||||||
|
val studentIds = group.students.map { it.student.id.value }.toSet()
|
||||||
|
|
||||||
|
val mapping = global.criterion.feedbacks.mapNotNull {
|
||||||
|
it.asPeerEvaluationFeedback?.let { x ->
|
||||||
|
if(x.id.value in studentIds) x.id to FeedbackItem.fromDb(it) else null
|
||||||
|
}
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
group.students.map { student -> student.student to mapping[student.student.id] }
|
||||||
|
}
|
||||||
|
|
||||||
|
val students = RawDbFocusableState { group: Group ->
|
||||||
|
group.students.map { it.student }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusGroup(idx: Int) {
|
||||||
|
_focus.value = idx
|
||||||
|
|
||||||
|
val current = groupList.entities.value[idx].group
|
||||||
|
evaluationMatrix.focus(current)
|
||||||
|
students.focus(current)
|
||||||
|
studentGrades.focus(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusPrev() {
|
||||||
|
if (focus.value > 0) focusGroup(focus.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focusNext() {
|
||||||
|
if (focus.value < groupList.entities.value.size - 1) focusGroup(focus.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setStudentEvaluation(evaluator: Student, evaluatee: Student, grade: Grade, feedback: String) = transaction {
|
||||||
|
val existing = PeerEvaluationS2S.find {
|
||||||
|
(PeerEvaluationS2SEvaluations.peerEvalId eq asPeer.id) and
|
||||||
|
(PeerEvaluationS2SEvaluations.studentId eq evaluator.id) and
|
||||||
|
(PeerEvaluationS2SEvaluations.evaluatedStudentId eq evaluatee.id)
|
||||||
|
}.firstOrNull()
|
||||||
|
|
||||||
|
if(existing != null) {
|
||||||
|
existing.evaluation.feedback = feedback
|
||||||
|
when(grade) {
|
||||||
|
is Grade.Categoric -> existing.evaluation.gradeCategoric = grade.value
|
||||||
|
is Grade.Numeric -> existing.evaluation.gradeNumeric = grade.value
|
||||||
|
is Grade.Percentage -> existing.evaluation.gradeNumeric = grade.percentage
|
||||||
|
is Grade.FreeText -> existing.evaluation.gradeFreeText = grade.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val base = BaseFeedback.new {
|
||||||
|
criterion = studentCriterion.criterion
|
||||||
|
this.feedback = feedback
|
||||||
|
when(grade) {
|
||||||
|
is Grade.Categoric -> this.gradeCategoric = grade.value
|
||||||
|
is Grade.Numeric -> this.gradeNumeric = grade.value
|
||||||
|
is Grade.Percentage -> this.gradeNumeric = grade.percentage
|
||||||
|
is Grade.FreeText -> this.gradeFreeText = grade.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PeerEvaluationS2S.new {
|
||||||
|
evaluation = base
|
||||||
|
peerEvaluation = asPeer
|
||||||
|
student = evaluator
|
||||||
|
evaluatedStudent = evaluatee
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setStudentGroupEvaluation(evaluator: Student, group: Group, grade: Grade, feedback: String) = transaction {
|
||||||
|
val existing = PeerEvaluationS2G.find {
|
||||||
|
(PeerEvaluationS2GEvaluations.peerEvalId eq asPeer.id) and
|
||||||
|
(PeerEvaluationS2GEvaluations.studentId eq evaluator.id) and
|
||||||
|
(PeerEvaluationS2GEvaluations.groupId eq group.id)
|
||||||
|
}.firstOrNull()
|
||||||
|
|
||||||
|
if(existing != null) {
|
||||||
|
existing.evaluation.feedback = feedback
|
||||||
|
when(grade) {
|
||||||
|
is Grade.Categoric -> existing.evaluation.gradeCategoric = grade.value
|
||||||
|
is Grade.Numeric -> existing.evaluation.gradeNumeric = grade.value
|
||||||
|
is Grade.Percentage -> existing.evaluation.gradeNumeric = grade.percentage
|
||||||
|
is Grade.FreeText -> existing.evaluation.gradeFreeText = grade.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val base = BaseFeedback.new {
|
||||||
|
criterion = studentCriterion.criterion
|
||||||
|
this.feedback = feedback
|
||||||
|
when(grade) {
|
||||||
|
is Grade.Categoric -> this.gradeCategoric = grade.value
|
||||||
|
is Grade.Numeric -> this.gradeNumeric = grade.value
|
||||||
|
is Grade.Percentage -> this.gradeNumeric = grade.percentage
|
||||||
|
is Grade.FreeText -> this.gradeFreeText = grade.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PeerEvaluationS2G.new {
|
||||||
|
evaluation = base
|
||||||
|
peerEvaluation = asPeer
|
||||||
|
student = evaluator
|
||||||
|
this.group = group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEvaluation(evaluator: Student, evaluatee: Student?, group: Group, grade: Grade, feedback: String) {
|
||||||
|
println("Setting: evaluator=${evaluator.name}, evaluatee=${evaluatee?.name}, group=${group.name}, grade=$grade, feedback=$feedback")
|
||||||
|
evaluatee?.let { setStudentEvaluation(evaluator, it, grade, feedback) } ?: setStudentGroupEvaluation(evaluator, group, grade, feedback)
|
||||||
|
|
||||||
|
evaluationMatrix.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStudentGrade(student: Student, grade: Grade, feedback: String) = transaction {
|
||||||
|
val existing = BaseFeedback.find { BaseFeedbacks.criterionId eq global.criterion.id }
|
||||||
|
.find { it.asPeerEvaluationFeedback?.id?.value == student.id.value }
|
||||||
|
|
||||||
|
if(existing != null) {
|
||||||
|
existing.feedback = feedback
|
||||||
|
when(grade) {
|
||||||
|
is Grade.Categoric -> existing.gradeCategoric = grade.value
|
||||||
|
is Grade.Numeric -> existing.gradeNumeric = grade.value
|
||||||
|
is Grade.Percentage -> existing.gradeNumeric = grade.percentage
|
||||||
|
is Grade.FreeText -> existing.gradeFreeText = grade.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val base = BaseFeedback.new {
|
||||||
|
criterion = global.criterion
|
||||||
|
this.feedback = feedback
|
||||||
|
when(grade) {
|
||||||
|
is Grade.Categoric -> this.gradeCategoric = grade.value
|
||||||
|
is Grade.Numeric -> this.gradeNumeric = grade.value
|
||||||
|
is Grade.Percentage -> this.gradeNumeric = grade.percentage
|
||||||
|
is Grade.FreeText -> this.gradeFreeText = grade.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PeerEvaluationFeedbacks.insert {
|
||||||
|
it[PeerEvaluationFeedbacks.feedbackId] = base.id
|
||||||
|
it[PeerEvaluationFeedbacks.studentId] = student.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
studentGrades.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.jaytux.grader.viewmodel
|
||||||
|
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.jaytux.grader.data.v2.BaseFeedback
|
||||||
|
import com.jaytux.grader.data.v2.CategoricGrade
|
||||||
|
import com.jaytux.grader.data.v2.CategoricGradeOption
|
||||||
|
import com.jaytux.grader.data.v2.Criterion
|
||||||
|
import com.jaytux.grader.data.v2.GradeType
|
||||||
|
import com.jaytux.grader.data.v2.NumericGrade
|
||||||
|
import com.jaytux.grader.maxN
|
||||||
|
import org.jetbrains.exposed.v1.core.Transaction
|
||||||
|
|
||||||
|
sealed class UiGradeType {
|
||||||
|
object FreeText : UiGradeType()
|
||||||
|
object Percentage : UiGradeType()
|
||||||
|
data class Numeric(val grade: NumericGrade) : UiGradeType()
|
||||||
|
data class Categoric(val options: List<CategoricGradeOption>, val grade: CategoricGrade) : UiGradeType()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
context(trns: Transaction)
|
||||||
|
fun from(type: GradeType, categoric: CategoricGrade?, numeric: NumericGrade?) = when(type) {
|
||||||
|
GradeType.CATEGORIC -> Categoric(categoric!!.options.toList(), categoric)
|
||||||
|
GradeType.NUMERIC -> Numeric(numeric!!)
|
||||||
|
GradeType.PERCENTAGE -> Percentage
|
||||||
|
GradeType.NONE -> FreeText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,39 @@
|
|||||||
[versions]
|
[versions]
|
||||||
androidx-lifecycle = "2.8.4"
|
androidx-lifecycle = "2.9.6"
|
||||||
compose-multiplatform = "1.8.1"
|
compose-multiplatform = "1.9.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.1.0"
|
kotlin = "2.3.0"
|
||||||
kotlinx-coroutines = "1.10.1"
|
kotlinx-coroutines = "1.10.1"
|
||||||
exposed = "0.59.0"
|
exposed = "1.1.1"
|
||||||
material3 = "1.7.3"
|
material3 = "1.9.0"
|
||||||
ui-android = "1.7.8"
|
ui-android = "1.7.8"
|
||||||
foundation-layout-android = "1.7.8"
|
foundation-layout-android = "1.7.8"
|
||||||
rtf = "1.0.0-rc11"
|
rtf = "1.0.0-rc11"
|
||||||
filekit = "0.10.0-beta04"
|
filekit = "0.10.0-beta04"
|
||||||
|
directories = "26"
|
||||||
|
androidx-activity-compose = "1.12.2"
|
||||||
|
jewel = "0.34.0-253.31033.149"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||||
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
|
||||||
|
compose-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-multiplatform" }
|
||||||
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||||
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||||
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
|
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
|
||||||
exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" }
|
exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" }
|
||||||
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }
|
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }
|
||||||
exposed-migration = { group = "org.jetbrains.exposed", name = "exposed-migration", version.ref = "exposed" }
|
exposed-migration = { group = "org.jetbrains.exposed", name = "exposed-migration-core", version.ref = "exposed" }
|
||||||
|
exposed-migration-jdbc = { group = "org.jetbrains.exposed", name = "exposed-migration-jdbc", version.ref = "exposed" }
|
||||||
exposed-kotlin-datetime = { group = "org.jetbrains.exposed", name = "exposed-kotlin-datetime", version.ref = "exposed" }
|
exposed-kotlin-datetime = { group = "org.jetbrains.exposed", name = "exposed-kotlin-datetime", version.ref = "exposed" }
|
||||||
sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.34.0" }
|
sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.34.0" }
|
||||||
sl4j = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.12" }
|
sl4j = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.12" }
|
||||||
material3-core = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "material3" }
|
material3-core = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "material3" }
|
||||||
material3-desktop = { group = "org.jetbrains.compose.material3", name = "material3-desktop", version.ref = "material3" }
|
material3-desktop = { group = "org.jetbrains.compose.material3", name = "material3-desktop", version.ref = "material3" }
|
||||||
material-icons = { group = "org.jetbrains.compose.material", name = "material-icons-extended", version.ref = "material3" }
|
|
||||||
androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "ui-android" }
|
androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "ui-android" }
|
||||||
androidx-foundation-layout-android = { group = "androidx.compose.foundation", name = "foundation-layout-android", version.ref = "foundation-layout-android" }
|
androidx-foundation-layout-android = { group = "androidx.compose.foundation", name = "foundation-layout-android", version.ref = "foundation-layout-android" }
|
||||||
rtfield = { group = "com.mohamedrejeb.richeditor", name = "richeditor-compose", version.ref = "rtf" }
|
rtfield = { group = "com.mohamedrejeb.richeditor", name = "richeditor-compose", version.ref = "rtf" }
|
||||||
@@ -35,6 +41,9 @@ 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 = { 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-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" }
|
filekit-coil = { group = "io.github.vinceglb", name = "filekit-coil", version.ref = "filekit" }
|
||||||
|
directories = { group = "dev.dirs", name = "directories", version.ref = "directories" }
|
||||||
|
jewel = { group = "org.jetbrains.jewel", name = "jewel-int-ui-standalone", version.ref = "jewel" }
|
||||||
|
jewel-windows = { group = "org.jetbrains.jewel", name = "jewel-int-ui-decorated-window", version.ref = "jewel" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||||
|
|||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-all.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
12
gradlew
vendored
12
gradlew
vendored
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright © 2015-2021 the original authors.
|
# Copyright © 2015 the original authors.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -86,8 +86,7 @@ done
|
|||||||
# shellcheck disable=SC2034
|
# shellcheck disable=SC2034
|
||||||
APP_BASE_NAME=${0##*/}
|
APP_BASE_NAME=${0##*/}
|
||||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
' "$PWD" ) || exit
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD=maximum
|
MAX_FD=maximum
|
||||||
@@ -115,7 +114,6 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
@@ -173,7 +171,6 @@ fi
|
|||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
if "$cygwin" || "$msys" ; then
|
if "$cygwin" || "$msys" ; then
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
@@ -206,15 +203,14 @@ fi
|
|||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
# Collect all arguments for the java command:
|
# Collect all arguments for the java command:
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
# and any embedded shellness will be escaped.
|
# and any embedded shellness will be escaped.
|
||||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
# treated as '${Hostname}' itself on the command line.
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
org.gradle.wrapper.GradleWrapperMain \
|
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
3
gradlew.bat
vendored
3
gradlew.bat
vendored
@@ -70,11 +70,10 @@ goto fail
|
|||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
|||||||
Reference in New Issue
Block a user