Restructure DB & UI, pt.1
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,4 +18,4 @@ captures
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
**/grader.db
|
||||
**/*.backup
|
||||
**/*.backup
|
||||
|
||||
@@ -7,12 +7,17 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm("desktop")
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xcontext-parameters")
|
||||
}
|
||||
|
||||
jvm("desktop") {}
|
||||
|
||||
sourceSets {
|
||||
val desktopMain by getting
|
||||
|
||||
commonMain.dependencies {
|
||||
|
||||
desktopMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material)
|
||||
@@ -22,16 +27,13 @@ kotlin {
|
||||
implementation(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.material3.core)
|
||||
implementation(libs.material.icons)
|
||||
implementation(libs.sl4j)
|
||||
}
|
||||
desktopMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
implementation(libs.exposed.core)
|
||||
implementation(libs.exposed.jdbc)
|
||||
implementation(libs.exposed.dao)
|
||||
implementation(libs.exposed.migration)
|
||||
implementation(libs.exposed.migration.jdbc)
|
||||
implementation(libs.exposed.kotlin.datetime)
|
||||
implementation(libs.sqlite)
|
||||
implementation(libs.material3.desktop)
|
||||
@@ -40,6 +42,9 @@ kotlin {
|
||||
implementation(libs.filekit.dialogs)
|
||||
implementation(libs.filekit.dialogs.compose)
|
||||
implementation(libs.filekit.coil)
|
||||
implementation(libs.directories)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.compose.backhandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +57,7 @@ compose.desktop {
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "com.jaytux.grader"
|
||||
mainClass = "com.jaytux.grader.MainKt"
|
||||
packageVersion = "1.0.0"
|
||||
includeAllModules = true
|
||||
|
||||
|
||||
4866
composeApp/hs_err_pid2264499.log
Normal file
4866
composeApp/hs_err_pid2264499.log
Normal file
File diff suppressed because one or more lines are too long
@@ -1,43 +1,37 @@
|
||||
package com.jaytux.grader
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jaytux.grader.ui.ChevronLeft
|
||||
import com.jaytux.grader.ui.CoursesView
|
||||
import com.jaytux.grader.ui.toDp
|
||||
import com.jaytux.grader.viewmodel.CourseListState
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
import com.jaytux.grader.data.v2.BaseAssignment
|
||||
import com.jaytux.grader.data.v2.Course
|
||||
import com.jaytux.grader.data.v2.Edition
|
||||
import com.jaytux.grader.ui.EditionTitle
|
||||
import com.jaytux.grader.ui.EditionView
|
||||
import com.jaytux.grader.ui.GroupsGradingTitle
|
||||
import com.jaytux.grader.ui.GroupsGradingView
|
||||
import com.jaytux.grader.ui.HomeTitle
|
||||
import com.jaytux.grader.ui.HomeView
|
||||
import com.jaytux.grader.ui.PeerEvalsGradingTitle
|
||||
import com.jaytux.grader.ui.PeerEvalsGradingView
|
||||
import com.jaytux.grader.ui.SolosGradingTitle
|
||||
import com.jaytux.grader.ui.SolosGradingView
|
||||
import com.jaytux.grader.viewmodel.Navigator
|
||||
|
||||
data class UiRoute(val heading: String, val content: @Composable (push: (UiRoute) -> Unit) -> Unit)
|
||||
object Home : Navigator.IDestination
|
||||
data class EditionDetail(val ed: Edition, val course: Course) : Navigator.IDestination
|
||||
data class GroupGrading(val course: Course, val edition: Edition, val assignment: BaseAssignment) : Navigator.IDestination
|
||||
data class SoloGrading(val course: Course, val edition: Edition, val assignment: BaseAssignment) : Navigator.IDestination
|
||||
data class PeerEvalGrading(val course: Course, val edition: Edition, val assignment: BaseAssignment) : Navigator.IDestination
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
MaterialTheme {
|
||||
val courseList = CourseListState()
|
||||
var stack by remember {
|
||||
val start = UiRoute("Courses Overview") { CoursesView(courseList, it) }
|
||||
mutableStateOf(listOf(start))
|
||||
}
|
||||
|
||||
Column {
|
||||
Surface(Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primary, tonalElevation = 10.dp, shadowElevation = 10.dp) {
|
||||
Row(Modifier.padding(10.dp)) {
|
||||
IconButton({ stack = stack.toMutableList().also { it.removeLast() } }, enabled = stack.size >= 2) {
|
||||
Icon(ChevronLeft, "Back", Modifier.size(MaterialTheme.typography.headlineLarge.fontSize.toDp()))
|
||||
}
|
||||
Text(stack.last().heading, Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.headlineLarge)
|
||||
}
|
||||
}
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Box {
|
||||
stack.last().content { stack += (it) }
|
||||
}
|
||||
}
|
||||
Navigator.NavHost(Home) {
|
||||
composable<Home>({ HomeTitle() }) { _, token -> HomeView(token) }
|
||||
composable<EditionDetail>({ EditionTitle(it) }) { data, token -> EditionView(data, token) }
|
||||
composable<GroupGrading>({ GroupsGradingTitle(it) }) { data, token -> GroupsGradingView(data, token) }
|
||||
composable<SoloGrading>({ SolosGradingTitle(it) }) { data, token -> SolosGradingView(data, token) }
|
||||
composable<PeerEvalGrading>({ PeerEvalsGradingTitle(it) }) { data, token -> PeerEvalsGradingView(data, token) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
package com.jaytux.grader
|
||||
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.Clipboard
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.jaytux.grader.data.Database
|
||||
import com.mohamedrejeb.richeditor.model.RichTextState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Clock
|
||||
import java.time.LocalDateTime
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
fun String.maxN(n: Int): String {
|
||||
return if (this.length > n) {
|
||||
@@ -14,10 +20,25 @@ fun String.maxN(n: Int): String {
|
||||
}
|
||||
}
|
||||
|
||||
fun RichTextState.toClipboard(clip: ClipboardManager) {
|
||||
suspend fun RichTextState.toClipboard(clip: ClipboardManager) {
|
||||
clip.setText(AnnotatedString(this.toMarkdown()))
|
||||
}
|
||||
|
||||
fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) {
|
||||
suspend fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) {
|
||||
scope.launch { setMarkdown(clip.getText()?.text ?: "") }
|
||||
}
|
||||
}
|
||||
|
||||
object Preferences {
|
||||
private val _p = Preferences.userNodeForPackage(this::class.java)
|
||||
|
||||
operator fun get(key: String): String? = _p.get(key, null)
|
||||
operator fun set(key: String, value: String) {
|
||||
_p.put(key, value)
|
||||
}
|
||||
|
||||
var exportPath
|
||||
get() = get("exportPath") ?: System.getProperty("user.home") + "/grader_export"
|
||||
set(value) { set("exportPath", value) }
|
||||
}
|
||||
|
||||
infix fun <T1, T2, T3> Pair<T1, T2>.app(x: T3) = Triple(first, second, x)
|
||||
|
||||
@@ -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
|
||||
|
||||
import MigrationUtils
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.jetbrains.exposed.sql.SchemaUtils
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import com.jaytux.grader.app
|
||||
import com.jaytux.grader.data.v2.CategoricGrade
|
||||
import com.jaytux.grader.data.v2.CategoricGradeOption
|
||||
import com.jaytux.grader.data.v2.CategoricGradeOptions
|
||||
import com.jaytux.grader.data.v2.CategoricGrades
|
||||
import com.jaytux.grader.data.v2.Courses
|
||||
import com.jaytux.grader.data.v2.NumericGrade
|
||||
import com.jaytux.grader.data.v2.v2Tables
|
||||
import dev.dirs.ProjectDirectories
|
||||
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import kotlin.getValue
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.exists
|
||||
import org.jetbrains.exposed.v1.jdbc.Database
|
||||
import org.jetbrains.exposed.v1.jdbc.batchInsert
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||
|
||||
object Database {
|
||||
val dataDir: String = ProjectDirectories.from("com", "jaytux", "grader").dataDir.also {
|
||||
val path = Path(it)
|
||||
if(!path.exists()) path.createDirectories()
|
||||
}
|
||||
val db by lazy {
|
||||
val actual = Database.connect("jdbc:sqlite:file:./grader.db", "org.sqlite.JDBC")
|
||||
transaction {
|
||||
SchemaUtils.create(
|
||||
Courses, Editions, Groups,
|
||||
Students, GroupStudents, EditionStudents,
|
||||
GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria,
|
||||
GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks,
|
||||
PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation,
|
||||
StudentToGroupEvaluation
|
||||
)
|
||||
|
||||
val migrate = MigrationUtils.statementsRequiredForDatabaseMigration(
|
||||
Courses, Editions, Groups,
|
||||
Students, GroupStudents, EditionStudents,
|
||||
GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria,
|
||||
GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks,
|
||||
PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation,
|
||||
StudentToGroupEvaluation,
|
||||
withLogs = true
|
||||
)
|
||||
|
||||
println(" --- Migration --- ")
|
||||
migrate.forEach { println(it); exec(it) }
|
||||
println(" --- End migration --- ")
|
||||
val actual = Database.connect("jdbc:sqlite:file:${dataDir}/grader.db", "org.sqlite.JDBC")
|
||||
transaction(actual) {
|
||||
SchemaUtils.create(*v2Tables)
|
||||
}
|
||||
|
||||
actual
|
||||
}
|
||||
|
||||
fun init() { db }
|
||||
fun init() {
|
||||
TransactionManager.defaultDatabase = db
|
||||
transaction {
|
||||
if(CategoricGrade.count() == 0L) {
|
||||
val (pf, af) = CategoricGrades.batchInsert(listOf("Pass/Fail", "Default A-F"), shouldReturnGeneratedValues = true) {
|
||||
this[CategoricGrades.name] = it
|
||||
}.map {
|
||||
it[CategoricGrades.id]
|
||||
}
|
||||
|
||||
CategoricGradeOptions.batchInsert(
|
||||
listOf("Pass", "Fail").mapIndexed { idx, it -> it to pf app idx } +
|
||||
listOf("A (Excellent)", "B (Good)", "C (Satisfactory)", "D (Poor)", "F (Fail)").mapIndexed { idx, it -> it to af app idx }
|
||||
) {
|
||||
this[CategoricGradeOptions.option] = it.first
|
||||
this[CategoricGradeOptions.gradeId] = it.second
|
||||
this[CategoricGradeOptions.index] = it.third
|
||||
}
|
||||
}
|
||||
|
||||
if(NumericGrade.count() == 0L) {
|
||||
NumericGrade.new {
|
||||
name = "Max-20"
|
||||
max = 20.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import com.jaytux.grader.viewmodel.Assignment
|
||||
import com.jaytux.grader.viewmodel.GroupAssignmentState
|
||||
import io.github.vinceglb.filekit.PlatformFile
|
||||
|
||||
class MdBuilder {
|
||||
private val content = StringBuilder()
|
||||
|
||||
fun appendHeader(text: String, level: Int = 1) {
|
||||
require(level in 1..6) { "Header level must be between 1 and 6" }
|
||||
content.appendLine()
|
||||
content.appendLine("#".repeat(level) + " $text")
|
||||
content.appendLine()
|
||||
}
|
||||
fun appendMd(text: String) { content.appendLine(text) }
|
||||
fun appendParagraph(text: String, bold: Boolean = false, italic: Boolean = false) {
|
||||
val formattedText = buildString {
|
||||
if (bold) append("**")
|
||||
if (italic) append("_")
|
||||
append(text)
|
||||
if (italic) append("_")
|
||||
if (bold) append("**")
|
||||
}
|
||||
content.appendLine(formattedText)
|
||||
content.appendLine()
|
||||
}
|
||||
|
||||
fun build(): String = content.toString()
|
||||
}
|
||||
|
||||
fun GroupAssignmentState.LocalGFeedback.exportTo(path: PlatformFile, assignment: GroupAssignment) {
|
||||
val builder = MdBuilder()
|
||||
builder.appendHeader("${assignment.name} Feedback for ${group.name}")
|
||||
if(feedback.global != null && feedback.global.grade.isNotBlank()) {
|
||||
val global = feedback.global.grade
|
||||
builder.appendParagraph("Overall grade: ${feedback.global.grade}", true, true)
|
||||
|
||||
individuals.forEach { (student, it) ->
|
||||
val (_, data) = it
|
||||
if(data.global != null && data.global.grade.isNotBlank() && data.global.grade != global) {
|
||||
builder.appendParagraph("${student.name} grade: ${data.global.grade}", true, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun appendFeedback(heading: String, group: GroupAssignmentState.FeedbackEntry?, byStudent: List<Pair<Student, GroupAssignmentState.FeedbackEntry>>) {
|
||||
if(group != null || byStudent.isNotEmpty()) {
|
||||
builder.appendHeader(heading, 2)
|
||||
if(group != null) {
|
||||
if(group.grade.isNotBlank()) {
|
||||
builder.appendParagraph("Group grade: ${group.grade}", true, true)
|
||||
}
|
||||
if(group.feedback.isNotBlank()) {
|
||||
builder.appendMd(group.feedback)
|
||||
}
|
||||
}
|
||||
|
||||
byStudent.forEach { (student, it) ->
|
||||
if(it.grade.isNotBlank() || it.feedback.isNotBlank()) builder.appendHeader(student.name, 3)
|
||||
if(it.grade.isNotBlank()) {
|
||||
builder.appendParagraph("Grade: ${it.grade}", true, true)
|
||||
}
|
||||
if(it.feedback.isNotBlank()) {
|
||||
builder.appendMd(it.feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendFeedback("Overall Feedback", feedback.global,
|
||||
individuals.mapNotNull { it.second.second.global?.let { g -> it.first to g } }
|
||||
)
|
||||
|
||||
val criteria = (feedback.byCriterion.map { (c, _) -> c } +
|
||||
individuals.flatMap { (_, it) -> it.second.byCriterion.map { (c, _) -> c } }).distinctBy { it.id.value }
|
||||
|
||||
criteria.forEach { c ->
|
||||
appendFeedback(
|
||||
c.name,
|
||||
feedback.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry,
|
||||
individuals.mapNotNull { (student, it) ->
|
||||
val entry = it.second.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry
|
||||
entry?.let { student to it }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
path.file.writeText(builder.build())
|
||||
}
|
||||
//class MdBuilder {
|
||||
// private val content = StringBuilder()
|
||||
//
|
||||
// fun appendHeader(text: String, level: Int = 1) {
|
||||
// require(level in 1..6) { "Header level must be between 1 and 6" }
|
||||
// content.appendLine()
|
||||
// content.appendLine("#".repeat(level) + " $text")
|
||||
// content.appendLine()
|
||||
// }
|
||||
// fun appendMd(text: String) { content.appendLine(text) }
|
||||
// fun appendParagraph(text: String, bold: Boolean = false, italic: Boolean = false) {
|
||||
// val formattedText = buildString {
|
||||
// if (bold) append("**")
|
||||
// if (italic) append("_")
|
||||
// append(text)
|
||||
// if (italic) append("_")
|
||||
// if (bold) append("**")
|
||||
// }
|
||||
// content.appendLine(formattedText)
|
||||
// content.appendLine()
|
||||
// }
|
||||
//
|
||||
// fun build(title: String, scheme: String = "dark"): String = "${prologue(title, scheme)}\n\n${content.toString()}"
|
||||
// private fun prologue(title: String, scheme: String = "dark") = """
|
||||
// ---
|
||||
// title: $title
|
||||
// date: ${Clock.System.now()}
|
||||
// header-includes:
|
||||
// - '<link rel="stylesheet" href="https://classless.de/classless-tiny.css" media="(prefers-color-scheme: $scheme)" />'
|
||||
// - '<link rel="stylesheet" href="https://classless.de/addons/themes.css" media="(prefers-color-scheme: light)" />'
|
||||
// ---
|
||||
// """.trimIndent()
|
||||
//}
|
||||
//
|
||||
//object Exporter {
|
||||
// private fun MdBuilder.appendGroupFeedback(assignment: GroupAssignment, it: GroupAssignmentState.LocalGFeedback) {
|
||||
// appendHeader("${assignment.name} (group: ${it.group.name})", 1)
|
||||
// if (it.feedback.global != null && it.feedback.global.grade.isNotBlank()) {
|
||||
// val global = it.feedback.global.grade
|
||||
// appendParagraph("Overall grade: ${it.feedback.global.grade}", true, true)
|
||||
//
|
||||
// it.individuals.forEach { (student, it) ->
|
||||
// val (_, data) = it
|
||||
// if (data.global != null && data.global.grade.isNotBlank() && data.global.grade != global) {
|
||||
// appendParagraph("${student.name} grade: ${data.global.grade}", true, true)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// fun appendFeedback(
|
||||
// heading: String,
|
||||
// group: GroupAssignmentState.FeedbackEntry?,
|
||||
// byStudent: List<Pair<Student, GroupAssignmentState.FeedbackEntry>>
|
||||
// ) {
|
||||
// if (group != null || byStudent.isNotEmpty()) {
|
||||
// appendHeader(heading, 2)
|
||||
// if (group != null) {
|
||||
// if (group.grade.isNotBlank()) {
|
||||
// appendParagraph("Group grade: ${group.grade}", true, true)
|
||||
// }
|
||||
// if (group.feedback.isNotBlank()) {
|
||||
// appendMd(group.feedback)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// byStudent.forEach { (student, it) ->
|
||||
// if (it.grade.isNotBlank() || it.feedback.isNotBlank()) appendHeader(student.name, 3)
|
||||
// if (it.grade.isNotBlank()) {
|
||||
// appendParagraph("Grade: ${it.grade}", true, true)
|
||||
// }
|
||||
// if (it.feedback.isNotBlank()) {
|
||||
// appendMd(it.feedback)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// appendFeedback(
|
||||
// "Overall Feedback", it.feedback.global,
|
||||
// it.individuals.mapNotNull { ind -> ind.second.second.global?.let { g -> ind.first to g } }
|
||||
// )
|
||||
//
|
||||
// val criteria = (it.feedback.byCriterion.map { (c, _) -> c } +
|
||||
// it.individuals.flatMap { (_, x) -> x.second.byCriterion.map { (c, _) -> c } }).distinctBy { x -> x.id.value }
|
||||
//
|
||||
// criteria.forEach { c ->
|
||||
// appendFeedback(
|
||||
// c.name,
|
||||
// it.feedback.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry,
|
||||
// it.individuals.mapNotNull { (student, s) ->
|
||||
// val entry = s.second.byCriterion.firstOrNull { it.criterion.id == c.id }?.entry
|
||||
// entry?.let { student to it }
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private fun MdBuilder.outputTo(path: Path, title: String) {
|
||||
// val contents = build(title)
|
||||
// val buffer = Buffer()
|
||||
// buffer.write(contents.toByteArray())
|
||||
// SystemFileSystem.sink(path, false).write(buffer, buffer.size)
|
||||
// }
|
||||
//
|
||||
// fun GroupAssignmentState.LocalGFeedback.exportTo(path: Path, assignment: GroupAssignment) {
|
||||
// val builder = MdBuilder()
|
||||
// builder.appendGroupFeedback(assignment, this)
|
||||
// builder.outputTo(path, "${assignment.name} (for group ${group.name})")
|
||||
// }
|
||||
//
|
||||
// fun GroupAssignmentState.batchExport(dirPath: Path) {
|
||||
// feedback.entities.value.forEach { (_, it) ->
|
||||
// it.exportTo(dirPath / "${it.group.name} (${assignment.name}).md", assignment)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.jaytux.grader.data.v2
|
||||
|
||||
import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable
|
||||
import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable
|
||||
import org.jetbrains.exposed.v1.datetime.datetime
|
||||
|
||||
object Courses : UUIDTable("courses") {
|
||||
val name = varchar("name", 50).uniqueIndex()
|
||||
}
|
||||
|
||||
object Editions : UUIDTable("editions") {
|
||||
val courseId = reference("course_id", Courses.id)
|
||||
val name = varchar("name", 50)
|
||||
val archived = bool("archived").default(false)
|
||||
|
||||
init {
|
||||
uniqueIndex(courseId, name)
|
||||
}
|
||||
}
|
||||
|
||||
object Groups : UUIDTable("groups") {
|
||||
val editionId = reference("edition_id", Editions.id)
|
||||
val name = varchar("name", 50)
|
||||
|
||||
init {
|
||||
uniqueIndex(editionId, name)
|
||||
}
|
||||
}
|
||||
|
||||
object Students : UUIDTable("students") {
|
||||
val name = varchar("name", 50)
|
||||
val contact = varchar("contact", 50)
|
||||
val note = text("note")
|
||||
}
|
||||
|
||||
object GroupStudents : UUIDTable("grpStudents") {
|
||||
val groupId = reference("group_id", Groups.id)
|
||||
val studentId = reference("student_id", Students.id)
|
||||
val role = varchar("role", 50).nullable()
|
||||
|
||||
init {
|
||||
uniqueIndex(groupId, studentId)
|
||||
}
|
||||
}
|
||||
|
||||
object EditionStudents : CompositeIdTable("editionStudents") {
|
||||
val editionId = reference("edition_id", Editions.id)
|
||||
val studentId = reference("student_id", Students.id)
|
||||
|
||||
override val primaryKey = PrimaryKey(editionId, studentId)
|
||||
}
|
||||
|
||||
object BaseAssignments : UUIDTable("baseAssgmts") {
|
||||
val editionId = reference("edition_id", Editions.id)
|
||||
val name = varchar("name", 50)
|
||||
val assignment = text("assignment")
|
||||
val globalCriterion = reference("global_crit", Criteria.id)
|
||||
val deadline = datetime("deadline")
|
||||
val number = integer("number").nullable()
|
||||
val type = enumerationByName("type", 20, AssignmentType::class)
|
||||
}
|
||||
|
||||
object Criteria : UUIDTable("criteria") {
|
||||
val assignmentId = reference("assignment_id", BaseAssignments.id)
|
||||
val name = varchar("name", 50)
|
||||
val desc = text("desc")
|
||||
val gradeType = enumerationByName("grade_type", 20, GradeType::class)
|
||||
val categoricGrade = reference("categoric_grade_id", CategoricGrades.id).nullable()
|
||||
val numericGrade = reference("numeric_grade_id", NumericGrades.id).nullable()
|
||||
}
|
||||
|
||||
object GroupAssignments : UUIDTable("grpAssgmts") {
|
||||
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
|
||||
}
|
||||
|
||||
object SoloAssignments : UUIDTable("soloAssgmts") {
|
||||
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
|
||||
}
|
||||
|
||||
object BaseFeedbacks : UUIDTable("baseFeedbacks") {
|
||||
val criterionId = reference("criterion_id", Criteria.id)
|
||||
val feedback = text("feedback")
|
||||
val gradeFreeText = varchar("grade_text", 32).nullable()
|
||||
val gradeCategoric = reference("grade_categoric", CategoricGradeOptions.id).nullable()
|
||||
val gradeNumeric = double("grade_numeric").nullable()
|
||||
}
|
||||
|
||||
object GroupFeedbacks : CompositeIdTable("grpFdbks") {
|
||||
val groupId = reference("group_id", Groups.id)
|
||||
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||
|
||||
override val primaryKey = PrimaryKey(groupId, feedbackId)
|
||||
}
|
||||
|
||||
object StudentOverrideFeedbacks : UUIDTable("studOvrFdbks") {
|
||||
val groupId = reference("group_id", Groups.id)
|
||||
val studentId = reference("student_id", Students.id)
|
||||
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||
val overrides = reference("overrides", GroupFeedbacks.feedbackId)
|
||||
}
|
||||
|
||||
object SoloFeedbacks : CompositeIdTable("soloFdbks") {
|
||||
val studentId = reference("student_id", Students.id)
|
||||
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||
|
||||
override val primaryKey = PrimaryKey(studentId, feedbackId)
|
||||
}
|
||||
|
||||
object PeerEvaluations : UUIDTable("peerEvals") {
|
||||
val baseAssignmentId = reference("base_assignment_id", BaseAssignments.id).uniqueIndex()
|
||||
}
|
||||
|
||||
object PeerEvaluationFeedbacks : CompositeIdTable("peerEvalFdbks") {
|
||||
val groupId = reference("group_id", Groups.id)
|
||||
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||
|
||||
override val primaryKey = PrimaryKey(groupId, feedbackId)
|
||||
}
|
||||
|
||||
object PeerEvaluationStudentOverrideFeedbacks : UUIDTable("peerEvalStudOvrFdbks") {
|
||||
val groupId = reference("group_id", Groups.id)
|
||||
val studentId = reference("student_id", Students.id)
|
||||
val feedbackId = reference("feedback_id", BaseFeedbacks.id)
|
||||
val overrides = reference("overrides", BaseFeedbacks.id)
|
||||
}
|
||||
|
||||
object PeerEvaluationS2GEvaluations : UUIDTable("peerEvalS2GEvals") {
|
||||
val peerEvalId = reference("peer_eval_id", PeerEvaluations.id)
|
||||
val studentId = reference("student_id", Students.id)
|
||||
val groupId = reference("group_id", Groups.id)
|
||||
val evaluationId = reference("evaluation_id", BaseFeedbacks.id)
|
||||
|
||||
init {
|
||||
uniqueIndex(peerEvalId, groupId, studentId)
|
||||
}
|
||||
}
|
||||
|
||||
object PeerEvaluationS2SEvaluations : UUIDTable("peerEvalS2SEvals") {
|
||||
val peerEvalId = reference("peer_eval_id", PeerEvaluations.id)
|
||||
val studentId = reference("student_id", Students.id)
|
||||
val evaluatedStudentId = reference("evaluated_student_id", Students.id)
|
||||
val evaluationId = reference("evaluation_id", BaseFeedbacks.id)
|
||||
|
||||
init {
|
||||
uniqueIndex(peerEvalId, studentId, evaluatedStudentId)
|
||||
}
|
||||
}
|
||||
|
||||
object CategoricGrades : UUIDTable("categoricGrades") {
|
||||
val name = varchar("name", 50).uniqueIndex()
|
||||
}
|
||||
|
||||
object CategoricGradeOptions : UUIDTable("categoricGradeOpts") {
|
||||
val gradeId = reference("grade_id", CategoricGrades.id)
|
||||
val option = varchar("option", 50)
|
||||
val index = integer("index")
|
||||
|
||||
init {
|
||||
uniqueIndex(gradeId, option)
|
||||
}
|
||||
}
|
||||
|
||||
object NumericGrades : UUIDTable("numericGrades") {
|
||||
val name = varchar("name", 50).uniqueIndex()
|
||||
val max = double("max")
|
||||
}
|
||||
|
||||
enum class GradeType {
|
||||
CATEGORIC, NUMERIC, PERCENTAGE, NONE
|
||||
}
|
||||
|
||||
enum class AssignmentType(val display: String) {
|
||||
GROUP("Group Assignment"), SOLO("Individual Assignment"), PEER_EVALUATION("Peer Evaluation")
|
||||
}
|
||||
|
||||
val v2Tables = arrayOf(
|
||||
Courses, Editions, Groups, Students, GroupStudents, EditionStudents, BaseAssignments, Criteria, GroupAssignments,
|
||||
SoloAssignments, BaseFeedbacks, GroupFeedbacks, StudentOverrideFeedbacks, SoloFeedbacks, PeerEvaluations,
|
||||
PeerEvaluationFeedbacks, PeerEvaluationStudentOverrideFeedbacks, PeerEvaluationS2GEvaluations,
|
||||
PeerEvaluationS2SEvaluations, CategoricGrades, CategoricGradeOptions, NumericGrades
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.jaytux.grader.data.v2
|
||||
|
||||
import org.jetbrains.exposed.v1.dao.Entity
|
||||
import org.jetbrains.exposed.v1.dao.EntityClass
|
||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||
import org.jetbrains.exposed.v1.dao.java.UUIDEntity
|
||||
import org.jetbrains.exposed.v1.dao.java.UUIDEntityClass
|
||||
import java.util.UUID
|
||||
|
||||
class Course(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : UUIDEntityClass<Course>(Courses)
|
||||
|
||||
var name by Courses.name
|
||||
|
||||
val editions by Edition referrersOn Editions.courseId orderBy Editions.name
|
||||
}
|
||||
|
||||
class Edition(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, Edition>(Editions)
|
||||
|
||||
var course by Course referencedOn Editions.courseId
|
||||
var name by Editions.name
|
||||
var archived by Editions.archived
|
||||
|
||||
val students by Student via EditionStudents orderBy Students.name
|
||||
val groups by Group referrersOn Groups.editionId orderBy Groups.name
|
||||
val assignments by BaseAssignment referrersOn BaseAssignments.editionId orderBy BaseAssignments.number
|
||||
}
|
||||
|
||||
class Group(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, Group>(Groups)
|
||||
|
||||
var edition by Edition referencedOn Groups.editionId
|
||||
var name by Groups.name
|
||||
|
||||
val students by GroupStudent referrersOn GroupStudents.groupId
|
||||
val feedbacks by BaseFeedback via GroupFeedbacks
|
||||
}
|
||||
|
||||
class Student(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, Student>(Students)
|
||||
|
||||
var name by Students.name
|
||||
var note by Students.note
|
||||
var contact by Students.contact
|
||||
|
||||
val editions by Edition via EditionStudents orderBy Editions.name
|
||||
val groups by GroupStudent referrersOn GroupStudents.studentId
|
||||
}
|
||||
|
||||
class GroupStudent(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, GroupStudent>(GroupStudents)
|
||||
|
||||
var student by Student referencedOn GroupStudents.studentId
|
||||
var group by Group referencedOn GroupStudents.groupId
|
||||
var role by GroupStudents.role
|
||||
}
|
||||
|
||||
class BaseAssignment(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, BaseAssignment>(BaseAssignments)
|
||||
|
||||
var name by BaseAssignments.name
|
||||
var assignment by BaseAssignments.assignment
|
||||
var globalCriterion by Criterion referencedOn BaseAssignments.globalCriterion
|
||||
var deadline by BaseAssignments.deadline
|
||||
var number by BaseAssignments.number
|
||||
var edition by Edition referencedOn BaseAssignments.editionId
|
||||
var type by BaseAssignments.type
|
||||
|
||||
private val _asGroupAssignment by GroupAssignment referrersOn GroupAssignments.baseAssignmentId
|
||||
private val _asSoloAssignment by SoloAssignment referrersOn SoloAssignments.baseAssignmentId
|
||||
private val _asPeerEvaluation by PeerEvaluation referrersOn PeerEvaluations.baseAssignmentId
|
||||
val asGroupAssignment get() = _asGroupAssignment.singleOrNull()
|
||||
val asSoloAssignment get() = _asSoloAssignment.singleOrNull()
|
||||
val asPeerEvaluation get() = _asPeerEvaluation.singleOrNull()
|
||||
|
||||
val criteria by Criterion referrersOn Criteria.assignmentId orderBy Criteria.name
|
||||
val nonBaseCriteria get() = criteria.filterNot { it.id.value == globalCriterion.id.value }
|
||||
}
|
||||
|
||||
class GroupAssignment(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, GroupAssignment>(GroupAssignments)
|
||||
|
||||
var base by BaseAssignment referencedOn GroupAssignments.baseAssignmentId
|
||||
}
|
||||
|
||||
class SoloAssignment(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, SoloAssignment>(SoloAssignments)
|
||||
|
||||
var base by BaseAssignment referencedOn SoloAssignments.baseAssignmentId
|
||||
}
|
||||
|
||||
class PeerEvaluation(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, PeerEvaluation>(PeerEvaluations)
|
||||
|
||||
var base by BaseAssignment referencedOn PeerEvaluations.baseAssignmentId
|
||||
}
|
||||
|
||||
class CategoricGrade(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, CategoricGrade>(CategoricGrades)
|
||||
|
||||
var name by CategoricGrades.name
|
||||
|
||||
val options by CategoricGradeOption referrersOn CategoricGradeOptions.gradeId orderBy CategoricGradeOptions.index
|
||||
}
|
||||
|
||||
class CategoricGradeOption(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, CategoricGradeOption>(CategoricGradeOptions)
|
||||
|
||||
var grade by CategoricGrade referencedOn CategoricGradeOptions.gradeId
|
||||
var option by CategoricGradeOptions.option
|
||||
var index by CategoricGradeOptions.index
|
||||
}
|
||||
|
||||
class NumericGrade(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, NumericGrade>(NumericGrades)
|
||||
|
||||
var name by NumericGrades.name
|
||||
var max by NumericGrades.max
|
||||
}
|
||||
|
||||
class Criterion(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, Criterion>(Criteria)
|
||||
|
||||
var assignment by BaseAssignment referencedOn Criteria.assignmentId
|
||||
var name by Criteria.name
|
||||
var desc by Criteria.desc
|
||||
var gradeType by Criteria.gradeType
|
||||
var categoricGrade by CategoricGrade optionalReferencedOn Criteria.categoricGrade
|
||||
var numericGrade by NumericGrade optionalReferencedOn Criteria.numericGrade
|
||||
|
||||
val feedbacks by BaseFeedback referrersOn BaseFeedbacks.criterionId
|
||||
}
|
||||
|
||||
class BaseFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, BaseFeedback>(BaseFeedbacks)
|
||||
|
||||
var criterion by Criterion referencedOn BaseFeedbacks.criterionId
|
||||
var feedback by BaseFeedbacks.feedback
|
||||
var gradeFreeText by BaseFeedbacks.gradeFreeText
|
||||
var gradeCategoric by CategoricGradeOption optionalReferencedOn BaseFeedbacks.gradeCategoric
|
||||
var gradeNumeric by BaseFeedbacks.gradeNumeric
|
||||
|
||||
private val _forStudentIfSolo by Student via SoloFeedbacks
|
||||
private val _forGroupIfGroup by Group via GroupFeedbacks
|
||||
private val _forGroupIfPeer by Group via PeerEvaluationFeedbacks
|
||||
|
||||
val asSoloFeedback get() = _forStudentIfSolo.singleOrNull()
|
||||
val asGroupFeedback get() = _forGroupIfGroup.singleOrNull()
|
||||
val asPeerEvaluationFeedback get() = _forGroupIfPeer.singleOrNull()
|
||||
|
||||
val forStudentsOverrideIfGroup by StudentOverrideFeedback referrersOn StudentOverrideFeedbacks.overrides
|
||||
val forStudentsOverrideIfPeer by PeerEvaluationStudentOverrideFeedback referrersOn PeerEvaluationStudentOverrideFeedbacks.overrides
|
||||
}
|
||||
|
||||
class StudentOverrideFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, StudentOverrideFeedback>(StudentOverrideFeedbacks)
|
||||
|
||||
var group by Group referencedOn StudentOverrideFeedbacks.groupId
|
||||
var student by Student referencedOn StudentOverrideFeedbacks.studentId
|
||||
var feedback by BaseFeedback referencedOn StudentOverrideFeedbacks.feedbackId
|
||||
var overrides by BaseFeedback referencedOn StudentOverrideFeedbacks.overrides
|
||||
}
|
||||
|
||||
class PeerEvaluationStudentOverrideFeedback(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, PeerEvaluationStudentOverrideFeedback>(PeerEvaluationStudentOverrideFeedbacks)
|
||||
|
||||
var group by Group referencedOn PeerEvaluationStudentOverrideFeedbacks.groupId
|
||||
var student by Student referencedOn PeerEvaluationStudentOverrideFeedbacks.studentId
|
||||
var feedback by BaseFeedback referencedOn PeerEvaluationStudentOverrideFeedbacks.feedbackId
|
||||
var overrides by BaseFeedback referencedOn PeerEvaluationStudentOverrideFeedbacks.overrides
|
||||
}
|
||||
|
||||
class PeerEvaluationS2G(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, PeerEvaluationS2G>(PeerEvaluationS2GEvaluations)
|
||||
|
||||
var peerEvaluation by PeerEvaluation referencedOn PeerEvaluationS2GEvaluations.peerEvalId
|
||||
var student by Student referencedOn PeerEvaluationS2GEvaluations.studentId
|
||||
var group by Group referencedOn PeerEvaluationS2GEvaluations.groupId
|
||||
var evaluation by BaseFeedback referencedOn PeerEvaluationS2GEvaluations.evaluationId
|
||||
}
|
||||
|
||||
class PeerEvaluationS2S(id: EntityID<UUID>) : UUIDEntity(id) {
|
||||
companion object : EntityClass<UUID, PeerEvaluationS2S>(PeerEvaluationS2SEvaluations)
|
||||
|
||||
var peerEvaluation by PeerEvaluation referencedOn PeerEvaluationS2SEvaluations.peerEvalId
|
||||
var student by Student referencedOn PeerEvaluationS2SEvaluations.studentId
|
||||
var evaluatedStudent by Student referencedOn PeerEvaluationS2SEvaluations.evaluatedStudentId
|
||||
var evaluation by BaseFeedback referencedOn PeerEvaluationS2SEvaluations.evaluationId
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,500 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
import com.jaytux.grader.GroupGrading
|
||||
import com.jaytux.grader.PeerEvalGrading
|
||||
import com.jaytux.grader.SoloGrading
|
||||
import com.jaytux.grader.data.v2.AssignmentType
|
||||
import com.jaytux.grader.viewmodel.EditionVM
|
||||
import com.jaytux.grader.viewmodel.Navigator
|
||||
import com.jaytux.grader.viewmodel.UiGradeType
|
||||
import com.mohamedrejeb.richeditor.model.rememberRichTextState
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.datetime.format.MonthNames
|
||||
import kotlinx.datetime.format.char
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Composable
|
||||
fun AssignmentsView(vm: EditionVM, token: Navigator.NavToken) = Row(Modifier.fillMaxSize()) {
|
||||
val assignments by vm.assignmentList.entities
|
||||
val focus by vm.focusIndex
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val descRtf = rememberRichTextState()
|
||||
val assignment = remember(assignments, focus) {
|
||||
assignments.getOrNull(focus)?.also {
|
||||
scope.launch { descRtf.setMarkdown(it.global.criterion.desc) }
|
||||
}
|
||||
}
|
||||
|
||||
var updatingDeadline by remember { mutableStateOf(false) }
|
||||
var addingRubric by remember { mutableStateOf(false) }
|
||||
var editingRubric by remember { mutableStateOf(-1) }
|
||||
var updatingGrade by remember { mutableStateOf(false) }
|
||||
|
||||
val navToGrading = lambda@{
|
||||
if(assignment == null) return@lambda
|
||||
when(assignment.assignment.type) {
|
||||
AssignmentType.GROUP -> token.navTo(GroupGrading(vm.course, vm.edition, assignment.assignment))
|
||||
AssignmentType.SOLO -> token.navTo(SoloGrading(vm.course, vm.edition, assignment.assignment))
|
||||
AssignmentType.PEER_EVALUATION -> token.navTo(PeerEvalGrading(vm.course, vm.edition, assignment.assignment))
|
||||
}
|
||||
}
|
||||
|
||||
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
|
||||
ListOrEmpty(assignments, { Text("No groups yet.") }) { idx, it ->
|
||||
QuickAssignment(idx, it, vm)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(Modifier.weight(0.75f).fillMaxHeight(), tonalElevation = 1.dp) {
|
||||
if (assignment == null) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Text("Select an assignment to see details.", Modifier.padding(10.dp).align(Alignment.Center), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(assignment.assignment.name, style = MaterialTheme.typography.headlineMedium)
|
||||
Text("Deadline: ${assignment.assignment.deadline.format(fmt)}", Modifier.padding(top = 5.dp).clickable { updatingDeadline = true }, fontStyle = FontStyle.Italic)
|
||||
Row {
|
||||
Text("${assignment.assignment.type.display} using grading ", Modifier.align(Alignment.CenterVertically))
|
||||
Surface(shape = MaterialTheme.shapes.small, tonalElevation = 10.dp) {
|
||||
Box(Modifier.clickable { updatingGrade = true }.padding(3.dp)) {
|
||||
Text(when(val t = assignment.global.gradeType){
|
||||
is UiGradeType.Categoric -> t.grade.name
|
||||
UiGradeType.FreeText -> "by free-form grades"
|
||||
is UiGradeType.Numeric -> t.grade.name
|
||||
UiGradeType.Percentage -> "by percentages"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Column(Modifier.weight(0.75f)) {
|
||||
Row {
|
||||
Text("Description:", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 10.dp).weight(1f))
|
||||
Button({ vm.setDesc(assignment, descRtf.toMarkdown()) }) {
|
||||
Text("Update")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
RichTextField(descRtf)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Surface(Modifier.weight(0.25f), color = Color.White) {
|
||||
Column(Modifier.padding(15.dp)) {
|
||||
Row {
|
||||
Text("Grading Rubrics", Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall)
|
||||
IconButton({ addingRubric = true }) {
|
||||
Icon(CirclePlus, "Add grading rubric")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
itemsIndexed(assignment.criteria) { idx, it ->
|
||||
Row(Modifier.padding(5.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(it.criterion.name)
|
||||
Text(it.criterion.desc, Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
IconButton({ editingRubric = idx }, Modifier.align(Alignment.Top)) {
|
||||
Icon(Edit, "Edit grading rubric")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Button({ navToGrading() }, Modifier.fillMaxWidth()) {
|
||||
Text("Go to grading")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(updatingDeadline) {
|
||||
if(assignment == null) updatingDeadline = false
|
||||
else {
|
||||
DeadlinePicker(assignment.assignment.deadline, { updatingDeadline = false }) {
|
||||
vm.modAssignment(assignment.assignment, null, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(addingRubric) {
|
||||
if(assignment == null) addingRubric = false
|
||||
else {
|
||||
AddCriterionDialog(null, vm, assignment.criteria.map { it.criterion.name }, { addingRubric = false }) { name, desc, type ->
|
||||
vm.mkCriterion(assignment.assignment, name, desc, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(editingRubric != -1) {
|
||||
if(assignment == null) editingRubric = -1
|
||||
else {
|
||||
AddCriterionDialog(assignment.criteria[editingRubric], vm, assignment.criteria.map { it.criterion.name }, { editingRubric = -1 }) { name, desc, type ->
|
||||
vm.modCriterion(assignment.criteria[editingRubric].criterion, name, desc, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(updatingGrade) {
|
||||
if(assignment == null) updatingGrade = false
|
||||
else {
|
||||
SetGradingDialog(assignment.assignment.name, assignment.global.gradeType, vm, { updatingGrade = false }) { type ->
|
||||
vm.modCriterion(assignment.global.criterion, null, null, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fmt = LocalDateTime.Format {
|
||||
date(LocalDate.Format {
|
||||
day(); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED); char(' '); year()
|
||||
})
|
||||
char(' ')
|
||||
time(LocalTime.Format {
|
||||
amPmHour(); char(':'); minute(); char(' '); amPmMarker("AM", "PM")
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickAssignment(idx: Int, assignment: EditionVM.AssignmentData, vm: EditionVM) {
|
||||
val focus by vm.focusIndex
|
||||
Surface(tonalElevation = if(focus == idx) 15.dp else 0.dp, shape = MaterialTheme.shapes.small) {
|
||||
Column(Modifier.fillMaxWidth().clickable { vm.focus(idx) }.padding(10.dp)) {
|
||||
Text(assignment.assignment.name, fontWeight = FontWeight.Bold)
|
||||
Text("Deadline: ${assignment.assignment.deadline.format(fmt)}", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddAssignmentDialog(label: String, taken: List<String>, onClose: () -> Unit, current: String = "", onSave: (String, AssignmentType) -> Unit) = DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(750.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
var type by remember { mutableStateOf(AssignmentType.SOLO) }
|
||||
var name by remember(current) { mutableStateOf(current) }
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||
AssignmentType.entries.forEachIndexed { idx, it ->
|
||||
SegmentedButton(
|
||||
shape = SegmentedButtonDefaults.itemShape(idx, AssignmentType.entries.size),
|
||||
selected = type == it,
|
||||
onClick = { type = it }
|
||||
) { Text(it.display) }
|
||||
}
|
||||
}
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text(label) }, isError = name in taken)
|
||||
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
||||
onSave(name, type)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeadlinePicker(deadline: LocalDateTime, onDismiss: () -> Unit, onSave: (LocalDateTime) -> Unit) {
|
||||
val state = rememberDatePickerState(deadline.date.toJavaLocalDate())
|
||||
val (h, m) = deadline.time.let { it.hour to it.minute }
|
||||
val time = rememberTimePickerState(h, m)
|
||||
|
||||
val reconstruct = {
|
||||
val inst = Instant.fromEpochMilliseconds(state.selectedDateMillis!!)
|
||||
val date = inst.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
LocalDateTime(date.date, LocalTime(time.hour, time.minute))
|
||||
}
|
||||
|
||||
Dialog(onDismiss, DialogProperties()) {
|
||||
Surface(tonalElevation = 5.dp, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column(Modifier.padding(15.dp)) {
|
||||
DatePicker(state, Modifier.fillMaxWidth())
|
||||
TimeInput(time, Modifier.fillMaxWidth())
|
||||
Row {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Button({ onSave(reconstruct()); onDismiss() }) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddCriterionDialog(current: EditionVM.CriterionData?, vm: EditionVM, taken: List<String>, onClose: () -> Unit, onSave: (name: String, desc: String, type: UiGradeType) -> Unit) = DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
var type by remember(current) { mutableStateOf(current?.gradeType ?: UiGradeType.FreeText) }
|
||||
var name by remember(current) { mutableStateOf(current?.criterion?.name ?: "") }
|
||||
var desc by remember(current) { mutableStateOf(current?.criterion?.desc ?: "") }
|
||||
val categories by vm.categoricGrades.entities
|
||||
val numeric by vm.numericGrades.entities
|
||||
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text("Criterion Name") }, isError = name in taken, singleLine = true)
|
||||
OutlinedTextField(desc, { desc = it }, Modifier.fillMaxWidth(), label = { Text("Short Description") }, singleLine = true)
|
||||
Surface(shape = MaterialTheme.shapes.small, color = Color.White, modifier = Modifier.fillMaxWidth().padding(5.dp)) {
|
||||
Column {
|
||||
GradeTypePicker(type, categories, numeric, { n, o -> vm.mkScale(n, o) }, { n, m -> vm.mkNumericScale(n, m) }, Modifier.weight(1f)) { type = it }
|
||||
|
||||
CancelSaveRow(name.isNotBlank() && (name !in taken || name == current?.criterion?.name), onClose) {
|
||||
onSave(name, desc, type)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(current) { focus.requestFocus() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SetGradingDialog(name: String, current: UiGradeType, vm: EditionVM, onClose: () -> Unit, onSave: (type: UiGradeType) -> Unit) = DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
val categories by vm.categoricGrades.entities
|
||||
val numeric by vm.numericGrades.entities
|
||||
var type by remember(current) { mutableStateOf(current) }
|
||||
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
Text("Select a grading scale for $name", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 10.dp))
|
||||
Surface(shape = MaterialTheme.shapes.small, color = Color.White, modifier = Modifier.fillMaxWidth().padding(5.dp)) {
|
||||
Column {
|
||||
GradeTypePicker(type, categories, numeric, { n, o -> vm.mkScale(n, o) }, { n, m -> vm.mkNumericScale(n, m) }, Modifier.weight(1f)) { type = it }
|
||||
|
||||
CancelSaveRow(true, onClose) {
|
||||
onSave(type)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(current) { focus.requestFocus() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GradeTypePicker(
|
||||
type: UiGradeType, categories: List<UiGradeType.Categoric>, numeric: List<UiGradeType.Numeric>,
|
||||
mkCat: (String, List<String>) -> Unit, mkNum: (String, Double) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onUpdate: (UiGradeType) -> Unit
|
||||
) = Column(modifier) {
|
||||
var selectedCategory by remember(categories) {
|
||||
mutableStateOf(
|
||||
if(type is UiGradeType.Categoric) categories.indexOfFirst { it.grade.id == type.grade.id }
|
||||
else -1
|
||||
)
|
||||
}
|
||||
var selectedNumeric by remember(numeric) {
|
||||
mutableStateOf(
|
||||
if(type is UiGradeType.Numeric) numeric.indexOfFirst { it.grade.id == type.grade.id }
|
||||
else -1
|
||||
)
|
||||
}
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||
SegmentedButton(
|
||||
type is UiGradeType.FreeText, onClick = { onUpdate(UiGradeType.FreeText) },
|
||||
shape = SegmentedButtonDefaults.itemShape(0, 4)
|
||||
) {
|
||||
Text("Free-form grade")
|
||||
}
|
||||
SegmentedButton(
|
||||
type is UiGradeType.Percentage, onClick = { onUpdate(UiGradeType.Percentage) },
|
||||
shape = SegmentedButtonDefaults.itemShape(1, 4)
|
||||
) {
|
||||
Text("Percentage")
|
||||
}
|
||||
SegmentedButton(
|
||||
type is UiGradeType.Categoric, onClick = { onUpdate(categories[maxOf(selectedCategory, 0)]) },
|
||||
shape = SegmentedButtonDefaults.itemShape(2, 4)
|
||||
) {
|
||||
Text("Grading System")
|
||||
}
|
||||
SegmentedButton(
|
||||
type is UiGradeType.Numeric, onClick = { onUpdate(numeric[maxOf(selectedNumeric, 0)]) },
|
||||
shape = SegmentedButtonDefaults.itemShape(3, 4)
|
||||
) {
|
||||
Text("Numeric Grade")
|
||||
}
|
||||
}
|
||||
(type as? UiGradeType.Categoric)?.let {
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
itemsIndexed(categories) { idx, it ->
|
||||
Surface(
|
||||
tonalElevation = if (selectedCategory == idx) 15.dp else 0.dp,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().clickable { selectedCategory = idx; onUpdate(it) }.padding(10.dp)) {
|
||||
Text(it.grade.name, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"(${it.options.size} options)",
|
||||
Modifier.padding(start = 10.dp),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Button({ adding = true }, Modifier.fillMaxWidth()) {
|
||||
Text("Add grading system")
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: (type as? UiGradeType.Numeric)?.let {
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
itemsIndexed(numeric) { idx, it ->
|
||||
Surface(
|
||||
tonalElevation = if (selectedNumeric == idx) 15.dp else 0.dp,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().clickable { selectedNumeric = idx; onUpdate(it) }.padding(10.dp)) {
|
||||
Text(it.grade.name, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"(graded as X/${it.grade.max})",
|
||||
Modifier.padding(start = 10.dp),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Button({ adding = true }, Modifier.fillMaxWidth()) {
|
||||
Text("Add numeric system")
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: Spacer(Modifier.weight(1f))
|
||||
|
||||
if(adding) {
|
||||
when(type) {
|
||||
is UiGradeType.Categoric -> AddCatScaleDialog(categories.map { it.grade.name }, { adding = false }) { name, options ->
|
||||
mkCat(name, options)
|
||||
}
|
||||
is UiGradeType.Numeric -> AddNumScaleDialog(numeric.map { it.grade.name }, { adding = false }) { name, max ->
|
||||
mkNum(name, max)
|
||||
}
|
||||
else -> adding = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddCatScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String, List<String>) -> Unit) = DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var options by remember { mutableStateOf(listOf<String>()) }
|
||||
var adding by remember { mutableStateOf("") }
|
||||
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text("Grading system name") }, isError = name in taken, singleLine = true)
|
||||
Text("Grade options:", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(top = 10.dp))
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
itemsIndexed(options) { idx, it ->
|
||||
Row(Modifier.fillMaxWidth().padding(5.dp)) {
|
||||
Text(it, Modifier.weight(1f))
|
||||
IconButton({ options = options.filterNot { o -> o == it } }) {
|
||||
Icon(Delete, "Delete grading option")
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row {
|
||||
OutlinedTextField(adding, { adding = it }, Modifier.weight(1f).align(Alignment.CenterVertically).padding(5.dp), label = { Text("New option") }, isError = adding in options, singleLine = true)
|
||||
Button({ options = options + adding; adding = "" }, Modifier.align(Alignment.CenterVertically).padding(5.dp), enabled = adding.isNotBlank() && adding !in options) {
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
||||
onSave(name, options)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddNumScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String, Double) -> Unit) = DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(750.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var maxStr by remember { mutableStateOf("0") }
|
||||
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text("Grading system name") }, isError = name in taken, singleLine = true)
|
||||
OutlinedTextField(maxStr, { maxStr = it.toDoubleOrNull()?.let { _ -> it } ?: "0" }, Modifier.fillMaxWidth(), label = { Text("Maximum grade") }, singleLine = true)
|
||||
|
||||
CancelSaveRow(name.isNotBlank() && name !in taken && (maxStr.toDoubleOrNull() ?: 0.0) > 0.0, onClose) {
|
||||
onSave(name, maxStr.toDoubleOrNull()!!)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
}
|
||||
@@ -1,109 +1,87 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jaytux.grader.UiRoute
|
||||
import com.jaytux.grader.data.Edition
|
||||
import com.jaytux.grader.viewmodel.CourseListState
|
||||
import com.jaytux.grader.viewmodel.EditionListState
|
||||
import com.jaytux.grader.viewmodel.EditionState
|
||||
|
||||
@Composable
|
||||
fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) {
|
||||
val data by state.courses.entities
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.padding(15.dp)) {
|
||||
ListOrEmpty(
|
||||
data,
|
||||
{ Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) },
|
||||
{ Text("Add a course") },
|
||||
{ showDialog = true },
|
||||
addAfterLazy = false
|
||||
) { _, it ->
|
||||
CourseWidget(state.getEditions(it), { state.delete(it) }, push)
|
||||
}
|
||||
}
|
||||
|
||||
if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CourseWidget(state: EditionListState, onDelete: () -> Unit, push: (UiRoute) -> Unit) {
|
||||
val editions by state.editions.entities
|
||||
var isOpened by remember { mutableStateOf(false) }
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val callback = { it: Edition ->
|
||||
val s = EditionState(it)
|
||||
val route = UiRoute("${state.course.name}: ${it.name}") {
|
||||
EditionView(s)
|
||||
}
|
||||
push(route)
|
||||
}
|
||||
|
||||
Surface(Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 10.dp).clickable { isOpened = !isOpened }, shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 2.dp) {
|
||||
Row {
|
||||
Column(Modifier.weight(1f).padding(5.dp)) {
|
||||
Row {
|
||||
Icon(
|
||||
if (isOpened) ChevronDown else ChevronRight, "Toggle editions",
|
||||
Modifier.size(MaterialTheme.typography.headlineMedium.fontSize.toDp())
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
Column {
|
||||
Text(state.course.name, style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Spacer(Modifier.width(25.dp))
|
||||
Text(
|
||||
"${editions.size} edition(s)",
|
||||
fontStyle = FontStyle.Italic,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
if(isOpened) {
|
||||
Row {
|
||||
Spacer(Modifier.width(25.dp))
|
||||
Column {
|
||||
editions.forEach { EditionWidget(it, { callback(it) }) { state.delete(it) } }
|
||||
Button({ showDialog = true }, Modifier.fillMaxWidth()) { Text("Add edition") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column {
|
||||
IconButton({ onDelete() }) { Icon(Icons.Default.Delete, "Remove") }
|
||||
IconButton({ TODO() }, enabled = false) { Icon(Icons.Default.Edit, "Edit") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(showDialog) AddStringDialog("Edition name", editions.map { it.name }, { showDialog = false }) { state.new(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditionWidget(edition: Edition, onOpen: () -> Unit, onDelete: () -> Unit) {
|
||||
Surface(Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 10.dp).clickable { onOpen() }, shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 2.dp) {
|
||||
Row(Modifier.padding(5.dp)) {
|
||||
Text(edition.name, Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall)
|
||||
IconButton({ onDelete() }) { Icon(Icons.Default.Delete, "Remove") }
|
||||
}
|
||||
}
|
||||
}
|
||||
//@Composable
|
||||
//fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) {
|
||||
// val data by state.courses.entities
|
||||
// var showDialog by remember { mutableStateOf(false) }
|
||||
//
|
||||
// Box(Modifier.padding(15.dp)) {
|
||||
// ListOrEmpty(
|
||||
// data,
|
||||
// { Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) },
|
||||
// { Text("Add a course") },
|
||||
// { showDialog = true },
|
||||
// addAfterLazy = false
|
||||
// ) { _, it ->
|
||||
// CourseWidget(state.getEditions(it), { state.delete(it) }, push)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun CourseWidget(state: EditionListState, onDelete: () -> Unit, push: (UiRoute) -> Unit) {
|
||||
// val editions by state.editions.entities
|
||||
// var isOpened by remember { mutableStateOf(false) }
|
||||
// var showDialog by remember { mutableStateOf(false) }
|
||||
//
|
||||
// val callback = { it: Edition ->
|
||||
// val s = EditionState(it)
|
||||
// val route = UiRoute("${state.course.name}: ${it.name}") {
|
||||
// EditionView(s)
|
||||
// }
|
||||
// push(route)
|
||||
// }
|
||||
//
|
||||
// Surface(Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 10.dp).clickable { isOpened = !isOpened }, shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 2.dp) {
|
||||
// Row {
|
||||
// Column(Modifier.weight(1f).padding(5.dp)) {
|
||||
// Row {
|
||||
// Icon(
|
||||
// if (isOpened) ChevronDown else ChevronRight, "Toggle editions",
|
||||
// Modifier.size(MaterialTheme.typography.headlineMedium.fontSize.toDp())
|
||||
// .align(Alignment.CenterVertically)
|
||||
// )
|
||||
// Column {
|
||||
// Text(state.course.name, style = MaterialTheme.typography.headlineMedium)
|
||||
// }
|
||||
// }
|
||||
// Row {
|
||||
// Spacer(Modifier.width(25.dp))
|
||||
// Text(
|
||||
// "${editions.size} edition(s)",
|
||||
// fontStyle = FontStyle.Italic,
|
||||
// style = MaterialTheme.typography.bodySmall
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// if(isOpened) {
|
||||
// Row {
|
||||
// Spacer(Modifier.width(25.dp))
|
||||
// Column {
|
||||
// editions.forEach { EditionWidget(it, { callback(it) }) { state.delete(it) } }
|
||||
// Button({ showDialog = true }, Modifier.fillMaxWidth()) { Text("Add edition") }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Column {
|
||||
// IconButton({ onDelete() }) { Icon(Icons.Default.Delete, "Remove") }
|
||||
// IconButton({ TODO() }, enabled = false) { Icon(Icons.Default.Edit, "Edit") }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if(showDialog) AddStringDialog("Edition name", editions.map { it.name }, { showDialog = false }) { state.new(it) }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun EditionWidget(edition: Edition, onOpen: () -> Unit, onDelete: () -> Unit) {
|
||||
// Surface(Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 10.dp).clickable { onOpen() }, shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 2.dp) {
|
||||
// Row(Modifier.padding(5.dp)) {
|
||||
// Text(edition.name, Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall)
|
||||
// IconButton({ onDelete() }) { Icon(Icons.Default.Delete, "Remove") }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.jaytux.grader.EditionDetail
|
||||
import com.jaytux.grader.data.v2.BaseAssignment
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.viewmodel.EditionVM
|
||||
import com.jaytux.grader.viewmodel.Navigator
|
||||
|
||||
@Composable
|
||||
fun EditionTitle(data: EditionDetail) = Text("Courses / ${data.course.name} / ${data.ed.name}")
|
||||
|
||||
@Composable
|
||||
fun EditionView(data: EditionDetail, token: Navigator.NavToken) {
|
||||
val vm = viewModel<EditionVM>(key = data.ed.id.toString()) { EditionVM(data.ed, data.course) }
|
||||
val tab by vm.selectedTab
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
|
||||
val groups by vm.groupList.entities
|
||||
val assignments by vm.assignmentList.entities
|
||||
|
||||
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Row {
|
||||
Text("${vm.course.name} - ${vm.edition.name}", Modifier.weight(1f), style = MaterialTheme.typography.headlineMedium)
|
||||
Button({ adding = true }) {
|
||||
Icon(CirclePlus, "Add ${tab.addText}")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Add ${tab.addText}")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
PrimaryScrollableTabRow(tab.ordinal, edgePadding = 10.dp) {
|
||||
EditionVM.Tab.entries.forEach {
|
||||
Tab(tab == it, onClick = { vm.switchTo(it) }, modifier = Modifier.padding(horizontal = 5.dp)) { it.renderTab() }
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f)) {
|
||||
when (tab) {
|
||||
EditionVM.Tab.STUDENTS -> StudentsView(vm)
|
||||
EditionVM.Tab.GROUPS -> GroupsView(vm)
|
||||
EditionVM.Tab.ASSIGNMENTS -> AssignmentsView(vm, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(adding) {
|
||||
when(tab) {
|
||||
EditionVM.Tab.STUDENTS ->
|
||||
AddStringDialog("Student Name", listOf(), { adding = false }, "") { vm.mkStudent(it, "", "") }
|
||||
EditionVM.Tab.GROUPS ->
|
||||
AddStringDialog("Group Name", groups.map { it.group.name }, { adding = false }, "") { vm.mkGroup(it) }
|
||||
EditionVM.Tab.ASSIGNMENTS ->
|
||||
AddAssignmentDialog("Assignment Name", assignments.map { it.assignment.name }, { adding = false }, "") { name, type -> vm.mkAssignment(name, type) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StudentsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
|
||||
Icon(UserIcon, "Students")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Students")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
|
||||
Icon(UserGroupIcon, "Groups")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Groups")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AssignmentsTabHeader() = Row(Modifier.padding(all = 5.dp)) {
|
||||
Icon(AssignmentIcon, "Assignments")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Assignments")
|
||||
}
|
||||
@@ -1,419 +1,409 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogWindow
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.rememberDialogState
|
||||
import com.jaytux.grader.data.Course
|
||||
import com.jaytux.grader.data.Edition
|
||||
import com.jaytux.grader.data.Group
|
||||
import com.jaytux.grader.data.Student
|
||||
import com.jaytux.grader.viewmodel.*
|
||||
|
||||
data class Navigators(
|
||||
val student: (Student) -> Unit,
|
||||
val group: (Group) -> Unit,
|
||||
val assignment: (Assignment) -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) {
|
||||
val course = state.course; val edition = state.edition
|
||||
val students by state.students.entities
|
||||
val availableStudents by state.availableStudents.entities
|
||||
val groups by state.groups.entities
|
||||
val solo by state.solo.entities
|
||||
val groupAs by state.groupAs.entities
|
||||
val peers by state.peer.entities
|
||||
val mergedAssignments by remember(solo, groupAs, peers) { mutableStateOf(Assignment.merge(groupAs, solo, peers)) }
|
||||
val hist by state.history
|
||||
|
||||
val navs = Navigators(
|
||||
student = { state.navTo(OpenPanel.Student, students.indexOfFirst{ s -> s.id == it.id }) },
|
||||
group = { state.navTo(OpenPanel.Group, groups.indexOfFirst { g -> g.id == it.id }) },
|
||||
assignment = { state.navTo(OpenPanel.Assignment, mergedAssignments.indexOfFirst { a -> a.id() == it.id() }) }
|
||||
)
|
||||
|
||||
val (id, tab) = hist.last()
|
||||
Surface(Modifier.weight(0.25f), tonalElevation = 5.dp) {
|
||||
TabLayout(
|
||||
OpenPanel.entries,
|
||||
tab.ordinal,
|
||||
{ state.navTo(OpenPanel.entries[it]) },
|
||||
{ Text(it.tabName) }
|
||||
) {
|
||||
when(tab) {
|
||||
OpenPanel.Student -> StudentPanel(
|
||||
course, edition, students, availableStudents, id,
|
||||
{ state.navTo(it) },
|
||||
{ name, note, contact, add -> state.newStudent(name, contact, note, add) },
|
||||
{ students -> state.addToCourse(students) },
|
||||
{ s, name -> state.setStudentName(s, name) }
|
||||
) { s, idx -> state.delete(s); if(id == idx) state.clearHistoryIndex() }
|
||||
|
||||
OpenPanel.Group -> GroupPanel(
|
||||
course, edition, groups, id,
|
||||
{ state.navTo(it) },
|
||||
{ name -> state.newGroup(name) },
|
||||
{ g, name -> state.setGroupName(g, name) }
|
||||
) { g, idx -> state.delete(g); if(id == idx) state.clearHistoryIndex() }
|
||||
|
||||
OpenPanel.Assignment -> AssignmentPanel(
|
||||
course, edition, mergedAssignments, id,
|
||||
{ state.navTo(it) },
|
||||
{ type, name -> state.newAssignment(type, name) },
|
||||
{ a, name -> state.setAssignmentTitle(a, name) },
|
||||
{ a1, a2 -> state.swapOrder(a1, a2) }
|
||||
) { a, idx -> state.delete(a); if(id == idx) state.clearHistoryIndex() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.weight(0.75f)) {
|
||||
Row {
|
||||
IconButton({ state.back() }, enabled = hist.size >= 2) {
|
||||
Icon(ChevronLeft, "Back", Modifier.size(MaterialTheme.typography.headlineMedium.fontSize.toDp()).align(Alignment.CenterVertically))
|
||||
}
|
||||
when(tab) {
|
||||
OpenPanel.Student -> {
|
||||
if(id == -1) PaneHeader("Nothing selected", "students", course, edition)
|
||||
else PaneHeader(students[id].name, "student", course, edition)
|
||||
}
|
||||
OpenPanel.Group -> {
|
||||
if(id == -1) PaneHeader("Nothing selected", "groups", course, edition)
|
||||
else PaneHeader(groups[id].name, "group", course, edition)
|
||||
}
|
||||
OpenPanel.Assignment -> {
|
||||
if(id == -1) PaneHeader("Nothing selected", "assignments", course, edition)
|
||||
else {
|
||||
when(val a = mergedAssignments[id]) {
|
||||
is Assignment.SAssignment -> PaneHeader(a.name(), "individual assignment", course, edition)
|
||||
is Assignment.GAssignment -> PaneHeader(a.name(), "group assignment", course, edition)
|
||||
is Assignment.PeerEval -> PaneHeader(a.name(), "peer evaluation", course, edition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f)) {
|
||||
if (id != -1) {
|
||||
when (tab) {
|
||||
OpenPanel.Student -> StudentView(StudentState(students[id], edition), navs)
|
||||
OpenPanel.Group -> GroupView(GroupState(groups[id]), navs)
|
||||
OpenPanel.Assignment -> {
|
||||
when (val a = mergedAssignments[id]) {
|
||||
is Assignment.SAssignment -> SoloAssignmentView(SoloAssignmentState(a.assignment))
|
||||
is Assignment.GAssignment -> GroupAssignmentView(GroupAssignmentState(a.assignment))
|
||||
is Assignment.PeerEval -> PeerEvaluationView(PeerEvaluationState(a.evaluation))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StudentPanel(
|
||||
course: Course, edition: Edition, students: List<Student>, available: List<Student>,
|
||||
selected: Int, onSelect: (Int) -> Unit,
|
||||
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit,
|
||||
onImport: (List<Student>) -> Unit, onUpdate: (Student, String) -> Unit, onDelete: (Student, Int) -> Unit
|
||||
) = Column(Modifier.padding(10.dp)) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var deleting by remember { mutableStateOf(-1) }
|
||||
var editing by remember { mutableStateOf(-1) }
|
||||
|
||||
Text("Student list (${students.size})", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
ListOrEmpty(
|
||||
students,
|
||||
{ Text(
|
||||
"Course ${course.name} (edition ${edition.name})\nhas no students yet.",
|
||||
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
|
||||
) },
|
||||
{ Text("Add a student") },
|
||||
{ showDialog = true }
|
||||
) { idx, it ->
|
||||
SelectEditDeleteRow(
|
||||
selected == idx,
|
||||
{ onSelect(idx) }, { onSelect(-1) },
|
||||
{ editing = idx }, { deleting = idx }
|
||||
) {
|
||||
Text(it.name, Modifier.padding(5.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if(showDialog) {
|
||||
StudentDialog(course, edition, { showDialog = false }, available, onImport, onAdd)
|
||||
}
|
||||
else if(editing != -1) {
|
||||
AddStringDialog("Student name", students.map { it.name }, { editing = -1 }, students[editing].name) {
|
||||
onUpdate(students[editing], it)
|
||||
}
|
||||
}
|
||||
else if(deleting != -1) {
|
||||
ConfirmDeleteDialog(
|
||||
"a student",
|
||||
{ deleting = -1 },
|
||||
{ onDelete(students[deleting], deleting) }
|
||||
) { Text(students[deleting].name) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupPanel(
|
||||
course: Course, edition: Edition, groups: List<Group>,
|
||||
selected: Int, onSelect: (Int) -> Unit,
|
||||
onAdd: (String) -> Unit, onUpdate: (Group, String) -> Unit, onDelete: (Group, Int) -> Unit
|
||||
) = Column(Modifier.padding(10.dp)) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var deleting by remember { mutableStateOf(-1) }
|
||||
var editing by remember { mutableStateOf(-1) }
|
||||
|
||||
Text("Group list (${groups.size})", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
ListOrEmpty(
|
||||
groups,
|
||||
{ Text(
|
||||
"Course ${course.name} (edition ${edition.name})\nhas no groups yet.",
|
||||
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
|
||||
) },
|
||||
{ Text("Add a group") },
|
||||
{ showDialog = true }
|
||||
) { idx, it ->
|
||||
SelectEditDeleteRow(
|
||||
selected == idx,
|
||||
{ onSelect(idx) }, { onSelect(-1) },
|
||||
{ editing = idx }, { deleting = idx }
|
||||
) {
|
||||
Text(it.name, Modifier.padding(5.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if(showDialog) {
|
||||
AddStringDialog("Group name", groups.map{ it.name }, { showDialog = false }) { onAdd(it) }
|
||||
}
|
||||
else if(editing != -1) {
|
||||
AddStringDialog("Group name", groups.map { it.name }, { editing = -1 }, groups[editing].name) {
|
||||
onUpdate(groups[editing], it)
|
||||
}
|
||||
}
|
||||
else if(deleting != -1) {
|
||||
ConfirmDeleteDialog(
|
||||
"a group",
|
||||
{ deleting = -1 },
|
||||
{ onDelete(groups[deleting], deleting) }
|
||||
) { Text(groups[deleting].name) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AssignmentPanel(
|
||||
course: Course, edition: Edition, assignments: List<Assignment>,
|
||||
selected: Int, onSelect: (Int) -> Unit,
|
||||
onAdd: (AssignmentType, String) -> Unit, onUpdate: (Assignment, String) -> Unit,
|
||||
onSwapOrder: (Assignment, Assignment) -> Unit, onDelete: (Assignment, Int) -> Unit
|
||||
) = Column(Modifier.padding(10.dp)) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var deleting by remember { mutableStateOf(-1) }
|
||||
var editing by remember { mutableStateOf(-1) }
|
||||
|
||||
val dialog: @Composable (String, List<String>, () -> Unit, String, (AssignmentType, String) -> Unit) -> Unit =
|
||||
{ label, taken, onClose, current, onSave ->
|
||||
DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
var name by remember(current) { mutableStateOf(current) }
|
||||
var tab by remember { mutableStateOf(AssignmentType.Solo) }
|
||||
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
TabLayout(
|
||||
AssignmentType.entries,
|
||||
tab.ordinal,
|
||||
{ tab = AssignmentType.entries[it] },
|
||||
{ Text(it.show) }
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
OutlinedTextField(
|
||||
name,
|
||||
{ name = it },
|
||||
Modifier.fillMaxWidth(),
|
||||
label = { Text(label) },
|
||||
isError = name in taken
|
||||
)
|
||||
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
||||
onSave(tab, name)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Assignment list (${assignments.size})", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
ListOrEmpty(
|
||||
assignments,
|
||||
{ Text(
|
||||
"Course ${course.name} (edition ${edition.name})\nhas no assignments yet.",
|
||||
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
|
||||
) },
|
||||
{ Text("Add an assignment") },
|
||||
{ showDialog = true }
|
||||
) { idx, it ->
|
||||
Selectable(
|
||||
selected == idx,
|
||||
{ onSelect(idx) }, { onSelect(-1) }
|
||||
) {
|
||||
Row {
|
||||
Text(it.name(), Modifier.padding(5.dp).align(Alignment.CenterVertically).weight(1f))
|
||||
Column(Modifier.padding(2.dp)) {
|
||||
Icon(Icons.Default.ArrowUpward, "Move up", Modifier.clickable {
|
||||
if(idx > 0) onSwapOrder(assignments[idx], assignments[idx - 1])
|
||||
})
|
||||
Icon(Icons.Default.ArrowDownward, "Move down", Modifier.clickable {
|
||||
if(idx < assignments.size - 1) onSwapOrder(assignments[idx], assignments[idx + 1])
|
||||
})
|
||||
}
|
||||
Column(Modifier.padding(2.dp)) {
|
||||
Icon(Icons.Default.Edit, "Edit", Modifier.clickable { editing = idx })
|
||||
Icon(Icons.Default.Delete, "Delete", Modifier.clickable { deleting = idx })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(showDialog) {
|
||||
dialog("Assignment name", assignments.map{ it.name() }, { showDialog = false }, "", onAdd)
|
||||
}
|
||||
else if(editing != -1) {
|
||||
AddStringDialog("Assignment name", assignments.map { it.name() }, { editing = -1 }, assignments[editing].name()) {
|
||||
onUpdate(assignments[editing], it)
|
||||
}
|
||||
}
|
||||
else if(deleting != -1) {
|
||||
ConfirmDeleteDialog(
|
||||
"an assignment",
|
||||
{ deleting = -1 },
|
||||
{ onDelete(assignments[deleting], deleting) }
|
||||
) { if(deleting != -1) Text(assignments[deleting].name()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StudentDialog(
|
||||
course: Course,
|
||||
edition: Edition,
|
||||
onClose: () -> Unit,
|
||||
availableStudents: List<Student>,
|
||||
onImport: (List<Student>) -> Unit,
|
||||
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit
|
||||
) = DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(600.dp, 400.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
var isImport by remember { mutableStateOf(false) }
|
||||
TabRow(if(isImport) 1 else 0) {
|
||||
Tab(!isImport, { isImport = false }) { Text("Add new student") }
|
||||
Tab(isImport, { isImport = true }) { Text("Add existing student") }
|
||||
}
|
||||
|
||||
if(isImport) {
|
||||
if(availableStudents.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Text("No students available to add to this course.", Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
else {
|
||||
var selected by remember { mutableStateOf(setOf<Int>()) }
|
||||
|
||||
val onClick = { idx: Int ->
|
||||
selected = if(idx in selected) selected - idx else selected + idx
|
||||
}
|
||||
|
||||
Text("Select students to add to ${course.name} ${edition.name}")
|
||||
LazyColumn {
|
||||
itemsIndexed(availableStudents) { idx, student ->
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().clickable { onClick(idx) },
|
||||
tonalElevation = if (selected.contains(idx)) 5.dp else 0.dp
|
||||
) {
|
||||
Row {
|
||||
Checkbox(selected.contains(idx), { onClick(idx) })
|
||||
Text(student.name, Modifier.padding(5.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CancelSaveRow(selected.isNotEmpty(), onClose) {
|
||||
onImport(selected.map { idx -> availableStudents[idx] })
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var contact by remember { mutableStateOf("") }
|
||||
var note by remember { mutableStateOf("") }
|
||||
var add by remember { mutableStateOf(true) }
|
||||
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
OutlinedTextField(
|
||||
name,
|
||||
{ name = it },
|
||||
Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Student name") })
|
||||
OutlinedTextField(
|
||||
contact,
|
||||
{ contact = it },
|
||||
Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Student contact") })
|
||||
OutlinedTextField(
|
||||
note,
|
||||
{ note = it },
|
||||
Modifier.fillMaxWidth(),
|
||||
singleLine = false,
|
||||
minLines = 3,
|
||||
label = { Text("Note") })
|
||||
Row {
|
||||
Checkbox(add, { add = it })
|
||||
Text(
|
||||
"Add student to ${course.name} ${edition.name}?",
|
||||
Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
CancelSaveRow(name.isNotBlank() && contact.isNotBlank(), onClose) {
|
||||
onAdd(name, note, contact, add)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//data class Navigators(
|
||||
// val student: (Student) -> Unit,
|
||||
// val group: (Group) -> Unit,
|
||||
// val assignment: (Assignment) -> Unit
|
||||
//)
|
||||
//
|
||||
//@Composable
|
||||
//fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) {
|
||||
// val course = state.course; val edition = state.edition
|
||||
// val students by state.students.entities
|
||||
// val availableStudents by state.availableStudents.entities
|
||||
// val groups by state.groups.entities
|
||||
// val solo by state.solo.entities
|
||||
// val groupAs by state.groupAs.entities
|
||||
// val peers by state.peer.entities
|
||||
// val mergedAssignments by remember(solo, groupAs, peers) { mutableStateOf(Assignment.merge(groupAs, solo, peers)) }
|
||||
// val hist by state.history
|
||||
//
|
||||
// val scope = rememberCoroutineScope()
|
||||
//
|
||||
// var groupExporting by remember { mutableStateOf<GroupAssignmentState?>(null) }
|
||||
// val groupPopup = rememberDirectoryPickerLauncher(directory = PlatformFile(Preferences.exportPath)) { path ->
|
||||
// if(path != null) {
|
||||
// groupExporting?.let {
|
||||
// Preferences.exportPath = path.toKotlinxIoPath().toString()
|
||||
// scope.launch { it.batchExport(path.toKotlinxIoPath()) }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// val navs = Navigators(
|
||||
// student = { state.navTo(OpenPanel.Student, students.indexOfFirst{ s -> s.id == it.id }) },
|
||||
// group = { state.navTo(OpenPanel.Group, groups.indexOfFirst { g -> g.id == it.id }) },
|
||||
// assignment = { state.navTo(OpenPanel.Assignment, mergedAssignments.indexOfFirst { a -> a.id() == it.id() }) }
|
||||
// )
|
||||
//
|
||||
// val (id, tab) = hist.last()
|
||||
// Surface(Modifier.weight(0.25f), tonalElevation = 5.dp) {
|
||||
// TabLayout(
|
||||
// OpenPanel.entries,
|
||||
// tab.ordinal,
|
||||
// { state.navTo(OpenPanel.entries[it]) },
|
||||
// { Text(it.tabName) }
|
||||
// ) {
|
||||
// when(tab) {
|
||||
// OpenPanel.Student -> StudentPanel(
|
||||
// course, edition, students, availableStudents, id,
|
||||
// { state.navTo(it) },
|
||||
// { name, note, contact, add -> state.newStudent(name, contact, note, add) },
|
||||
// { students -> state.addToCourse(students) },
|
||||
// { s, name -> state.setStudentName(s, name) }
|
||||
// ) { s, idx -> state.delete(s); if(id == idx) state.clearHistoryIndex() }
|
||||
//
|
||||
// OpenPanel.Group -> GroupPanel(
|
||||
// course, edition, groups, id,
|
||||
// { state.navTo(it) },
|
||||
// { name -> state.newGroup(name) },
|
||||
// { g, name -> state.setGroupName(g, name) }
|
||||
// ) { g, idx -> state.delete(g); if(id == idx) state.clearHistoryIndex() }
|
||||
//
|
||||
// OpenPanel.Assignment -> AssignmentPanel(
|
||||
// course, edition, mergedAssignments, id,
|
||||
// { state.navTo(it) },
|
||||
// { type, name -> state.newAssignment(type, name) },
|
||||
// { a, name -> state.setAssignmentTitle(a, name) },
|
||||
// { a1, a2 -> state.swapOrder(a1, a2) }
|
||||
// ) { a, idx -> state.delete(a); if(id == idx) state.clearHistoryIndex() }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Column(Modifier.weight(0.75f)) {
|
||||
// Row {
|
||||
// IconButton({ state.back() }, enabled = hist.size >= 2) {
|
||||
// Icon(ChevronLeft, "Back", Modifier.size(MaterialTheme.typography.headlineMedium.fontSize.toDp()).align(Alignment.CenterVertically))
|
||||
// }
|
||||
// when(tab) {
|
||||
// OpenPanel.Student -> {
|
||||
// if(id == -1) PaneHeader("Nothing selected", "students", course, edition)
|
||||
// else PaneHeader(students[id].name, "student", course, edition)
|
||||
// }
|
||||
// OpenPanel.Group -> {
|
||||
// if(id == -1) PaneHeader("Nothing selected", "groups", course, edition)
|
||||
// else PaneHeader(groups[id].name, "group", course, edition)
|
||||
// }
|
||||
// OpenPanel.Assignment -> {
|
||||
// if(id == -1) PaneHeader("Nothing selected", "assignments", course, edition)
|
||||
// else {
|
||||
// when(val a = mergedAssignments[id]) {
|
||||
// is Assignment.SAssignment -> PaneHeader(a.name(), "individual assignment", course, edition)
|
||||
// is Assignment.GAssignment -> PaneHeader(a.name(), "group assignment", course, edition) {
|
||||
// groupExporting = GroupAssignmentState(a.assignment); groupPopup.launch()
|
||||
// }
|
||||
// is Assignment.PeerEval -> PaneHeader(a.name(), "peer evaluation", course, edition)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Box(Modifier.weight(1f)) {
|
||||
// if (id != -1) {
|
||||
// when (tab) {
|
||||
// OpenPanel.Student -> StudentView(StudentState(students[id], edition), navs)
|
||||
// OpenPanel.Group -> GroupView(GroupState(groups[id]), navs)
|
||||
// OpenPanel.Assignment -> {
|
||||
// when (val a = mergedAssignments[id]) {
|
||||
// is Assignment.SAssignment -> SoloAssignmentView(SoloAssignmentState(a.assignment))
|
||||
// is Assignment.GAssignment -> GroupAssignmentView(GroupAssignmentState(a.assignment))
|
||||
// is Assignment.PeerEval -> PeerEvaluationView(PeerEvaluationState(a.evaluation))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun StudentPanel(
|
||||
// course: Course, edition: Edition, students: List<Student>, available: List<Student>,
|
||||
// selected: Int, onSelect: (Int) -> Unit,
|
||||
// onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit,
|
||||
// onImport: (List<Student>) -> Unit, onUpdate: (Student, String) -> Unit, onDelete: (Student, Int) -> Unit
|
||||
//) = Column(Modifier.padding(10.dp)) {
|
||||
// var showDialog by remember { mutableStateOf(false) }
|
||||
// var deleting by remember { mutableStateOf(-1) }
|
||||
// var editing by remember { mutableStateOf(-1) }
|
||||
//
|
||||
// Text("Student list (${students.size})", style = MaterialTheme.typography.headlineMedium)
|
||||
//
|
||||
// ListOrEmpty(
|
||||
// students,
|
||||
// { Text(
|
||||
// "Course ${course.name} (edition ${edition.name})\nhas no students yet.",
|
||||
// Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
|
||||
// ) },
|
||||
// { Text("Add a student") },
|
||||
// { showDialog = true }
|
||||
// ) { idx, it ->
|
||||
// SelectEditDeleteRow(
|
||||
// selected == idx,
|
||||
// { onSelect(idx) }, { onSelect(-1) },
|
||||
// { editing = idx }, { deleting = idx }
|
||||
// ) {
|
||||
// Text(it.name, Modifier.padding(5.dp))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if(showDialog) {
|
||||
// StudentDialog(course, edition, { showDialog = false }, available, onImport, onAdd)
|
||||
// }
|
||||
// else if(editing != -1) {
|
||||
// AddStringDialog("Student name", students.map { it.name }, { editing = -1 }, students[editing].name) {
|
||||
// onUpdate(students[editing], it)
|
||||
// }
|
||||
// }
|
||||
// else if(deleting != -1) {
|
||||
// ConfirmDeleteDialog(
|
||||
// "a student",
|
||||
// { deleting = -1 },
|
||||
// { onDelete(students[deleting], deleting) }
|
||||
// ) { Text(students[deleting].name) }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun GroupPanel(
|
||||
// course: Course, edition: Edition, groups: List<Group>,
|
||||
// selected: Int, onSelect: (Int) -> Unit,
|
||||
// onAdd: (String) -> Unit, onUpdate: (Group, String) -> Unit, onDelete: (Group, Int) -> Unit
|
||||
//) = Column(Modifier.padding(10.dp)) {
|
||||
// var showDialog by remember { mutableStateOf(false) }
|
||||
// var deleting by remember { mutableStateOf(-1) }
|
||||
// var editing by remember { mutableStateOf(-1) }
|
||||
//
|
||||
// Text("Group list (${groups.size})", style = MaterialTheme.typography.headlineMedium)
|
||||
//
|
||||
// ListOrEmpty(
|
||||
// groups,
|
||||
// { Text(
|
||||
// "Course ${course.name} (edition ${edition.name})\nhas no groups yet.",
|
||||
// Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
|
||||
// ) },
|
||||
// { Text("Add a group") },
|
||||
// { showDialog = true }
|
||||
// ) { idx, it ->
|
||||
// SelectEditDeleteRow(
|
||||
// selected == idx,
|
||||
// { onSelect(idx) }, { onSelect(-1) },
|
||||
// { editing = idx }, { deleting = idx }
|
||||
// ) {
|
||||
// Text(it.name, Modifier.padding(5.dp))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if(showDialog) {
|
||||
// AddStringDialog("Group name", groups.map{ it.name }, { showDialog = false }) { onAdd(it) }
|
||||
// }
|
||||
// else if(editing != -1) {
|
||||
// AddStringDialog("Group name", groups.map { it.name }, { editing = -1 }, groups[editing].name) {
|
||||
// onUpdate(groups[editing], it)
|
||||
// }
|
||||
// }
|
||||
// else if(deleting != -1) {
|
||||
// ConfirmDeleteDialog(
|
||||
// "a group",
|
||||
// { deleting = -1 },
|
||||
// { onDelete(groups[deleting], deleting) }
|
||||
// ) { Text(groups[deleting].name) }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun AssignmentPanel(
|
||||
// course: Course, edition: Edition, assignments: List<Assignment>,
|
||||
// selected: Int, onSelect: (Int) -> Unit,
|
||||
// onAdd: (AssignmentType, String) -> Unit, onUpdate: (Assignment, String) -> Unit,
|
||||
// onSwapOrder: (Assignment, Assignment) -> Unit, onDelete: (Assignment, Int) -> Unit
|
||||
//) = Column(Modifier.padding(10.dp)) {
|
||||
// var showDialog by remember { mutableStateOf(false) }
|
||||
// var deleting by remember { mutableStateOf(-1) }
|
||||
// var editing by remember { mutableStateOf(-1) }
|
||||
//
|
||||
// val dialog: @Composable (String, List<String>, () -> Unit, String, (AssignmentType, String) -> Unit) -> Unit =
|
||||
// { label, taken, onClose, current, onSave ->
|
||||
// DialogWindow(
|
||||
// onCloseRequest = onClose,
|
||||
// state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||
// ) {
|
||||
// var name by remember(current) { mutableStateOf(current) }
|
||||
// var tab by remember { mutableStateOf(AssignmentType.Solo) }
|
||||
//
|
||||
// Surface(Modifier.fillMaxSize()) {
|
||||
// TabLayout(
|
||||
// AssignmentType.entries,
|
||||
// tab.ordinal,
|
||||
// { tab = AssignmentType.entries[it] },
|
||||
// { Text(it.show) }
|
||||
// ) {
|
||||
// Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
// Column(Modifier.align(Alignment.Center)) {
|
||||
// OutlinedTextField(
|
||||
// name,
|
||||
// { name = it },
|
||||
// Modifier.fillMaxWidth(),
|
||||
// label = { Text(label) },
|
||||
// isError = name in taken
|
||||
// )
|
||||
// CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
||||
// onSave(tab, name)
|
||||
// onClose()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Text("Assignment list (${assignments.size})", style = MaterialTheme.typography.headlineMedium)
|
||||
//
|
||||
// ListOrEmpty(
|
||||
// assignments,
|
||||
// { Text(
|
||||
// "Course ${course.name} (edition ${edition.name})\nhas no assignments yet.",
|
||||
// Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
|
||||
// ) },
|
||||
// { Text("Add an assignment") },
|
||||
// { showDialog = true }
|
||||
// ) { idx, it ->
|
||||
// Selectable(
|
||||
// selected == idx,
|
||||
// { onSelect(idx) }, { onSelect(-1) }
|
||||
// ) {
|
||||
// Row {
|
||||
// Text(it.name(), Modifier.padding(5.dp).align(Alignment.CenterVertically).weight(1f))
|
||||
// Column(Modifier.padding(2.dp)) {
|
||||
// Icon(Icons.Default.ArrowUpward, "Move up", Modifier.clickable {
|
||||
// if(idx > 0) onSwapOrder(assignments[idx], assignments[idx - 1])
|
||||
// })
|
||||
// Icon(Icons.Default.ArrowDownward, "Move down", Modifier.clickable {
|
||||
// if(idx < assignments.size - 1) onSwapOrder(assignments[idx], assignments[idx + 1])
|
||||
// })
|
||||
// }
|
||||
// Column(Modifier.padding(2.dp)) {
|
||||
// Icon(Icons.Default.Edit, "Edit", Modifier.clickable { editing = idx })
|
||||
// Icon(Icons.Default.Delete, "Delete", Modifier.clickable { deleting = idx })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if(showDialog) {
|
||||
// dialog("Assignment name", assignments.map{ it.name() }, { showDialog = false }, "", onAdd)
|
||||
// }
|
||||
// else if(editing != -1) {
|
||||
// AddStringDialog("Assignment name", assignments.map { it.name() }, { editing = -1 }, assignments[editing].name()) {
|
||||
// onUpdate(assignments[editing], it)
|
||||
// }
|
||||
// }
|
||||
// else if(deleting != -1) {
|
||||
// ConfirmDeleteDialog(
|
||||
// "an assignment",
|
||||
// { deleting = -1 },
|
||||
// { onDelete(assignments[deleting], deleting) }
|
||||
// ) { if(deleting != -1) Text(assignments[deleting].name()) }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun StudentDialog(
|
||||
// course: Course,
|
||||
// edition: Edition,
|
||||
// onClose: () -> Unit,
|
||||
// availableStudents: List<Student>,
|
||||
// onImport: (List<Student>) -> Unit,
|
||||
// onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit
|
||||
//) = DialogWindow(
|
||||
// onCloseRequest = onClose,
|
||||
// state = rememberDialogState(size = DpSize(600.dp, 400.dp), position = WindowPosition(Alignment.Center))
|
||||
//) {
|
||||
// Surface(Modifier.fillMaxSize()) {
|
||||
// Column(Modifier.padding(10.dp)) {
|
||||
// var isImport by remember { mutableStateOf(false) }
|
||||
// TabRow(if(isImport) 1 else 0) {
|
||||
// Tab(!isImport, { isImport = false }) { Text("Add new student") }
|
||||
// Tab(isImport, { isImport = true }) { Text("Add existing student") }
|
||||
// }
|
||||
//
|
||||
// if(isImport) {
|
||||
// if(availableStudents.isEmpty()) {
|
||||
// Box(Modifier.fillMaxSize()) {
|
||||
// Text("No students available to add to this course.", Modifier.align(Alignment.Center))
|
||||
// }
|
||||
// }
|
||||
// else {
|
||||
// var selected by remember { mutableStateOf(setOf<Int>()) }
|
||||
//
|
||||
// val onClick = { idx: Int ->
|
||||
// selected = if(idx in selected) selected - idx else selected + idx
|
||||
// }
|
||||
//
|
||||
// Text("Select students to add to ${course.name} ${edition.name}")
|
||||
// LazyColumn {
|
||||
// itemsIndexed(availableStudents) { idx, student ->
|
||||
// Surface(
|
||||
// Modifier.fillMaxWidth().clickable { onClick(idx) },
|
||||
// tonalElevation = if (selected.contains(idx)) 5.dp else 0.dp
|
||||
// ) {
|
||||
// Row {
|
||||
// Checkbox(selected.contains(idx), { onClick(idx) })
|
||||
// Text(student.name, Modifier.padding(5.dp))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// CancelSaveRow(selected.isNotEmpty(), onClose) {
|
||||
// onImport(selected.map { idx -> availableStudents[idx] })
|
||||
// onClose()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// else {
|
||||
// Box(Modifier.fillMaxSize()) {
|
||||
// var name by remember { mutableStateOf("") }
|
||||
// var contact by remember { mutableStateOf("") }
|
||||
// var note by remember { mutableStateOf("") }
|
||||
// var add by remember { mutableStateOf(true) }
|
||||
//
|
||||
// Column(Modifier.align(Alignment.Center)) {
|
||||
// OutlinedTextField(
|
||||
// name,
|
||||
// { name = it },
|
||||
// Modifier.fillMaxWidth(),
|
||||
// singleLine = true,
|
||||
// label = { Text("Student name") })
|
||||
// OutlinedTextField(
|
||||
// contact,
|
||||
// { contact = it },
|
||||
// Modifier.fillMaxWidth(),
|
||||
// singleLine = true,
|
||||
// label = { Text("Student contact") })
|
||||
// OutlinedTextField(
|
||||
// note,
|
||||
// { note = it },
|
||||
// Modifier.fillMaxWidth(),
|
||||
// singleLine = false,
|
||||
// minLines = 3,
|
||||
// label = { Text("Note") })
|
||||
// Row {
|
||||
// Checkbox(add, { add = it })
|
||||
// Text(
|
||||
// "Add student to ${course.name} ${edition.name}?",
|
||||
// Modifier.align(Alignment.CenterVertically)
|
||||
// )
|
||||
// }
|
||||
// CancelSaveRow(name.isNotBlank() && contact.isNotBlank(), onClose) {
|
||||
// onAdd(name, note, contact, add)
|
||||
// onClose()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.jaytux.grader.GroupGrading
|
||||
import com.jaytux.grader.app
|
||||
import com.jaytux.grader.data.v2.CategoricGrade
|
||||
import com.jaytux.grader.data.v2.Criterion
|
||||
import com.jaytux.grader.data.v2.GradeType
|
||||
import com.jaytux.grader.data.v2.Group
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.viewmodel.Grade
|
||||
import com.jaytux.grader.viewmodel.GroupsGradingVM
|
||||
import com.jaytux.grader.viewmodel.Navigator
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun GroupsGradingTitle(data: GroupGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Group Assignments / ${data.assignment.name} / Grading")
|
||||
|
||||
@Composable
|
||||
fun GroupsGradingView(data: GroupGrading, token: Navigator.NavToken) {
|
||||
val vm = viewModel<GroupsGradingVM>(key = data.assignment.id.toString()) {
|
||||
GroupsGradingVM(data.course, data.edition, data.assignment)
|
||||
}
|
||||
val groups by vm.groupList.entities
|
||||
val focus by vm.focus
|
||||
|
||||
val selectedGroup = remember(focus, groups) { groups.getOrNull(focus) }
|
||||
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text("Grading ${vm.base.name}", Modifier.weight(1f), style = MaterialTheme.typography.headlineMedium)
|
||||
Text("Group assignment in ${vm.course.name} - ${vm.edition.name}")
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
|
||||
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
|
||||
QuickAGroup(idx == focus, { vm.focusGroup(idx) }, it)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(Modifier.weight(0.75f).fillMaxHeight(), tonalElevation = 1.dp) {
|
||||
if (focus == -1 || selectedGroup == null) {
|
||||
Box(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||
Text("Select a group to start grading.", Modifier.align(Alignment.Center))
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.weight(0.75f).padding(15.dp)) {
|
||||
Row {
|
||||
IconButton({ vm.focusPrev() }, Modifier.align(Alignment.CenterVertically), enabled = focus > 0) {
|
||||
Icon(DoubleBack, "Previous group")
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(selectedGroup.group.name, Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton({ vm.focusNext() }, Modifier.align(Alignment.CenterVertically), enabled = focus < groups.size - 1) {
|
||||
Icon(DoubleForward, "Next group")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
val global by vm.globalGrade.entity
|
||||
val byCriteria by vm.gradeList.entities
|
||||
|
||||
Surface(Modifier.fillMaxSize(), color = Color.White, shape = MaterialTheme.shapes.medium) {
|
||||
LazyColumn {
|
||||
items(byCriteria ?: listOf()) { (crit, fdbk) ->
|
||||
var isOpen by remember(selectedGroup) { mutableStateOf(false) }
|
||||
Column(Modifier.padding(5.dp)) {
|
||||
GFWidget(crit, selectedGroup.group, fdbk, vm, global to byCriteria, isOpen) { isOpen = !isOpen }
|
||||
Spacer(Modifier.height(5.dp))
|
||||
}
|
||||
}
|
||||
global?.let { fdbk ->
|
||||
item {
|
||||
Box(Modifier.padding(5.dp)) {
|
||||
GFWidget(
|
||||
vm.global, selectedGroup.group, fdbk, vm, global to byCriteria, true,
|
||||
(byCriteria ?: listOf()).flatMap { (_, it) ->
|
||||
it.overrides.mapNotNull { o ->
|
||||
o.second?.let { _ -> o.first.id.value }
|
||||
}
|
||||
}.toSet()
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickAGroup(isFocus: Boolean, onFocus: () -> Unit, group: GroupsGradingVM.GroupData) {
|
||||
Surface(tonalElevation = if(isFocus) 15.dp else 0.dp, shape = MaterialTheme.shapes.small) {
|
||||
Column(Modifier.fillMaxWidth().clickable { onFocus() }.padding(10.dp)) {
|
||||
Text(group.group.name, fontWeight = FontWeight.Bold)
|
||||
Text("${group.students.size} student(s)", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun gradeState(crit: GroupsGradingVM.CritData, current: Grade?): Grade = transaction {
|
||||
if(current == null) Grade.default(crit.criterion.gradeType, crit.cat, crit.num)
|
||||
when(crit.criterion.gradeType) {
|
||||
GradeType.CATEGORIC ->
|
||||
if(current is Grade.Categoric && current.grade.id == crit.criterion.categoricGrade?.id) current
|
||||
else Grade.default(GradeType.CATEGORIC, crit.cat, crit.num)
|
||||
GradeType.NUMERIC ->
|
||||
if(current is Grade.Numeric && current.grade.id == crit.criterion.numericGrade?.id) current
|
||||
else Grade.default(GradeType.NUMERIC, crit.cat, crit.num)
|
||||
GradeType.PERCENTAGE ->
|
||||
current as? Grade.Percentage ?: Grade.default(GradeType.PERCENTAGE, crit.cat, crit.num)
|
||||
GradeType.NONE ->
|
||||
current as? Grade.FreeText ?: Grade.default(GradeType.NONE, crit.cat, crit.num)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GFWidget(crit: GroupsGradingVM.CritData, gr: Group, feedback: GroupsGradingVM.FeedbackData, vm: GroupsGradingVM, key: Any, isOpen: Boolean, markOverridden: Set<UUID> = setOf(), onToggle: () -> Unit) = Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
shadowElevation = 3.dp
|
||||
) {
|
||||
Column {
|
||||
Surface(tonalElevation = 5.dp) {
|
||||
Row(Modifier.fillMaxWidth().clickable { onToggle() }.padding(10.dp)) {
|
||||
Icon(if(isOpen) ChevronDown else ChevronRight, "Toggle criterion detail grading", Modifier.align(Alignment.CenterVertically))
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(crit.criterion.name, Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
feedback.groupLevel?.grade?.let {
|
||||
Row(Modifier.align(Alignment.Bottom)) {
|
||||
ProvideTextStyle(MaterialTheme.typography.bodySmall) {
|
||||
Text("(Grade: ")
|
||||
it.render()
|
||||
Text(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(isOpen) {
|
||||
Row(Modifier.padding(10.dp)) {
|
||||
var grade by remember(key, feedback) { mutableStateOf(gradeState(crit, feedback.groupLevel?.grade)) }
|
||||
var text by remember(key, feedback) { mutableStateOf(feedback.groupLevel?.feedback ?: "") }
|
||||
Column(Modifier.weight(0.5f).height(IntrinsicSize.Min)) {
|
||||
|
||||
GradePicker(grade, key = crit to gr) { grade = it }
|
||||
Spacer(Modifier.height(5.dp))
|
||||
OutlinedTextField(text, { text = it }, label = { Text("Feedback") }, singleLine = false, minLines = 5, modifier = Modifier.fillMaxWidth().weight(1f))
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Button({ vm.modGroupFeedback(crit.criterion, gr, grade, text) }) {
|
||||
Text("Save grade and feedback")
|
||||
}
|
||||
}
|
||||
|
||||
feedback.groupLevel?.let { groupLevel ->
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
Surface(Modifier.weight(0.5f).height(IntrinsicSize.Min), tonalElevation = 10.dp, shape = MaterialTheme.shapes.small) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text("Individual overrides", style = MaterialTheme.typography.bodyLarge)
|
||||
feedback.overrides.forEach { (student, it) ->
|
||||
var enable by remember(key, it) { mutableStateOf(false) }
|
||||
var maybeRemoving by remember(key, it) { mutableStateOf(false) }
|
||||
var sGrade by remember(key, it) { mutableStateOf(gradeState(crit, it?.grade ?: grade)) }
|
||||
var sText by remember(key, it) { mutableStateOf(it?.feedback ?: "") }
|
||||
|
||||
Column {
|
||||
Row {
|
||||
Checkbox(enable, { if(it) { enable = true } else { maybeRemoving = true } })
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(student.name, Modifier.align(Alignment.CenterVertically))
|
||||
if(student.id.value in markOverridden) {
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("(Overridden)", Modifier.align(Alignment.CenterVertically), style = MaterialTheme.typography.bodySmall, fontStyle = FontStyle.Italic, color = Color.Red)
|
||||
}
|
||||
}
|
||||
|
||||
if(enable) Row {
|
||||
Spacer(Modifier.width(15.dp))
|
||||
Surface(color = Color.White, shape = MaterialTheme.shapes.small) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Spacer(Modifier.height(5.dp))
|
||||
GradePicker(sGrade, key = crit to gr app student) { sGrade = it }
|
||||
Spacer(Modifier.height(5.dp))
|
||||
OutlinedTextField(sText, { sText = it }, label = { Text("Feedback") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Button({ vm.modOverrideFeedback(crit.criterion, gr, student, groupLevel, sGrade, sText) }) {
|
||||
Text("Save override")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(maybeRemoving) {
|
||||
ConfirmDeleteDialog("the individual grade for ${student.name}", { maybeRemoving = false }, {
|
||||
maybeRemoving = false
|
||||
enable = false
|
||||
vm.rmOverrideFeedback(crit.criterion, gr, student)
|
||||
}) {
|
||||
Column {
|
||||
Row {
|
||||
Text("Grade:")
|
||||
sGrade.render()
|
||||
}
|
||||
Row {
|
||||
Text("Feedback:")
|
||||
if(sText.isBlank()) Text("No feedback", fontStyle = FontStyle.Italic)
|
||||
else Text(sText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.draganddrop.dragAndDropSource
|
||||
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTransferAction
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTransferData
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTransferable
|
||||
import androidx.compose.ui.draganddrop.awtTransferable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jaytux.grader.data.v2.Group
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.viewmodel.EditionVM
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun GroupsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
|
||||
val groups by vm.groupList.entities
|
||||
val focus by vm.focusIndex
|
||||
var swappingRole by remember { mutableStateOf(-1) }
|
||||
|
||||
val group = remember(groups, focus) { if(focus != -1) groups[focus] else null }
|
||||
val grades by vm.groupGrades.entities
|
||||
|
||||
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
|
||||
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
|
||||
QuickGroup(idx, it, vm)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(Modifier.weight(0.75f).fillMaxHeight(), tonalElevation = 1.dp) {
|
||||
if(group == null) {
|
||||
Box(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||
Text("Select a group to view details.", Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
else {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(group.group.name, style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(Modifier.padding(5.dp)) {
|
||||
var showTargetBorder by remember { mutableStateOf(false) }
|
||||
val ddTarget = remember {
|
||||
DDTarget({ showTargetBorder = true }, { showTargetBorder = false }, { DDTarget.extractStudent(it) }) {
|
||||
vm.addStudentToGroup(it, group.group, null)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.weight(0.75f)) {
|
||||
Surface(
|
||||
Modifier.weight(0.5f).then(if(showTargetBorder) Modifier.border(BorderStroke(3.dp, Color.Black)) else Modifier)
|
||||
.dragAndDropTarget({ true }, target = ddTarget),
|
||||
shape = MaterialTheme.shapes.medium, color = Color.White, shadowElevation = 1.dp) {
|
||||
LazyColumn {
|
||||
item {
|
||||
Surface(tonalElevation = 15.dp) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp)) {
|
||||
Text("Members", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(group.members) { idx, (student, role) ->
|
||||
Row(Modifier.clickable { vm.focus(student) }.padding(10.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(student.name, fontWeight = FontWeight.Bold)
|
||||
if(student.contact.isEmpty())
|
||||
Text("No contact info.", fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
else Text(student.contact)
|
||||
}
|
||||
if(role != null) {
|
||||
Surface(Modifier.align(Alignment.CenterVertically), tonalElevation = 5.dp, shape = MaterialTheme.shapes.small) {
|
||||
Box(Modifier.clickable { swappingRole = -1 }.clickable { swappingRole = idx }) {
|
||||
Text(role, Modifier.padding(horizontal = 5.dp, vertical = 2.dp), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Text("No role", Modifier.align(Alignment.CenterVertically).clickable { swappingRole = idx }, fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
}
|
||||
IconButton({ vm.rmStudentFromGroup(student, group.group) }, Modifier.align(Alignment.CenterVertically)) {
|
||||
Icon(PersonMinus, "Remove ${student.name} from group")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(group.members.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||
Text("No members yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
Column(Modifier.weight(0.5f)) {
|
||||
Text("Grade Summary: ", style = MaterialTheme.typography.headlineSmall)
|
||||
Surface(shape = MaterialTheme.shapes.medium, color = Color.White) {
|
||||
LazyColumn {
|
||||
item {
|
||||
Surface(tonalElevation = 15.dp) {
|
||||
Row(Modifier.padding(10.dp)) {
|
||||
Text("Assignment", Modifier.weight(0.66f))
|
||||
Text("Grade", Modifier.weight(0.33f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(grades ?: listOf()) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Row {
|
||||
Text(it.first.name, Modifier.weight(0.66f))
|
||||
it.second?.render(Modifier.weight(0.33f))
|
||||
?: Text("---", Modifier.weight(0.33f), color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if((grades ?: listOf()).isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||
Text("No grades yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
val available by vm.groupAvailableStudents.entities
|
||||
Surface(Modifier.weight(0.25f), shape = MaterialTheme.shapes.medium, color = Color.White, shadowElevation = 1.dp) {
|
||||
LazyColumn {
|
||||
item {
|
||||
Surface(tonalElevation = 15.dp) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp)) {
|
||||
Text("Available Students", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(10.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(available ?: listOf()) { student ->
|
||||
AvailableStudent(student, group.group, vm)
|
||||
}
|
||||
|
||||
if((available ?: listOf()).isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||
Text("No available students.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(swappingRole != -1) {
|
||||
if(group != null) {
|
||||
val roles by vm.usedRoles.entities
|
||||
RolePicker(roles, group.members[swappingRole].second, { swappingRole = -1 }) { upd ->
|
||||
vm.setStudentRole(group.members[swappingRole].first, group.group, upd)
|
||||
}
|
||||
}
|
||||
else {
|
||||
swappingRole = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DDTarget<T>(val onStart: () -> Unit, val onEnd: () -> Unit, val validator: (Transferable) -> T?, val handle: (T) -> Unit) : DragAndDropTarget {
|
||||
override fun onStarted(event: DragAndDropEvent) {
|
||||
onStart()
|
||||
super.onStarted(event)
|
||||
}
|
||||
|
||||
override fun onEnded(event: DragAndDropEvent) {
|
||||
onEnd()
|
||||
super.onEnded(event)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||
println("Action at the target: ${event.action}")
|
||||
|
||||
return validator(event.awtTransferable)?.let {
|
||||
handle(it)
|
||||
true
|
||||
} ?: false
|
||||
}
|
||||
|
||||
companion object {
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun mkStudentTransferable(student: Student) = DragAndDropTransferable(StringSelection("com.jaytux.grader:student:${student.id.value}"))
|
||||
|
||||
fun extractStudent(transf: Transferable): Student? {
|
||||
if(transf.isDataFlavorSupported(DataFlavor.stringFlavor)) {
|
||||
val raw = transf.getTransferData(DataFlavor.stringFlavor) as String
|
||||
val prefix = "com.jaytux.grader:student:"
|
||||
if(raw.startsWith(prefix)) {
|
||||
val id = UUID.fromString(raw.removePrefix(prefix))
|
||||
return transaction { Student.findById(id) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickGroup(idx: Int, group: EditionVM.GroupData, vm: EditionVM) {
|
||||
val focus by vm.focusIndex
|
||||
Surface(tonalElevation = if(focus == idx) 15.dp else 0.dp, shape = MaterialTheme.shapes.small) {
|
||||
Column(Modifier.fillMaxWidth().clickable { vm.focus(idx) }.padding(10.dp)) {
|
||||
Text(group.group.name, fontWeight = FontWeight.Bold)
|
||||
Text("${group.members.size} member(s)", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun AvailableStudent(student: Student, group: Group, vm: EditionVM) {
|
||||
Row(Modifier.padding(10.dp).dragAndDropSource(
|
||||
drawDragDecoration = {},
|
||||
) {
|
||||
DragAndDropTransferData(
|
||||
transferable = DDTarget.mkStudentTransferable(student),
|
||||
supportedActions = listOf(DragAndDropTransferAction.Move),
|
||||
dragDecorationOffset = it,
|
||||
onTransferCompleted = { act -> println("Source action: $act") }
|
||||
)
|
||||
}) {
|
||||
Text(student.name, Modifier.align(Alignment.CenterVertically).weight(1f), fontWeight = FontWeight.Bold)
|
||||
IconButton({ vm.addStudentToGroup(student, group, null) }) {
|
||||
Icon(CirclePlus, "Add ${student.name} to group")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.jaytux.grader.EditionDetail
|
||||
import com.jaytux.grader.data.v2.Edition
|
||||
import com.jaytux.grader.viewmodel.HomeVM
|
||||
import com.jaytux.grader.viewmodel.Navigator
|
||||
|
||||
@Composable
|
||||
fun HomeTitle() = Text("Grader")
|
||||
|
||||
@Composable
|
||||
fun HomeView(token: Navigator.NavToken) {
|
||||
val vm = viewModel<HomeVM>()
|
||||
val courses by vm.courses.entities
|
||||
var addingCourse by remember { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(Modifier.padding(15.dp)) {
|
||||
item {
|
||||
Row {
|
||||
Text("Courses Overview", Modifier.weight(0.8f), style = MaterialTheme.typography.headlineMedium)
|
||||
Button({ addingCourse = true }) {
|
||||
Icon(CirclePlus, "Add course")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Add course")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(courses) {
|
||||
CourseCard(it, vm) { e -> token.navTo(EditionDetail(e, it.course)) }
|
||||
}
|
||||
}
|
||||
|
||||
if(addingCourse) {
|
||||
AddStringDialog("Course Name", courses.map { it.course.name }, { addingCourse = false }, "") {
|
||||
vm.mkCourse(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CourseCard(course: HomeVM.CourseData, vm: HomeVM, onOpenEdition: (Edition) -> Unit) {
|
||||
var addingEdition by remember { mutableStateOf(false) }
|
||||
var deleting by remember { mutableStateOf(false) }
|
||||
Surface(shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 5.dp, modifier = Modifier.fillMaxWidth().padding(10.dp)) {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
Row {
|
||||
Text(course.course.name, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f))
|
||||
IconButton({ deleting = true }) { Icon(Delete, "Delete course") }
|
||||
}
|
||||
|
||||
Row {
|
||||
Text("Editions", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f))
|
||||
Button({ addingEdition = true }) {
|
||||
Icon(CirclePlus, "Add edition")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Add edition")
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
course.editions.forEach { EditionCard(course.course.name, it, vm, onOpenEdition) }
|
||||
}
|
||||
|
||||
if(course.archived.isNotEmpty()) {
|
||||
Text("Archived editions", style = MaterialTheme.typography.headlineSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
course.archived.forEach { EditionCard(course.course.name, it, vm, onOpenEdition) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(addingEdition) {
|
||||
AddStringDialog("Edition Name (in ${course.course.name})", course.editions.map { it.edition.name }, { addingEdition = false }, "") {
|
||||
vm.mkEdition(course.course, it)
|
||||
}
|
||||
}
|
||||
|
||||
if(deleting) {
|
||||
ConfirmDeleteDialog("a course", { deleting = false }, { vm.rmCourse(course.course) }) {
|
||||
Text(course.course.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditionCard(courseName: String, edition: HomeVM.EditionData, vm: HomeVM, onOpen: (Edition) -> Unit) {
|
||||
val type = if(edition.edition.archived) "Archived" else "Active"
|
||||
var deleting by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp, shadowElevation = 5.dp, modifier = Modifier.padding(10.dp).clickable { onOpen(edition.edition) }) {
|
||||
Column(Modifier.padding(10.dp).width(IntrinsicSize.Min)) {
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
Text(edition.edition.name, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"$type\n${edition.students.size} student(s) • ${edition.groups.size} group(s) • ${edition.assignments.size} assignment(s)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row {
|
||||
if(edition.edition.archived) {
|
||||
Button({ vm.unarchiveEdition(edition.edition) }, Modifier.weight(0.5f)) {
|
||||
Icon(Unarchive, "Unarchive edition")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Unarchive edition")
|
||||
}
|
||||
}
|
||||
else {
|
||||
Button({ vm.archiveEdition(edition.edition) }, Modifier.weight(0.5f)) {
|
||||
Icon(Archive, "Archive edition")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Archive edition")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Button({ deleting = true }, Modifier.weight(0.5f)) {
|
||||
Icon(Delete, "Archive edition")
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("Delete edition")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(deleting) {
|
||||
ConfirmDeleteDialog("an edition", { deleting = false }, { vm.rmEdition(edition.edition) }) {
|
||||
Column {
|
||||
Text(edition.edition.name, Modifier.align(Alignment.CenterHorizontally))
|
||||
Text("Edition in course $courseName", Modifier.align(Alignment.CenterHorizontally))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.jaytux.grader.GroupGrading
|
||||
import com.jaytux.grader.PeerEvalGrading
|
||||
import com.jaytux.grader.viewmodel.GroupsGradingVM
|
||||
import com.jaytux.grader.viewmodel.Navigator
|
||||
import com.jaytux.grader.viewmodel.PeerEvalsGradingVM
|
||||
|
||||
@Composable
|
||||
fun PeerEvalsGradingTitle(data: PeerEvalGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Peer Evaluations / ${data.assignment.name} / Grading")
|
||||
|
||||
@Composable
|
||||
fun PeerEvalsGradingView(data: PeerEvalGrading, token: Navigator.NavToken) {
|
||||
val vm = viewModel<PeerEvalsGradingVM>(key = data.assignment.id.toString()) {
|
||||
PeerEvalsGradingVM(data.course, data.edition, data.assignment)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -18,6 +12,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
@@ -29,13 +24,14 @@ import com.jaytux.grader.loadClipboard
|
||||
import com.jaytux.grader.toClipboard
|
||||
import com.mohamedrejeb.richeditor.model.RichTextState
|
||||
import com.mohamedrejeb.richeditor.ui.material.OutlinedRichTextEditor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RichTextStyleRow(
|
||||
modifier: Modifier = Modifier,
|
||||
state: RichTextState,
|
||||
) {
|
||||
val clip = LocalClipboardManager.current
|
||||
val clip = LocalClipboardManager.current // I know this is deprecated, but I won't figure out the Clipboard API now
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Row(modifier.fillMaxWidth()) {
|
||||
@@ -53,7 +49,7 @@ fun RichTextStyleRow(
|
||||
)
|
||||
},
|
||||
isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold,
|
||||
icon = Icons.Outlined.FormatBold
|
||||
icon = FormatBold
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,7 +63,7 @@ fun RichTextStyleRow(
|
||||
)
|
||||
},
|
||||
isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic,
|
||||
icon = Icons.Outlined.FormatItalic
|
||||
icon = FormatItalic
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,7 +77,7 @@ fun RichTextStyleRow(
|
||||
)
|
||||
},
|
||||
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true,
|
||||
icon = Icons.Outlined.FormatUnderlined
|
||||
icon = FormatUnderline
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,7 +91,7 @@ fun RichTextStyleRow(
|
||||
)
|
||||
},
|
||||
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true,
|
||||
icon = Icons.Outlined.FormatStrikethrough
|
||||
icon = FormatStrikethrough
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,7 +105,7 @@ fun RichTextStyleRow(
|
||||
)
|
||||
},
|
||||
isSelected = state.currentSpanStyle.fontSize == 28.sp,
|
||||
icon = Icons.Outlined.FormatSize
|
||||
icon = FormatSize
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,7 +119,7 @@ fun RichTextStyleRow(
|
||||
)
|
||||
},
|
||||
isSelected = state.currentSpanStyle.color == Color.Red,
|
||||
icon = Icons.Filled.Circle,
|
||||
icon = CircleFilled,
|
||||
tint = Color.Red
|
||||
)
|
||||
}
|
||||
@@ -138,7 +134,7 @@ fun RichTextStyleRow(
|
||||
)
|
||||
},
|
||||
isSelected = state.currentSpanStyle.background == Color.Yellow,
|
||||
icon = Icons.Outlined.Circle,
|
||||
icon = CircleOutline,
|
||||
tint = Color.Yellow
|
||||
)
|
||||
}
|
||||
@@ -158,7 +154,7 @@ fun RichTextStyleRow(
|
||||
state.toggleUnorderedList()
|
||||
},
|
||||
isSelected = state.isUnorderedList,
|
||||
icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
|
||||
icon = FormatListBullet,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,7 +164,7 @@ fun RichTextStyleRow(
|
||||
state.toggleOrderedList()
|
||||
},
|
||||
isSelected = state.isOrderedList,
|
||||
icon = Icons.Outlined.FormatListNumbered,
|
||||
icon = FormatListNumber,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,16 +183,16 @@ fun RichTextStyleRow(
|
||||
state.toggleCodeSpan()
|
||||
},
|
||||
isSelected = state.isCodeSpan,
|
||||
icon = Icons.Outlined.Code,
|
||||
icon = FormatCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton({ state.toClipboard(clip) }) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy markdown")
|
||||
IconButton({ scope.launch { state.toClipboard(clip) } }) {
|
||||
Icon(ContentCopy, contentDescription = "Copy markdown")
|
||||
}
|
||||
IconButton({ state.loadClipboard(clip, scope) }) {
|
||||
Icon(Icons.Default.ContentPaste, contentDescription = "Paste markdown")
|
||||
IconButton({ scope.launch { state.loadClipboard(clip, scope) } }) {
|
||||
Icon(ContentPaste, contentDescription = "Paste markdown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.jaytux.grader.GroupGrading
|
||||
import com.jaytux.grader.SoloGrading
|
||||
import com.jaytux.grader.viewmodel.GroupsGradingVM
|
||||
import com.jaytux.grader.viewmodel.Navigator
|
||||
import com.jaytux.grader.viewmodel.SolosGradingVM
|
||||
|
||||
@Composable
|
||||
fun SolosGradingTitle(data: SoloGrading) = Text("Courses / ${data.course.name} / ${data.edition.name} / Individual Assignments / ${data.assignment.name} / Grading")
|
||||
|
||||
@Composable
|
||||
fun SolosGradingView(data: SoloGrading, token: Navigator.NavToken) {
|
||||
val vm = viewModel<SolosGradingVM>(key = data.assignment.id.toString()) {
|
||||
SolosGradingVM(data.course, data.edition, data.assignment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jaytux.grader.data.v2.Edition
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.viewmodel.EditionVM
|
||||
|
||||
@Composable
|
||||
fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
|
||||
val students by vm.studentList.entities
|
||||
val focus by vm.focusIndex
|
||||
|
||||
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
|
||||
ListOrEmpty(students, { Text("No students yet.") }) { idx, it ->
|
||||
QuickStudent(idx, it, vm)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(Modifier.weight(0.75f).fillMaxHeight(), tonalElevation = 1.dp) {
|
||||
if(focus == -1) {
|
||||
Box(Modifier.weight(0.75f).fillMaxHeight()) {
|
||||
Text("Select a student to view details.", Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
else {
|
||||
val groups by vm.studentGroups.entities
|
||||
val grades by vm.studentGrades.entities
|
||||
|
||||
Column(Modifier.weight(0.75f).padding(15.dp)) {
|
||||
Surface(Modifier.padding(10.dp).fillMaxWidth(), tonalElevation = 10.dp, shadowElevation = 2.dp, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(students[focus].name, style = MaterialTheme.typography.headlineSmall)
|
||||
Row {
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
|
||||
Text("Contact: ", Modifier.align(Alignment.CenterVertically).padding(start = 15.dp))
|
||||
if(!editing) {
|
||||
if (students[focus].contact.isBlank()) Text(
|
||||
"No contact info.",
|
||||
Modifier.padding(start = 5.dp),
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = LocalTextStyle.current.color.copy(alpha = 0.5f)
|
||||
)
|
||||
else Text(students[focus].contact, Modifier.padding(start = 5.dp))
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Icon(Edit, "Edit contact info", Modifier.clickable { editing = true })
|
||||
}
|
||||
else {
|
||||
var mod by remember(focus, students[focus].contact, students[focus].id.value) { mutableStateOf(students[focus].contact) }
|
||||
OutlinedTextField(mod, { mod = it })
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Icon(Check, "Confirm edit", Modifier.align(Alignment.CenterVertically).clickable {
|
||||
vm.modStudent(students[focus], null, mod, null)
|
||||
editing = false
|
||||
})
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Icon(Close, "Cancel edit", Modifier.align(Alignment.CenterVertically).clickable { editing = false })
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Text("Groups:", style = MaterialTheme.typography.headlineSmall)
|
||||
groups?.let { gList ->
|
||||
if(gList.isEmpty()) null
|
||||
else {
|
||||
FlowRow(Modifier.padding(start = 10.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
gList.forEach { group ->
|
||||
Surface(tonalElevation = 15.dp, shadowElevation = 1.dp, shape = MaterialTheme.shapes.small) {
|
||||
Box(Modifier.padding(5.dp).clickable { vm.focus(group.first) }) {
|
||||
Text("${group.first.name} (${group.second ?: "no role"})", Modifier.padding(5.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: Text("Not a member of any group.", Modifier.padding(start = 15.dp), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Column(Modifier.weight(0.33f)) {
|
||||
var mod by remember(focus, students[focus].note, students[focus].id.value) { mutableStateOf(students[focus].note) }
|
||||
|
||||
Text("Internal Note:")
|
||||
OutlinedTextField(
|
||||
mod,
|
||||
{ mod = it },
|
||||
singleLine = false,
|
||||
minLines = 5,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if(mod != students[focus].note) {
|
||||
Row {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button({ vm.modStudent(students[focus], null, null, mod) }) {
|
||||
Text("Update note")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(0.66f)) {
|
||||
Text("Grade Summary: ", style = MaterialTheme.typography.headlineSmall)
|
||||
Surface(shape = MaterialTheme.shapes.medium, color = Color.White) {
|
||||
LazyColumn {
|
||||
item {
|
||||
Surface(tonalElevation = 15.dp) {
|
||||
Row(Modifier.padding(10.dp)) {
|
||||
Text("Assignment", Modifier.weight(0.66f))
|
||||
Text("Grade", Modifier.weight(0.33f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(grades ?: listOf()) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Row {
|
||||
Text(it.assignment.name, Modifier.weight(0.66f))
|
||||
it.grade?.render(Modifier.weight(0.33f))
|
||||
?: Text("---", Modifier.weight(0.33f), color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
}
|
||||
|
||||
it.asMember?.let { g ->
|
||||
Row(Modifier.padding(start = 10.dp)) {
|
||||
Text("As member of ${g.name}", fontStyle = FontStyle.Italic)
|
||||
if (it.overridden) Text(" (overridden)", fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if((grades ?: listOf()).isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||
Text("No grades yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickStudent(idx: Int, student: Student, vm: EditionVM) {
|
||||
val focus by vm.focusIndex
|
||||
Surface(tonalElevation = if(focus == idx) 15.dp else 0.dp, shape = MaterialTheme.shapes.small) {
|
||||
Column(Modifier.fillMaxWidth().clickable { vm.focus(idx) }.padding(10.dp)) {
|
||||
Text(student.name, fontWeight = FontWeight.Bold)
|
||||
if(student.contact.isBlank())
|
||||
Text("No contact info.", fontStyle = FontStyle.Italic, color = LocalTextStyle.current.color.copy(alpha = 0.5f))
|
||||
else Text(student.contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,220 +1,200 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogWindow
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.rememberDialogState
|
||||
import com.jaytux.grader.maxN
|
||||
import com.jaytux.grader.viewmodel.GroupState
|
||||
import com.jaytux.grader.viewmodel.StudentState
|
||||
|
||||
@Composable
|
||||
fun StudentView(state: StudentState, nav: Navigators) {
|
||||
val groups by state.groups.entities
|
||||
val courses by state.courseEditions.entities
|
||||
val groupGrades by state.groupGrades.entities
|
||||
val soloGrades by state.soloGrades.entities
|
||||
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Row {
|
||||
Column(Modifier.weight(0.45f)) {
|
||||
Column(Modifier.padding(10.dp).weight(0.35f)) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
InteractToEdit(state.student.name, { state.update { this.name = it } }, "Name")
|
||||
InteractToEdit(state.student.contact, { state.update { this.contact = it } }, "Contact")
|
||||
InteractToEdit(state.student.note, { state.update { this.note = it } }, "Note", singleLine = false)
|
||||
}
|
||||
Column(Modifier.weight(0.20f)) {
|
||||
Text("Courses", style = MaterialTheme.typography.headlineSmall)
|
||||
ListOrEmpty(courses, { Text("Not a member of any course") }) { _, it ->
|
||||
val (ed, course) = it
|
||||
Text("${course.name} (${ed.name})", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(0.45f)) {
|
||||
Text("Groups", style = MaterialTheme.typography.headlineSmall)
|
||||
ListOrEmpty(groups, { Text("Not a member of any group") }) { _, it ->
|
||||
val (group, c) = it
|
||||
val (course, ed) = c
|
||||
Row(Modifier.clickable { nav.group(group) }) {
|
||||
Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(
|
||||
"(in course $course ($ed))",
|
||||
Modifier.align(Alignment.Bottom),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(0.55f)) {
|
||||
Text("Courses", style = MaterialTheme.typography.headlineSmall)
|
||||
LazyColumn {
|
||||
item {
|
||||
Text("As group member", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
items(groupGrades) {
|
||||
groupGradeWidget(it)
|
||||
}
|
||||
|
||||
item {
|
||||
Text("Solo assignments", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
items(soloGrades) {
|
||||
soloGradeWidget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun groupGradeWidget(gg: StudentState.LocalGroupGrade) {
|
||||
val (group, assignment, gGrade, iGrade) = gg
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Row(Modifier.padding(5.dp)) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Surface(
|
||||
Modifier.clickable { expanded = !expanded }.fillMaxWidth(),
|
||||
tonalElevation = 5.dp,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Column(Modifier.padding(5.dp)) {
|
||||
Text("${assignment.maxN(25)} (${iGrade ?: gGrade ?: "no grade yet"})")
|
||||
|
||||
if (expanded) {
|
||||
Row {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
ItalicAndNormal("Assignment: ", assignment)
|
||||
ItalicAndNormal("Group name: ", group)
|
||||
ItalicAndNormal("Group grade: ", gGrade ?: "no grade yet")
|
||||
ItalicAndNormal("Individual grade: ", iGrade ?: "no individual grade")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun soloGradeWidget(sg: StudentState.LocalSoloGrade) {
|
||||
val (assignment, grade) = sg
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Row(Modifier.padding(5.dp)) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Surface(
|
||||
Modifier.clickable { expanded = !expanded }.fillMaxWidth(),
|
||||
tonalElevation = 5.dp,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Column(Modifier.padding(5.dp)) {
|
||||
Text("${assignment.maxN(25)} (${grade ?: "no grade yet"})")
|
||||
|
||||
if (expanded) {
|
||||
Row {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
ItalicAndNormal("Assignment: ", assignment)
|
||||
ItalicAndNormal("Individual grade: ", grade ?: "no grade yet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupView(state: GroupState, nav: Navigators) {
|
||||
val members by state.members.entities
|
||||
val available by state.availableStudents.entities
|
||||
val allRoles by state.roles.entities
|
||||
|
||||
var pickRole: Pair<String?, (String?) -> Unit>? by remember { mutableStateOf(null) }
|
||||
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Row {
|
||||
Column(Modifier.weight(0.5f)) {
|
||||
Text("Students", style = MaterialTheme.typography.headlineSmall)
|
||||
ListOrEmpty(members, { Text("No students in this group") }) { _, it ->
|
||||
val (student, role) = it
|
||||
Row(Modifier.clickable { nav.student(student) }) {
|
||||
Text(
|
||||
"${student.name} (${role ?: "no role"})",
|
||||
Modifier.weight(0.75f).align(Alignment.CenterVertically),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
IconButton({ pickRole = role to { r -> state.updateRole(student, r) } }, Modifier.weight(0.12f)) {
|
||||
Icon(Icons.Default.Edit, "Change role")
|
||||
}
|
||||
IconButton({ state.removeStudent(student) }, Modifier.weight(0.12f)) {
|
||||
Icon(Icons.Default.Delete, "Remove student")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(0.5f)) {
|
||||
Text("Available students", style = MaterialTheme.typography.headlineSmall)
|
||||
ListOrEmpty(available, { Text("No students available") }) { _, it ->
|
||||
Row(Modifier.padding(5.dp).clickable { nav.student(it) }) {
|
||||
IconButton({ state.addStudent(it) }) {
|
||||
Icon(ChevronLeft, "Add student")
|
||||
}
|
||||
Text(it.name, Modifier.weight(0.75f).align(Alignment.CenterVertically), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pickRole?.let {
|
||||
val (curr, onPick) = it
|
||||
RolePicker(allRoles, curr, { pickRole = null }, { role -> onPick(role); pickRole = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RolePicker(used: List<String>, curr: String?, onClose: () -> Unit, onSave: (String?) -> Unit) = DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(400.dp, 500.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
var role by remember { mutableStateOf(curr ?: "") }
|
||||
Column {
|
||||
Text("Used roles:")
|
||||
LazyColumn(Modifier.weight(1.0f).padding(5.dp)) {
|
||||
items(used) {
|
||||
Surface(Modifier.fillMaxWidth().clickable { role = it }, tonalElevation = 5.dp) {
|
||||
Text(it, Modifier.padding(5.dp))
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
}
|
||||
}
|
||||
OutlinedTextField(role, { role = it }, Modifier.fillMaxWidth())
|
||||
CancelSaveRow(true, onClose) {
|
||||
onSave(role.ifBlank { null })
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//@Composable
|
||||
//fun StudentView(state: StudentState, nav: Navigators) {
|
||||
// val groups by state.groups.entities
|
||||
// val courses by state.courseEditions.entities
|
||||
// val groupGrades by state.groupGrades.entities
|
||||
// val soloGrades by state.soloGrades.entities
|
||||
//
|
||||
// Column(Modifier.padding(10.dp)) {
|
||||
// Row {
|
||||
// Column(Modifier.weight(0.45f)) {
|
||||
// Column(Modifier.padding(10.dp).weight(0.35f)) {
|
||||
// Spacer(Modifier.height(10.dp))
|
||||
// InteractToEdit(state.student.name, { state.update { this.name = it } }, "Name")
|
||||
// InteractToEdit(state.student.contact, { state.update { this.contact = it } }, "Contact")
|
||||
// InteractToEdit(state.student.note, { state.update { this.note = it } }, "Note", singleLine = false)
|
||||
// }
|
||||
// Column(Modifier.weight(0.20f)) {
|
||||
// Text("Courses", style = MaterialTheme.typography.headlineSmall)
|
||||
// ListOrEmpty(courses, { Text("Not a member of any course") }) { _, it ->
|
||||
// val (ed, course) = it
|
||||
// Text("${course.name} (${ed.name})", style = MaterialTheme.typography.bodyMedium)
|
||||
// }
|
||||
// }
|
||||
// Column(Modifier.weight(0.45f)) {
|
||||
// Text("Groups", style = MaterialTheme.typography.headlineSmall)
|
||||
// ListOrEmpty(groups, { Text("Not a member of any group") }) { _, it ->
|
||||
// val (group, c) = it
|
||||
// val (course, ed) = c
|
||||
// Row(Modifier.clickable { nav.group(group) }) {
|
||||
// Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
||||
// Spacer(Modifier.width(5.dp))
|
||||
// Text(
|
||||
// "(in course $course ($ed))",
|
||||
// Modifier.align(Alignment.Bottom),
|
||||
// style = MaterialTheme.typography.bodySmall
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Column(Modifier.weight(0.55f)) {
|
||||
// Text("Courses", style = MaterialTheme.typography.headlineSmall)
|
||||
// LazyColumn {
|
||||
// item {
|
||||
// Text("As group member", fontWeight = FontWeight.Bold)
|
||||
// }
|
||||
// items(groupGrades) {
|
||||
// groupGradeWidget(it)
|
||||
// }
|
||||
//
|
||||
// item {
|
||||
// Text("Solo assignments", fontWeight = FontWeight.Bold)
|
||||
// }
|
||||
// items(soloGrades) {
|
||||
// soloGradeWidget(it)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun groupGradeWidget(gg: StudentState.LocalGroupGrade) {
|
||||
// val (group, assignment, gGrade, iGrade) = gg
|
||||
// var expanded by remember { mutableStateOf(false) }
|
||||
// Row(Modifier.padding(5.dp)) {
|
||||
// Spacer(Modifier.width(10.dp))
|
||||
// Surface(
|
||||
// Modifier.clickable { expanded = !expanded }.fillMaxWidth(),
|
||||
// tonalElevation = 5.dp,
|
||||
// shape = MaterialTheme.shapes.medium
|
||||
// ) {
|
||||
// Column(Modifier.padding(5.dp)) {
|
||||
// Text("${assignment.maxN(25)} (${iGrade ?: gGrade ?: "no grade yet"})")
|
||||
//
|
||||
// if (expanded) {
|
||||
// Row {
|
||||
// Spacer(Modifier.width(10.dp))
|
||||
// Column {
|
||||
// ItalicAndNormal("Assignment: ", assignment)
|
||||
// ItalicAndNormal("Group name: ", group)
|
||||
// ItalicAndNormal("Group grade: ", gGrade ?: "no grade yet")
|
||||
// ItalicAndNormal("Individual grade: ", iGrade ?: "no individual grade")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun soloGradeWidget(sg: StudentState.LocalSoloGrade) {
|
||||
// val (assignment, grade) = sg
|
||||
// var expanded by remember { mutableStateOf(false) }
|
||||
// Row(Modifier.padding(5.dp)) {
|
||||
// Spacer(Modifier.width(10.dp))
|
||||
// Surface(
|
||||
// Modifier.clickable { expanded = !expanded }.fillMaxWidth(),
|
||||
// tonalElevation = 5.dp,
|
||||
// shape = MaterialTheme.shapes.medium
|
||||
// ) {
|
||||
// Column(Modifier.padding(5.dp)) {
|
||||
// Text("${assignment.maxN(25)} (${grade ?: "no grade yet"})")
|
||||
//
|
||||
// if (expanded) {
|
||||
// Row {
|
||||
// Spacer(Modifier.width(10.dp))
|
||||
// Column {
|
||||
// ItalicAndNormal("Assignment: ", assignment)
|
||||
// ItalicAndNormal("Individual grade: ", grade ?: "no grade yet")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun GroupView(state: GroupState, nav: Navigators) {
|
||||
// val members by state.members.entities
|
||||
// val available by state.availableStudents.entities
|
||||
// val allRoles by state.roles.entities
|
||||
//
|
||||
// var pickRole: Pair<String?, (String?) -> Unit>? by remember { mutableStateOf(null) }
|
||||
//
|
||||
// Column(Modifier.padding(10.dp)) {
|
||||
// Row {
|
||||
// Column(Modifier.weight(0.5f)) {
|
||||
// Text("Students", style = MaterialTheme.typography.headlineSmall)
|
||||
// ListOrEmpty(members, { Text("No students in this group") }) { _, it ->
|
||||
// val (student, role) = it
|
||||
// Row(Modifier.clickable { nav.student(student) }) {
|
||||
// Text(
|
||||
// "${student.name} (${role ?: "no role"})",
|
||||
// Modifier.weight(0.75f).align(Alignment.CenterVertically),
|
||||
// style = MaterialTheme.typography.bodyMedium
|
||||
// )
|
||||
// IconButton({ pickRole = role to { r -> state.updateRole(student, r) } }, Modifier.weight(0.12f)) {
|
||||
// Icon(Icons.Default.Edit, "Change role")
|
||||
// }
|
||||
// IconButton({ state.removeStudent(student) }, Modifier.weight(0.12f)) {
|
||||
// Icon(Icons.Default.Delete, "Remove student")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Column(Modifier.weight(0.5f)) {
|
||||
// Text("Available students", style = MaterialTheme.typography.headlineSmall)
|
||||
// ListOrEmpty(available, { Text("No students available") }) { _, it ->
|
||||
// Row(Modifier.padding(5.dp).clickable { nav.student(it) }) {
|
||||
// IconButton({ state.addStudent(it) }) {
|
||||
// Icon(ChevronLeft, "Add student")
|
||||
// }
|
||||
// Text(it.name, Modifier.weight(0.75f).align(Alignment.CenterVertically), style = MaterialTheme.typography.bodyMedium)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// pickRole?.let {
|
||||
// val (curr, onPick) = it
|
||||
// RolePicker(allRoles, curr, { pickRole = null }, { role -> onPick(role); pickRole = null })
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun RolePicker(used: List<String>, curr: String?, onClose: () -> Unit, onSave: (String?) -> Unit) = DialogWindow(
|
||||
// onCloseRequest = onClose,
|
||||
// state = rememberDialogState(size = DpSize(400.dp, 500.dp), position = WindowPosition(Alignment.Center))
|
||||
//) {
|
||||
// Surface(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
// Box(Modifier.fillMaxSize()) {
|
||||
// var role by remember { mutableStateOf(curr ?: "") }
|
||||
// Column {
|
||||
// Text("Used roles:")
|
||||
// LazyColumn(Modifier.weight(1.0f).padding(5.dp)) {
|
||||
// items(used) {
|
||||
// Surface(Modifier.fillMaxWidth().clickable { role = it }, tonalElevation = 5.dp) {
|
||||
// Text(it, Modifier.padding(5.dp))
|
||||
// }
|
||||
// Spacer(Modifier.height(5.dp))
|
||||
// }
|
||||
// }
|
||||
// OutlinedTextField(role, { role = it }, Modifier.fillMaxWidth())
|
||||
// CancelSaveRow(true, onClose) {
|
||||
// onSave(role.ifBlank { null })
|
||||
// onClose()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -1,45 +1,34 @@
|
||||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
import com.jaytux.grader.data.Course
|
||||
import com.jaytux.grader.data.Edition
|
||||
import com.jaytux.grader.viewmodel.PeerEvaluationState
|
||||
import com.mohamedrejeb.richeditor.model.RichTextState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import com.jaytux.grader.maxN
|
||||
import com.jaytux.grader.viewmodel.Grade
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.*
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
@Composable
|
||||
fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "Cancel", saveText: String = "Save", onSave: () -> Unit) {
|
||||
@@ -50,37 +39,18 @@ fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> TabLayout(
|
||||
options: List<T>,
|
||||
currentIndex: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
optionContent: @Composable (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) = Column(modifier) {
|
||||
TabRow(currentIndex) {
|
||||
options.forEachIndexed { idx, it ->
|
||||
Tab(
|
||||
selected = idx == currentIndex,
|
||||
onClick = { onSelect(idx) },
|
||||
text = { optionContent(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, current: String = "", onSave: (String) -> Unit) = DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
var name by remember(current) { mutableStateOf(current) }
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text(label) }, isError = name in taken)
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth().focusRequester(focus), label = { Text(label) }, isError = name in taken)
|
||||
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
||||
onSave(name)
|
||||
onClose()
|
||||
@@ -88,6 +58,8 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -118,299 +90,25 @@ fun ConfirmDeleteDialog(
|
||||
fun <T> ListOrEmpty(
|
||||
data: List<T>,
|
||||
onEmpty: @Composable ColumnScope.() -> Unit,
|
||||
addOptions: @Composable ColumnScope.() -> Unit,
|
||||
addAfterLazy: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
|
||||
) {
|
||||
if(data.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
onEmpty()
|
||||
addOptions()
|
||||
Box(modifier) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
onEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Column {
|
||||
Column(modifier) {
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
itemsIndexed(data) { idx, it ->
|
||||
item(idx, it)
|
||||
}
|
||||
|
||||
if(!addAfterLazy) item { addOptions() }
|
||||
}
|
||||
if(addAfterLazy) addOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> ListOrEmpty(
|
||||
data: List<T>,
|
||||
emptyText: @Composable ColumnScope.() -> Unit,
|
||||
addText: @Composable RowScope.() -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
addAfterLazy: Boolean = true,
|
||||
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
|
||||
) = ListOrEmpty(
|
||||
data, emptyText,
|
||||
{ Button(onAdd, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) { addText() } },
|
||||
addAfterLazy,
|
||||
item
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun <T> ListOrEmpty(
|
||||
data: List<T>,
|
||||
emptyText: @Composable ColumnScope.() -> Unit,
|
||||
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
|
||||
) {
|
||||
if(data.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
emptyText()
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Column {
|
||||
LazyColumn(Modifier.padding(5.dp).weight(1f)) {
|
||||
itemsIndexed(data) { idx, it ->
|
||||
item(idx, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InteractToEdit(
|
||||
content: String, onSave: (String) -> Unit, pre: String, modifier: Modifier = Modifier,
|
||||
w1: Float = 0.75f, w2: Float = 0.25f,
|
||||
singleLine: Boolean = true
|
||||
) {
|
||||
var text by remember(content) { mutableStateOf(content) }
|
||||
|
||||
Row(modifier.padding(5.dp)) {
|
||||
val base = if(singleLine) Modifier.align(Alignment.CenterVertically) else Modifier
|
||||
OutlinedTextField(
|
||||
text, { text = it }, base.weight(w1), label = { Text(pre) },
|
||||
singleLine = singleLine, minLines = if(singleLine) 1 else 5
|
||||
)
|
||||
IconButton({ onSave(text) }, base.weight(w2)) { Icon(Icons.Default.Check, "Save") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PaneHeader(name: String, type: String, course: Course, edition: Edition) = Column {
|
||||
Text(name, style = MaterialTheme.typography.headlineMedium)
|
||||
Text("${type.capitalize(Locale.current)} in ${course.name} (${edition.name})", fontStyle = FontStyle.Italic)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PaneHeader(name: String, type: String, courseEdition: Pair<Course, Edition>) = PaneHeader(name, type, courseEdition.first, courseEdition.second)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AutocompleteLineField__(
|
||||
// state: RichTextState,
|
||||
value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit,
|
||||
modifier: Modifier = Modifier, label: @Composable (() -> Unit)? = null,
|
||||
onFilter: (String) -> List<String>
|
||||
) = Column(modifier) {
|
||||
var suggestions by remember { mutableStateOf(listOf<String>()) }
|
||||
var selected by remember { mutableStateOf(0) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberLazyListState()
|
||||
|
||||
val posToLine = { pos: Int ->
|
||||
(value.text.take(pos).count { it == '\n' }) to (value.text.take(pos).lastIndexOf('\n'))
|
||||
}
|
||||
|
||||
val autoComplete = { str: String ->
|
||||
val pos = value.selection.start
|
||||
val lines = value.text.split("\n").toMutableList()
|
||||
val (lineno, lineStart) = posToLine(pos)
|
||||
|
||||
lines[lineno] = str
|
||||
onValueChange(value.copy(text = lines.joinToString("\n"), selection = TextRange(lineStart + str.length + 1)))
|
||||
}
|
||||
|
||||
val currentLine = {
|
||||
value.text.split('\n')[posToLine(value.selection.start).first]
|
||||
}
|
||||
|
||||
val gotoOption = { idx: Int ->
|
||||
selected = if(suggestions.isEmpty()) 0 else ((idx + suggestions.size) % suggestions.size)
|
||||
scope.launch {
|
||||
scrollState.animateScrollToItem(if(suggestions.isNotEmpty()) (selected + 1) else 0)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
val onKey = { kev: KeyEvent ->
|
||||
var res = true
|
||||
if(suggestions.isNotEmpty()) {
|
||||
when (kev.key) {
|
||||
Key.Tab -> autoComplete(suggestions[selected])
|
||||
Key.DirectionUp -> gotoOption(selected - 1)
|
||||
Key.DirectionDown -> gotoOption(selected + 1)
|
||||
Key.Escape -> suggestions = listOf()
|
||||
else -> res = false
|
||||
}
|
||||
}
|
||||
else res = false
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
LaunchedEffect(value.text) {
|
||||
delay(300)
|
||||
suggestions = onFilter(currentLine())
|
||||
gotoOption(if(suggestions.isEmpty()) 0 else selected % suggestions.size)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value, onValueChange,
|
||||
Modifier.fillMaxWidth().weight(0.75f).onKeyEvent(onKey), label = label, singleLine = false, minLines = 5
|
||||
)
|
||||
|
||||
if(suggestions.isNotEmpty()) {
|
||||
LazyColumn(Modifier.weight(0.25f), state = scrollState) {
|
||||
stickyHeader {
|
||||
Surface(tonalElevation = 5.dp) {
|
||||
Text("Suggestions", Modifier.padding(5.dp).fillMaxWidth(), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
itemsIndexed(suggestions) { idx, it ->
|
||||
Surface(Modifier.padding(5.dp).fillMaxWidth(), tonalElevation = if(selected == idx) 50.dp else 0.dp) {
|
||||
Text(it, Modifier.clickable { autoComplete(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DateTimePicker(
|
||||
value: LocalDateTime,
|
||||
onPick: (LocalDateTime) -> Unit,
|
||||
formatter: (LocalDateTime) -> String = { java.text.DateFormat.getDateTimeInstance().format(Date.from(it.toInstant(TimeZone.currentSystemDefault()).toJavaInstant())) },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
|
||||
Row(modifier) {
|
||||
Text(
|
||||
formatter(value),
|
||||
Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Button({ showPicker = true }) { Text("Change") }
|
||||
|
||||
if (showPicker) {
|
||||
val dateState = rememberDatePickerState(value.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds())
|
||||
val timeState = rememberTimePickerState(value.hour, value.minute)
|
||||
|
||||
Dialog(
|
||||
{ showPicker = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge, tonalElevation = 6.dp,
|
||||
modifier = Modifier.width(800.dp).height(600.dp)
|
||||
) {
|
||||
val colors = TimePickerDefaults.colors(
|
||||
selectorColor = MaterialTheme.colorScheme.primary,
|
||||
timeSelectorSelectedContainerColor = MaterialTheme.colorScheme.primary,
|
||||
timeSelectorSelectedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
clockDialSelectedContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) // the colors are fucked, and I don't get why :(
|
||||
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Row {
|
||||
DatePicker(
|
||||
dateState,
|
||||
Modifier.padding(10.dp).weight(0.5f),
|
||||
)
|
||||
TimePicker(
|
||||
timeState,
|
||||
Modifier.weight(0.5f).align(Alignment.CenterVertically),
|
||||
layoutType = TimePickerLayoutType.Vertical,
|
||||
colors = colors
|
||||
)
|
||||
}
|
||||
CancelSaveRow(true, { showPicker = false }) {
|
||||
val date = (dateState.selectedDateMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.currentSystemDefault()) } ?: value).date
|
||||
val time = LocalTime(timeState.hour, timeState.minute)
|
||||
|
||||
onPick(LocalDateTime(date, time))
|
||||
showPicker = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// DatePickerDialog(
|
||||
// { showPicker = false },
|
||||
// {
|
||||
// Button({
|
||||
// showPicker = false; dateState.selectedDateMillis?.let { state.updateDeadline(it) }
|
||||
// }) { Text("Set deadline") }
|
||||
// },
|
||||
// Modifier,
|
||||
// { Button({ showPicker = false }) { Text("Cancel") } },
|
||||
// shape = MaterialTheme.shapes.medium,
|
||||
// tonalElevation = 10.dp,
|
||||
// colors = DatePickerDefaults.colors(),
|
||||
// properties = DialogProperties()
|
||||
// ) {
|
||||
// DatePicker(
|
||||
// dateState,
|
||||
// Modifier.fillMaxWidth().padding(10.dp),
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItalicAndNormal(italic: String, normal: String) = Row{
|
||||
Text(italic, fontStyle = FontStyle.Italic)
|
||||
Text(normal)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Selectable(
|
||||
isSelected: Boolean,
|
||||
onSelect: () -> Unit, onDeselect: () -> Unit,
|
||||
unselectedElevation: Dp = 0.dp, selectedElevation: Dp = 50.dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().clickable { if(isSelected) onDeselect() else onSelect() },
|
||||
tonalElevation = if (isSelected) selectedElevation else unselectedElevation,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectEditDeleteRow(
|
||||
isSelected: Boolean,
|
||||
onSelect: () -> Unit, onDeselect: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) = Selectable(isSelected, onSelect, onDeselect) {
|
||||
Row {
|
||||
Box(Modifier.weight(1f).align(Alignment.CenterVertically)) { content() }
|
||||
IconButton(onEdit, Modifier.align(Alignment.CenterVertically)) {
|
||||
Icon(Icons.Default.Edit, "Edit")
|
||||
}
|
||||
IconButton(onDelete, Modifier.align(Alignment.CenterVertically)) {
|
||||
Icon(Icons.Default.Delete, "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,19 +136,19 @@ fun FromTo(size: Dp) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PEGradeWidget(
|
||||
grade: PeerEvaluationState.Student2StudentEntry?,
|
||||
onSelect: () -> Unit, onDeselect: () -> Unit,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) = Box(modifier.padding(2.dp)) {
|
||||
Selectable(isSelected, onSelect, onDeselect) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(grade?.let { if(it.grade.isNotBlank()) it.grade else if(it.feedback.isNotBlank()) "(other)" else null } ?: "none")
|
||||
}
|
||||
}
|
||||
}
|
||||
//@Composable
|
||||
//fun PEGradeWidget(
|
||||
// grade: PeerEvaluationState.Student2StudentEntry?,
|
||||
// onSelect: () -> Unit, onDeselect: () -> Unit,
|
||||
// isSelected: Boolean,
|
||||
// modifier: Modifier = Modifier
|
||||
//) = Box(modifier.padding(2.dp)) {
|
||||
// Selectable(isSelected, onSelect, onDeselect) {
|
||||
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// Text(grade?.let { if(it.grade.isNotBlank()) it.grade else if(it.feedback.isNotBlank()) "(other)" else null } ?: "none")
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@Composable
|
||||
fun VLine(width: Dp = 1.dp, color: Color = Color.Black) = Spacer(Modifier.fillMaxHeight().width(width).background(color))
|
||||
@@ -459,4 +157,103 @@ fun VLine(width: Dp = 1.dp, color: Color = Color.Black) = Spacer(Modifier.fillMa
|
||||
fun MeasuredLazyItemScope.HLine(height: Dp = 1.dp, color: Color = Color.Black) {
|
||||
val width by measuredWidth()
|
||||
Spacer(Modifier.width(width).height(height).background(color))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RolePicker(used: List<String>, curr: String?, onClose: () -> Unit, onSave: (String?) -> Unit) = DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(400.dp, 500.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
var role by remember { mutableStateOf(curr ?: "") }
|
||||
Column {
|
||||
Text("Used roles:")
|
||||
LazyColumn(Modifier.weight(1.0f).padding(5.dp)) {
|
||||
items(used) {
|
||||
Surface(Modifier.fillMaxWidth().clickable { role = it }, tonalElevation = 5.dp) {
|
||||
Text(it, Modifier.padding(5.dp))
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
}
|
||||
}
|
||||
OutlinedTextField(role, { role = it }, Modifier.fillMaxWidth())
|
||||
CancelSaveRow(true, onClose) {
|
||||
onSave(role.ifBlank { null })
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GradePicker(grade: Grade, modifier: Modifier = Modifier, key: Any = Unit, onUpdate: (Grade) -> Unit) = Row(modifier) { // TODO: fix UI to remove save-buttons (instead wait fo end of editing)
|
||||
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
|
||||
|
||||
when(grade) {
|
||||
is Grade.Categoric -> {
|
||||
if(grade.options.size <= 5) {
|
||||
Column {
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||
grade.options.forEachIndexed { idx, opt ->
|
||||
println("Rendering opt ${opt.option} (index $idx) ~ current value: ${grade.value.option})")
|
||||
SegmentedButton(
|
||||
grade.value.option == opt.option, { onUpdate(Grade.Categoric(opt, grade.options, grade.grade)) },
|
||||
shape = SegmentedButtonDefaults.itemShape(idx, grade.options.size)
|
||||
) { Text(opt.option.maxN(15)) }
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(grade.value.option, fontStyle = FontStyle.Italic, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
var slider by remember(grade, key) { mutableStateOf(maxOf(0, grade.options.indexOfFirst { it.option == grade.value.option })) }
|
||||
Row {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Slider(
|
||||
slider.toFloat(),
|
||||
onValueChange = { onUpdate(grade.copy(value = grade.options[slider])) },
|
||||
steps = grade.options.size,
|
||||
valueRange = 0f..(grade.options.size - 1).toFloat()
|
||||
)
|
||||
Row {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(grade.options[slider].option, fontStyle = FontStyle.Italic, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is Grade.FreeText -> {
|
||||
var text by remember(grade, key) { mutableStateOf(grade.text) }
|
||||
|
||||
OutlinedTextField(grade.text, { onUpdate(grade.copy(text = it)) }, Modifier.weight(1f), singleLine = true)
|
||||
Button({ onUpdate(Grade.FreeText(text)) }, enabled = text != grade.text) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
is Grade.Numeric -> {
|
||||
var num by remember(grade, key) { mutableStateOf(grade.value.toString()) }
|
||||
|
||||
OutlinedTextField(
|
||||
num, { num = it.filter { c -> c.isDigit() || c == '.' || c == ',' }.ifEmpty { "0" } },
|
||||
Modifier.weight(1f), singleLine = true, isError = (num.toDoubleOrNull() ?: 0.0) > grade.grade.max
|
||||
)
|
||||
Button({ onUpdate(Grade.Numeric(num.toDoubleOrNull() ?: 0.0, grade.grade)) }, enabled = (num.toDoubleOrNull() ?: 0.0) <= grade.grade.max) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
is Grade.Percentage -> {
|
||||
var perc by remember(grade, key) { mutableStateOf(grade.percentage.toString()) }
|
||||
|
||||
OutlinedTextField("$perc%", { perc = it.filter { c -> c.isDigit() || c == '.' || c == ',' }.ifEmpty { "0" } }, Modifier.weight(1f), singleLine = true)
|
||||
Button({ onUpdate(Grade.Percentage(perc.toDoubleOrNull() ?: 0.0)) }) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,821 +3,64 @@ package com.jaytux.grader.viewmodel
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.jaytux.grader.data.*
|
||||
import com.jaytux.grader.data.EditionStudents.editionId
|
||||
import com.jaytux.grader.data.EditionStudents.studentId
|
||||
import com.jaytux.grader.viewmodel.GroupAssignmentState.*
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.jetbrains.exposed.dao.id.EntityID
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
|
||||
fun <T> MutableState<T>.immutable(): State<T> = this
|
||||
fun <T> SizedIterable<T>.sortAsc(vararg columns: Expression<*>) = this.orderBy(*(columns.map { it to SortOrder.ASC }.toTypedArray()))
|
||||
|
||||
enum class AssignmentType(val show: String) { Solo("Solo Assignment"), Group("Group Assignment"), Peer("Peer Evaluation") }
|
||||
sealed class Assignment {
|
||||
class GAssignment(val assignment: GroupAssignment) : Assignment() {
|
||||
override fun name(): String = assignment.name
|
||||
override fun id(): EntityID<UUID> = assignment.id
|
||||
override fun index(): Int? = assignment.number
|
||||
}
|
||||
class SAssignment(val assignment: SoloAssignment) : Assignment() {
|
||||
override fun name(): String = assignment.name
|
||||
override fun id(): EntityID<UUID> = assignment.id
|
||||
override fun index(): Int? = assignment.number
|
||||
}
|
||||
class PeerEval(val evaluation: com.jaytux.grader.data.PeerEvaluation) : Assignment() {
|
||||
override fun name(): String = evaluation.name
|
||||
override fun id(): EntityID<UUID> = evaluation.id
|
||||
override fun index(): Int? = evaluation.number
|
||||
}
|
||||
|
||||
abstract fun name(): String
|
||||
abstract fun id(): EntityID<UUID>
|
||||
abstract fun index(): Int?
|
||||
|
||||
companion object {
|
||||
fun from(assignment: GroupAssignment) = GAssignment(assignment)
|
||||
fun from(assignment: SoloAssignment) = SAssignment(assignment)
|
||||
fun from(pEval: PeerEvaluation) = PeerEval(pEval)
|
||||
|
||||
fun merge(groups: List<GroupAssignment>, solos: List<SoloAssignment>, peers: List<PeerEvaluation>): List<Assignment> {
|
||||
val g = groups.map { from(it) }
|
||||
val s = solos.map { from(it) }
|
||||
val p = peers.map { from(it) }
|
||||
return (g + s + p).sortedWith(compareBy<Assignment> { it.index() }.thenBy { it.name() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RawDbState<T: Any>(private val loader: (Transaction.() -> List<T>)) {
|
||||
|
||||
private val rawEntities by lazy {
|
||||
mutableStateOf(transaction { loader() })
|
||||
}
|
||||
val entities = rawEntities.immutable()
|
||||
|
||||
val entities = rawEntities.immutable()
|
||||
fun refresh() {
|
||||
rawEntities.value = transaction { loader() }
|
||||
}
|
||||
}
|
||||
|
||||
class CourseListState {
|
||||
val courses = RawDbState { Course.all().sortAsc(Courses.name).toList() }
|
||||
|
||||
fun new(name: String) {
|
||||
transaction { Course.new { this.name = name } }
|
||||
courses.refresh()
|
||||
class RawDbFocusableSingleState<TIn, TOut: Any>(private val loader: (Transaction.(TIn) -> TOut?)) {
|
||||
private var _input: TIn? = null
|
||||
private val rawEntity by lazy {
|
||||
mutableStateOf<TOut?>(null)
|
||||
}
|
||||
|
||||
fun delete(course: Course) {
|
||||
transaction { course.delete() }
|
||||
courses.refresh()
|
||||
val entity: State<TOut?> = rawEntity.immutable()
|
||||
|
||||
fun focus(input: TIn) {
|
||||
_input = input
|
||||
rawEntity.value = transaction { loader(input) }
|
||||
}
|
||||
|
||||
fun getEditions(course: Course) = EditionListState(course)
|
||||
}
|
||||
|
||||
class EditionListState(val course: Course) {
|
||||
val editions = RawDbState { Edition.find { Editions.courseId eq course.id }.sortAsc(Editions.name).toList() }
|
||||
|
||||
fun new(name: String) {
|
||||
transaction { Edition.new { this.name = name; this.course = this@EditionListState.course } }
|
||||
editions.refresh()
|
||||
fun unfocus() {
|
||||
_input = null
|
||||
rawEntity.value = null
|
||||
}
|
||||
|
||||
fun delete(edition: Edition) {
|
||||
transaction { edition.delete() }
|
||||
editions.refresh()
|
||||
fun refresh() {
|
||||
rawEntity.value = transaction { _input?.let { loader(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenPanel(val tabName: String) {
|
||||
Student("Students"), Group("Groups"), Assignment("Assignments")
|
||||
}
|
||||
|
||||
class EditionState(val edition: Edition) {
|
||||
val course = transaction { edition.course }
|
||||
val students = RawDbState { edition.soloStudents.sortAsc(Students.name).toList() }
|
||||
val groups = RawDbState { edition.groups.sortAsc(Groups.name).toList() }
|
||||
val solo = RawDbState { edition.soloAssignments.sortAsc(SoloAssignments.name).toList() }
|
||||
val groupAs = RawDbState { edition.groupAssignments.sortAsc(GroupAssignments.name).toList() }
|
||||
val peer = RawDbState { edition.peerEvaluations.sortAsc(PeerEvaluations.name).toList() }
|
||||
private val _history = mutableStateOf(listOf(-1 to OpenPanel.Assignment))
|
||||
val history = _history.immutable()
|
||||
|
||||
val availableStudents = RawDbState {
|
||||
Student.find {
|
||||
(Students.id notInList edition.soloStudents.map { it.id })
|
||||
}.toList()
|
||||
class RawDbFocusableState<TIn, TOut: Any>(private val loader: (Transaction.(TIn) -> List<TOut>)) {
|
||||
private var _input: TIn? = null
|
||||
private val rawEntities by lazy {
|
||||
mutableStateOf<List<TOut>?>(null)
|
||||
}
|
||||
|
||||
fun newStudent(name: String, contact: String, note: String, addToEdition: Boolean) {
|
||||
transaction {
|
||||
val student = Student.new { this.name = name; this.contact = contact; this.note = note }
|
||||
if(addToEdition) EditionStudents.insert {
|
||||
it[editionId] = edition.id
|
||||
it[studentId] = student.id
|
||||
}
|
||||
}
|
||||
val entities: State<List<TOut>?> = rawEntities.immutable()
|
||||
|
||||
if(addToEdition) students.refresh()
|
||||
else availableStudents.refresh()
|
||||
}
|
||||
fun setStudentName(student: Student, name: String) {
|
||||
transaction {
|
||||
student.name = name
|
||||
}
|
||||
students.refresh()
|
||||
}
|
||||
fun addToCourse(students: List<Student>) {
|
||||
transaction {
|
||||
EditionStudents.batchInsert(students) {
|
||||
this[editionId] = edition.id
|
||||
this[studentId] = it.id
|
||||
}
|
||||
}
|
||||
availableStudents.refresh()
|
||||
this.students.refresh()
|
||||
fun focus(input: TIn) {
|
||||
_input = input
|
||||
rawEntities.value = transaction { loader(input) }
|
||||
}
|
||||
|
||||
fun newGroup(name: String) {
|
||||
transaction {
|
||||
Group.new { this.name = name; this.edition = this@EditionState.edition }
|
||||
groups.refresh()
|
||||
}
|
||||
}
|
||||
fun setGroupName(group: Group, name: String) {
|
||||
transaction {
|
||||
group.name = name
|
||||
}
|
||||
groups.refresh()
|
||||
fun unfocus() {
|
||||
_input = null
|
||||
rawEntities.value = null
|
||||
}
|
||||
|
||||
private fun now(): LocalDateTime {
|
||||
val instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||
return instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
fun refresh() {
|
||||
rawEntities.value = transaction { _input?.let { loader(it) } }
|
||||
}
|
||||
|
||||
private fun nextIdx(): Int = max(
|
||||
solo.entities.value.maxOfOrNull { it.number ?: 0 } ?: 0,
|
||||
groupAs.entities.value.maxOfOrNull { it.number ?: 0 } ?: 0
|
||||
) + 1
|
||||
|
||||
fun newSoloAssignment(name: String) {
|
||||
transaction {
|
||||
val assign = SoloAssignment.new {
|
||||
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
|
||||
this.number = nextIdx()
|
||||
}
|
||||
val global = SoloAssignmentCriterion.new {
|
||||
this.name = "_global"; this.description = "[Global] Meta-criterion for $name"; this.assignment = assign
|
||||
}
|
||||
assign.globalCriterion = global
|
||||
solo.refresh()
|
||||
}
|
||||
}
|
||||
fun setSoloAssignmentTitle(assignment: SoloAssignment, title: String) {
|
||||
transaction {
|
||||
assignment.name = title
|
||||
}
|
||||
solo.refresh()
|
||||
}
|
||||
fun newGroupAssignment(name: String) {
|
||||
transaction {
|
||||
val assign = GroupAssignment.new {
|
||||
this.name = name; this.edition = this@EditionState.edition; assignment = ""; deadline = now()
|
||||
this.number = nextIdx()
|
||||
}
|
||||
val global = GroupAssignmentCriterion.new {
|
||||
this.name = "_global"; this.description = "[Global] Meta-criterion for $name"; this.assignment = assign
|
||||
}
|
||||
assign.globalCriterion = global
|
||||
groupAs.refresh()
|
||||
}
|
||||
}
|
||||
fun setGroupAssignmentTitle(assignment: GroupAssignment, title: String) {
|
||||
transaction {
|
||||
assignment.name = title
|
||||
}
|
||||
groupAs.refresh()
|
||||
}
|
||||
fun newPeerEvaluation(name: String) {
|
||||
transaction {
|
||||
PeerEvaluation.new {
|
||||
this.name = name; this.edition = this@EditionState.edition
|
||||
this.number = nextIdx()
|
||||
}
|
||||
peer.refresh()
|
||||
}
|
||||
}
|
||||
fun setPeerEvaluationTitle(assignment: PeerEvaluation, title: String) {
|
||||
transaction {
|
||||
assignment.name = title
|
||||
}
|
||||
peer.refresh()
|
||||
}
|
||||
|
||||
fun newAssignment(type: AssignmentType, name: String) = when(type) {
|
||||
AssignmentType.Solo -> newSoloAssignment(name)
|
||||
AssignmentType.Group -> newGroupAssignment(name)
|
||||
AssignmentType.Peer -> newPeerEvaluation(name)
|
||||
}
|
||||
fun setAssignmentTitle(assignment: Assignment, title: String) = when(assignment) {
|
||||
is Assignment.GAssignment -> setGroupAssignmentTitle(assignment.assignment, title)
|
||||
is Assignment.SAssignment -> setSoloAssignmentTitle(assignment.assignment, title)
|
||||
is Assignment.PeerEval -> setPeerEvaluationTitle(assignment.evaluation, title)
|
||||
}
|
||||
|
||||
fun swapOrder(a1: Assignment, a2: Assignment) {
|
||||
transaction {
|
||||
when(a1) {
|
||||
is Assignment.GAssignment -> {
|
||||
when(a2) {
|
||||
is Assignment.GAssignment -> {
|
||||
val temp = a1.assignment.number
|
||||
a1.assignment.number = a2.assignment.number
|
||||
a2.assignment.number = temp
|
||||
}
|
||||
is Assignment.SAssignment -> {
|
||||
val temp = a1.assignment.number
|
||||
a1.assignment.number = nextIdx()
|
||||
a2.assignment.number = temp
|
||||
}
|
||||
is Assignment.PeerEval -> {
|
||||
val temp = a1.assignment.number
|
||||
a1.assignment.number = nextIdx()
|
||||
a2.evaluation.number = temp
|
||||
}
|
||||
}
|
||||
}
|
||||
is Assignment.SAssignment -> {
|
||||
when(a2) {
|
||||
is Assignment.GAssignment -> {
|
||||
val temp = a1.assignment.number
|
||||
a1.assignment.number = a2.assignment.number
|
||||
a2.assignment.number = temp
|
||||
}
|
||||
is Assignment.SAssignment -> {
|
||||
val temp = a1.assignment.number
|
||||
a1.assignment.number = a2.assignment.number
|
||||
a2.assignment.number = temp
|
||||
}
|
||||
is Assignment.PeerEval -> {
|
||||
val temp = a1.assignment.number
|
||||
a1.assignment.number = nextIdx()
|
||||
a2.evaluation.number = temp
|
||||
}
|
||||
}
|
||||
}
|
||||
is Assignment.PeerEval -> {
|
||||
when(a2) {
|
||||
is Assignment.GAssignment -> {
|
||||
val temp = a1.evaluation.number
|
||||
a1.evaluation.number = a2.assignment.number
|
||||
a2.assignment.number = temp
|
||||
}
|
||||
is Assignment.SAssignment -> {
|
||||
val temp = a1.evaluation.number
|
||||
a1.evaluation.number = a2.assignment.number
|
||||
a2.assignment.number = temp
|
||||
}
|
||||
is Assignment.PeerEval -> {
|
||||
val temp = a1.evaluation.number
|
||||
a1.evaluation.number = a2.evaluation.number
|
||||
a2.evaluation.number = temp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
solo.refresh(); groupAs.refresh()
|
||||
}
|
||||
|
||||
fun delete(s: Student) {
|
||||
transaction {
|
||||
EditionStudents.deleteWhere { studentId eq s.id }
|
||||
GroupStudents.deleteWhere { studentId eq s.id }
|
||||
IndividualFeedbacks.deleteWhere { studentId eq s.id }
|
||||
}
|
||||
students.refresh(); availableStudents.refresh()
|
||||
}
|
||||
fun delete(g: Group) {
|
||||
transaction {
|
||||
GroupFeedbacks.deleteWhere { groupId eq g.id }
|
||||
IndividualFeedbacks.deleteWhere { groupId eq g.id }
|
||||
GroupStudents.deleteWhere { groupId eq g.id }
|
||||
g.delete()
|
||||
}
|
||||
groups.refresh(); groupAs.refresh()
|
||||
}
|
||||
fun delete(sa: SoloAssignment) {
|
||||
transaction {
|
||||
SoloAssignmentCriteria.selectAll().where { SoloAssignmentCriteria.assignmentId eq sa.id }.forEach { it ->
|
||||
val id = it[SoloAssignmentCriteria.assignmentId]
|
||||
SoloFeedbacks.deleteWhere { criterionId eq id }
|
||||
}
|
||||
SoloAssignmentCriteria.deleteWhere { assignmentId eq sa.id }
|
||||
sa.delete()
|
||||
}
|
||||
solo.refresh()
|
||||
}
|
||||
fun delete(ga: GroupAssignment) {
|
||||
transaction {
|
||||
GroupAssignmentCriteria.selectAll().where { GroupAssignmentCriteria.assignmentId eq ga.id }.forEach { it ->
|
||||
val id = it[GroupAssignmentCriteria.assignmentId]
|
||||
GroupFeedbacks.deleteWhere { criterionId eq id }
|
||||
IndividualFeedbacks.deleteWhere { criterionId eq id }
|
||||
}
|
||||
GroupAssignmentCriteria.deleteWhere { assignmentId eq ga.id }
|
||||
ga.delete()
|
||||
}
|
||||
groupAs.refresh()
|
||||
}
|
||||
fun delete(pe: PeerEvaluation) {
|
||||
transaction {
|
||||
PeerEvaluationContents.deleteWhere { peerEvaluationId eq pe.id }
|
||||
StudentToStudentEvaluation.deleteWhere { peerEvaluationId eq pe.id }
|
||||
pe.delete()
|
||||
}
|
||||
peer.refresh()
|
||||
}
|
||||
fun delete(assignment: Assignment) = when(assignment) {
|
||||
is Assignment.GAssignment -> delete(assignment.assignment)
|
||||
is Assignment.SAssignment -> delete(assignment.assignment)
|
||||
is Assignment.PeerEval -> delete(assignment.evaluation)
|
||||
}
|
||||
|
||||
fun navTo(panel: OpenPanel, id: Int = -1) {
|
||||
_history.value += (id to panel)
|
||||
}
|
||||
fun navTo(id: Int) = navTo(_history.value.last().second, id)
|
||||
fun back() {
|
||||
var temp = _history.value.dropLast(1)
|
||||
while(temp.last().first == -1 && temp.size >= 2) temp = temp.dropLast(1)
|
||||
_history.value = temp
|
||||
}
|
||||
fun clearHistoryIndex() {
|
||||
val last = _history.value.lastOrNull() ?: return
|
||||
_history.value = _history.value.filter { (i, panel) -> panel != last.second || i != last.first } + (-1 to last.second)
|
||||
}
|
||||
}
|
||||
|
||||
class StudentState(val student: Student, edition: Edition) {
|
||||
data class LocalGroupGrade(val groupName: String, val assignmentName: String, val groupGrade: String?, val indivGrade: String?)
|
||||
data class LocalSoloGrade(val assignmentName: String, val grade: String)
|
||||
|
||||
val editionCourse = transaction { edition.course to edition }
|
||||
val groups = RawDbState { student.groups.sortAsc(Groups.name).map { it to (it.edition.course.name to it.edition.name) }.toList() }
|
||||
val courseEditions = RawDbState { student.courses.map{ it to it.course }.sortedWith {
|
||||
(e1, c1), (e2, c2) -> c1.name.compareTo(c2.name).let { if(it == 0) e1.name.compareTo(e2.name) else it }
|
||||
}.toList() }
|
||||
|
||||
val groupGrades = RawDbState {
|
||||
val groupsForEdition = Group.find {
|
||||
(Groups.editionId eq edition.id) and (Groups.id inList student.groups.map { it.id })
|
||||
}.associate { it.id to it.name }
|
||||
|
||||
val asGroup = (GroupAssignments innerJoin GroupAssignmentCriteria innerJoin GroupFeedbacks innerJoin Groups).selectAll().where {
|
||||
(GroupFeedbacks.groupId inList groupsForEdition.keys.toList()) and
|
||||
(GroupAssignmentCriteria.id eq GroupAssignments.globalCriterion)
|
||||
}.map { it[GroupAssignments.id] to it }
|
||||
|
||||
val asIndividual = (GroupAssignments innerJoin GroupAssignmentCriteria innerJoin IndividualFeedbacks innerJoin Groups).selectAll().where {
|
||||
(IndividualFeedbacks.studentId eq student.id) and
|
||||
(GroupAssignmentCriteria.id eq GroupAssignments.globalCriterion)
|
||||
}.map { it[GroupAssignments.id] to it }
|
||||
|
||||
val res = mutableMapOf<EntityID<UUID>, LocalGroupGrade>()
|
||||
asGroup.forEach {
|
||||
val (gAId, gRow) = it
|
||||
|
||||
res[gAId] = LocalGroupGrade(
|
||||
gRow[Groups.name], gRow[GroupAssignments.name], gRow[GroupFeedbacks.grade], null
|
||||
)
|
||||
}
|
||||
|
||||
asIndividual.forEach {
|
||||
val (gAId, iRow) = it
|
||||
|
||||
val og = res[gAId] ?: LocalGroupGrade(iRow[Groups.name], iRow[GroupAssignments.name], null, null)
|
||||
res[gAId] = og.copy(indivGrade = iRow[IndividualFeedbacks.grade])
|
||||
}
|
||||
|
||||
res.values.toList()
|
||||
}
|
||||
|
||||
val soloGrades = RawDbState {
|
||||
(SoloAssignments innerJoin SoloAssignmentCriteria innerJoin SoloFeedbacks).selectAll().where {
|
||||
(SoloFeedbacks.studentId eq student.id) and
|
||||
(SoloAssignmentCriteria.name eq "")
|
||||
}.map { LocalSoloGrade(it[SoloAssignments.name], it[SoloFeedbacks.grade]) }.toList()
|
||||
}
|
||||
|
||||
fun update(f: Student.() -> Unit) {
|
||||
transaction {
|
||||
student.f()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GroupState(val group: Group) {
|
||||
val members = RawDbState { group.studentRoles.map{ it.student to it.role }.sortedBy { it.first.name }.toList() }
|
||||
val availableStudents = RawDbState { Student.find {
|
||||
// not yet in the group
|
||||
(Students.id notInList group.students.map { it.id }) and
|
||||
// but in the same course (edition)
|
||||
(Students.id inList group.edition.soloStudents.map { it.id })
|
||||
}.sortAsc(Students.name).toList() }
|
||||
val course = transaction { group.edition.course to group.edition }
|
||||
val roles = RawDbState {
|
||||
GroupStudents.select(GroupStudents.role).where{ GroupStudents.role.isNotNull() }
|
||||
.withDistinct(true).sortAsc(GroupStudents.role).map{ it[GroupStudents.role] ?: "" }.toList()
|
||||
}
|
||||
|
||||
fun addStudent(student: Student) {
|
||||
transaction {
|
||||
GroupStudents.insert {
|
||||
it[groupId] = group.id
|
||||
it[studentId] = student.id
|
||||
}
|
||||
}
|
||||
members.refresh(); availableStudents.refresh()
|
||||
}
|
||||
|
||||
fun removeStudent(student: Student) {
|
||||
transaction {
|
||||
GroupStudents.deleteWhere { groupId eq group.id and (studentId eq student.id) }
|
||||
}
|
||||
members.refresh(); availableStudents.refresh()
|
||||
}
|
||||
|
||||
fun updateRole(student: Student, role: String?) {
|
||||
transaction {
|
||||
GroupStudents.update({ GroupStudents.groupId eq group.id and (GroupStudents.studentId eq student.id) }) {
|
||||
it[this.role] = role
|
||||
}
|
||||
members.refresh()
|
||||
roles.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GroupAssignmentState(val assignment: GroupAssignment) {
|
||||
data class FeedbackEntry(val feedback: String, val grade: String)
|
||||
data class LocalCriterionFeedback(
|
||||
val criterion: GroupAssignmentCriterion, val entry: FeedbackEntry?
|
||||
)
|
||||
data class LocalFeedback(
|
||||
val global: FeedbackEntry?, val byCriterion: List<LocalCriterionFeedback>
|
||||
)
|
||||
data class LocalGFeedback(
|
||||
val group: Group,
|
||||
val feedback: LocalFeedback,
|
||||
val individuals: List<Pair<Student, Pair<String?, LocalFeedback>>> // Student -> (Role, Feedback)
|
||||
)
|
||||
|
||||
val editionCourse = transaction { assignment.edition.course to assignment.edition }
|
||||
private val _name = mutableStateOf(assignment.name); val name = _name.immutable()
|
||||
private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable()
|
||||
private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable()
|
||||
val criteria = RawDbState {
|
||||
assignment.criteria.orderBy(GroupAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.id }
|
||||
}
|
||||
val feedback = RawDbState { loadFeedback() }
|
||||
|
||||
val autofill = RawDbState {
|
||||
val forGroups = (GroupFeedbacks innerJoin GroupAssignmentCriteria).selectAll().where { GroupAssignmentCriteria.assignmentId eq assignment.id }.flatMap {
|
||||
it[GroupFeedbacks.feedback].split('\n')
|
||||
}
|
||||
|
||||
val forIndividuals = (IndividualFeedbacks innerJoin GroupAssignmentCriteria).selectAll().where { GroupAssignmentCriteria.assignmentId eq assignment.id }.flatMap {
|
||||
it[IndividualFeedbacks.feedback].split('\n')
|
||||
}
|
||||
|
||||
(forGroups + forIndividuals).distinct().sorted()
|
||||
}
|
||||
|
||||
private fun Transaction.loadFeedback(): List<Pair<Group, LocalGFeedback>> {
|
||||
val allCrit = GroupAssignmentCriterion.find {
|
||||
GroupAssignmentCriteria.assignmentId eq assignment.id
|
||||
}.orderBy(GroupAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.id }
|
||||
|
||||
return Group.find {
|
||||
(Groups.editionId eq assignment.edition.id)
|
||||
}.sortAsc(Groups.name).map { group ->
|
||||
val forGroup = (GroupFeedbacks innerJoin Groups).selectAll().where {
|
||||
(GroupFeedbacks.assignmentId eq assignment.id) and (Groups.id eq group.id)
|
||||
}.map { row ->
|
||||
val crit = GroupAssignmentCriterion[row[GroupFeedbacks.criterionId]]
|
||||
val fdbk = row[GroupFeedbacks.feedback]
|
||||
val grade = row[GroupFeedbacks.grade]
|
||||
|
||||
crit to FeedbackEntry(fdbk, grade)
|
||||
}
|
||||
|
||||
val global = forGroup.firstOrNull { it.first.id == assignment.globalCriterion.id }?.second
|
||||
val byCrit_ = forGroup
|
||||
.filter{ it.first.id != assignment.globalCriterion.id }
|
||||
.map { LocalCriterionFeedback(it.first, it.second) }
|
||||
.associateBy { it.criterion.id }
|
||||
|
||||
val byCrit = allCrit.map { c ->
|
||||
byCrit_[c.id] ?: LocalCriterionFeedback(c, null)
|
||||
}
|
||||
|
||||
val byGroup = LocalFeedback(global, byCrit)
|
||||
|
||||
val indiv = group.studentRoles.map {
|
||||
val student = it.student
|
||||
val role = it.role
|
||||
|
||||
val forSt = (IndividualFeedbacks innerJoin Groups)
|
||||
.selectAll().where {
|
||||
(IndividualFeedbacks.assignmentId eq assignment.id) and
|
||||
(IndividualFeedbacks.studentId eq student.id) and (Groups.id eq group.id)
|
||||
}.map { row ->
|
||||
val stdId = row[IndividualFeedbacks.studentId]
|
||||
val crit = GroupAssignmentCriterion[row[IndividualFeedbacks.criterionId]]
|
||||
val fdbk = row[IndividualFeedbacks.feedback]
|
||||
val grade = row[IndividualFeedbacks.grade]
|
||||
|
||||
crit to FeedbackEntry(fdbk, grade)
|
||||
}
|
||||
|
||||
val global = forSt.firstOrNull { it.first.id == assignment.globalCriterion.id }?.second
|
||||
val byCrit_ = forSt
|
||||
.filter { it.first != assignment.globalCriterion.id }
|
||||
.map { LocalCriterionFeedback(it.first, it.second) }
|
||||
.associateBy { it.criterion.id }
|
||||
|
||||
val byCrit = allCrit.map { c ->
|
||||
byCrit_[c.id] ?: LocalCriterionFeedback(c, null)
|
||||
}
|
||||
val byStudent = LocalFeedback(global, byCrit)
|
||||
|
||||
student to (role to byStudent)
|
||||
}
|
||||
|
||||
group to LocalGFeedback(group, byGroup, indiv)
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertGroupFeedback(group: Group, msg: String, grd: String, criterion: GroupAssignmentCriterion? = null) {
|
||||
transaction {
|
||||
GroupFeedbacks.upsert {
|
||||
it[assignmentId] = assignment.id
|
||||
it[groupId] = group.id
|
||||
it[this.feedback] = msg
|
||||
it[this.grade] = grd
|
||||
it[criterionId] = criterion?.id ?: assignment.globalCriterion.id
|
||||
}
|
||||
}
|
||||
feedback.refresh(); autofill.refresh()
|
||||
}
|
||||
|
||||
fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String, criterion: GroupAssignmentCriterion? = null) {
|
||||
transaction {
|
||||
IndividualFeedbacks.upsert {
|
||||
it[assignmentId] = assignment.id
|
||||
it[groupId] = group.id
|
||||
it[studentId] = student.id
|
||||
it[this.feedback] = msg
|
||||
it[this.grade] = grd
|
||||
it[criterionId] = criterion?.id ?: assignment.globalCriterion.id
|
||||
}
|
||||
}
|
||||
feedback.refresh(); autofill.refresh()
|
||||
}
|
||||
|
||||
fun updateTask(t: String) {
|
||||
transaction {
|
||||
assignment.assignment = t
|
||||
}
|
||||
_task.value = t
|
||||
}
|
||||
|
||||
fun updateDeadline(d: LocalDateTime) {
|
||||
transaction {
|
||||
assignment.deadline = d
|
||||
}
|
||||
_deadline.value = d
|
||||
}
|
||||
|
||||
fun addCriterion(name: String) {
|
||||
transaction {
|
||||
GroupAssignmentCriterion.new {
|
||||
this.name = name;
|
||||
this.description = "";
|
||||
this.assignment = this@GroupAssignmentState.assignment
|
||||
}
|
||||
criteria.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCriterion(criterion: GroupAssignmentCriterion, name: String, desc: String) {
|
||||
transaction {
|
||||
criterion.name = name
|
||||
criterion.description = desc
|
||||
}
|
||||
criteria.refresh()
|
||||
}
|
||||
|
||||
fun deleteCriterion(criterion: GroupAssignmentCriterion) {
|
||||
transaction {
|
||||
GroupFeedbacks.deleteWhere { criterionId eq criterion.id }
|
||||
IndividualFeedbacks.deleteWhere { criterionId eq criterion.id }
|
||||
criterion.delete()
|
||||
}
|
||||
criteria.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
class SoloAssignmentState(val assignment: SoloAssignment) {
|
||||
data class LocalFeedback(val feedback: String, val grade: String)
|
||||
data class FullFeedback(val global: LocalFeedback?, val byCriterion: List<Pair<SoloAssignmentCriterion, LocalFeedback?>>)
|
||||
|
||||
val editionCourse = transaction { assignment.edition.course to assignment.edition }
|
||||
private val _name = mutableStateOf(assignment.name); val name = _name.immutable()
|
||||
private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable()
|
||||
private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable()
|
||||
val criteria = RawDbState {
|
||||
assignment.criteria.orderBy(SoloAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.id }
|
||||
}
|
||||
val feedback = RawDbState { loadFeedback() }
|
||||
|
||||
val autofill = RawDbState {
|
||||
SoloFeedbacks.selectAll().where { SoloFeedbacks.assignmentId eq assignment.id }.map {
|
||||
it[SoloFeedbacks.feedback].split('\n')
|
||||
}.flatten().distinct().sorted()
|
||||
}
|
||||
|
||||
private fun Transaction.loadFeedback(): List<Pair<Student, FullFeedback>> {3
|
||||
val allCrit = SoloAssignmentCriterion.find {
|
||||
SoloAssignmentCriteria.assignmentId eq assignment.id
|
||||
}.orderBy(SoloAssignmentCriteria.name to SortOrder.ASC).filter { it.id != assignment.globalCriterion.id }
|
||||
|
||||
return editionCourse.second.soloStudents.sortAsc(Students.name).map { student ->
|
||||
val forStudent = (IndividualFeedbacks innerJoin Students).selectAll().where {
|
||||
(IndividualFeedbacks.assignmentId eq assignment.id) and (Students.id eq student.id)
|
||||
}.map { row ->
|
||||
val crit = SoloAssignmentCriterion[row[IndividualFeedbacks.criterionId]]
|
||||
val fdbk = row[IndividualFeedbacks.feedback]
|
||||
val grade = row[IndividualFeedbacks.grade]
|
||||
|
||||
crit to LocalFeedback(fdbk, grade)
|
||||
}
|
||||
|
||||
val global = forStudent.firstOrNull { it.first == assignment.globalCriterion.id }?.second
|
||||
val byCrit_ = forStudent
|
||||
.filter { it.first != assignment.globalCriterion.id }
|
||||
.map { Pair(it.first, it.second) }
|
||||
.associateBy { it.first.id }
|
||||
|
||||
val byCrit = allCrit.map { c ->
|
||||
byCrit_[c.id] ?: Pair(c, null)
|
||||
}
|
||||
|
||||
student to FullFeedback(global, byCrit)
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertFeedback(student: Student, msg: String?, grd: String?, criterion: SoloAssignmentCriterion? = null) {
|
||||
transaction {
|
||||
SoloFeedbacks.upsert {
|
||||
it[assignmentId] = assignment.id
|
||||
it[studentId] = student.id
|
||||
it[this.feedback] = msg ?: ""
|
||||
it[this.grade] = grd ?: ""
|
||||
it[criterionId] = criterion?.id ?: assignment.globalCriterion.id
|
||||
}
|
||||
}
|
||||
feedback.refresh(); autofill.refresh()
|
||||
}
|
||||
|
||||
fun updateTask(t: String) {
|
||||
transaction {
|
||||
assignment.assignment = t
|
||||
}
|
||||
_task.value = t
|
||||
}
|
||||
|
||||
fun updateDeadline(d: LocalDateTime) {
|
||||
transaction {
|
||||
assignment.deadline = d
|
||||
}
|
||||
_deadline.value = d
|
||||
}
|
||||
|
||||
fun addCriterion(name: String) {
|
||||
transaction {
|
||||
SoloAssignmentCriterion.new {
|
||||
this.name = name;
|
||||
this.description = "";
|
||||
this.assignment = this@SoloAssignmentState.assignment
|
||||
}
|
||||
criteria.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCriterion(criterion: SoloAssignmentCriterion, name: String, desc: String) {
|
||||
transaction {
|
||||
criterion.name = name
|
||||
criterion.description = desc
|
||||
}
|
||||
criteria.refresh()
|
||||
}
|
||||
|
||||
fun deleteCriterion(criterion: SoloAssignmentCriterion) {
|
||||
transaction {
|
||||
SoloFeedbacks.deleteWhere { criterionId eq criterion.id }
|
||||
criterion.delete()
|
||||
}
|
||||
criteria.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
class PeerEvaluationState(val evaluation: PeerEvaluation) {
|
||||
data class Student2StudentEntry(val grade: String, val feedback: String)
|
||||
data class StudentEntry(val student: Student, val role: String?, val global: Student2StudentEntry?, val others: List<Pair<Student, Student2StudentEntry?>>)
|
||||
data class GroupEntry(val group: Group, val content: String, val students: List<StudentEntry>)
|
||||
val editionCourse = transaction { evaluation.edition.course to evaluation.edition }
|
||||
private val _name = mutableStateOf(evaluation.name); val name = _name.immutable()
|
||||
val contents = RawDbState { loadContents() }
|
||||
|
||||
private fun Transaction.loadContents(): List<GroupEntry> {
|
||||
return evaluation.edition.groups.map { group ->
|
||||
val globalNotes = PeerEvaluationContents.selectAll()
|
||||
.where {
|
||||
(PeerEvaluationContents.groupId eq group.id) and
|
||||
(PeerEvaluationContents.peerEvaluationId eq evaluation.id)
|
||||
}.firstOrNull()?.let {
|
||||
it[PeerEvaluationContents.content]
|
||||
}
|
||||
|
||||
val students = group.students.map { from ->
|
||||
val role = GroupStudents.selectAll().where { (GroupStudents.studentId eq from.id) and (GroupStudents.groupId eq group.id) }.firstOrNull()?.let {
|
||||
it[GroupStudents.role]
|
||||
}
|
||||
val s2g = StudentToGroupEvaluation.selectAll().where {
|
||||
(StudentToGroupEvaluation.peerEvaluationId eq evaluation.id) and
|
||||
(StudentToGroupEvaluation.studentId eq from.id)
|
||||
}.firstOrNull()?.let {
|
||||
Student2StudentEntry(it[StudentToGroupEvaluation.grade], it[StudentToGroupEvaluation.note])
|
||||
}
|
||||
|
||||
val others = group.students.map { other ->
|
||||
val eval = StudentToStudentEvaluation.selectAll().where {
|
||||
(StudentToStudentEvaluation.peerEvaluationId eq evaluation.id) and
|
||||
(StudentToStudentEvaluation.studentIdFrom eq from.id) and
|
||||
(StudentToStudentEvaluation.studentIdTo eq other.id)
|
||||
}.firstOrNull()?.let {
|
||||
Student2StudentEntry(it[StudentToStudentEvaluation.grade], it[StudentToStudentEvaluation.note])
|
||||
}
|
||||
|
||||
other to eval
|
||||
}
|
||||
|
||||
StudentEntry(from, role, s2g, others)
|
||||
}
|
||||
|
||||
GroupEntry(group, globalNotes ?: "", students)
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertGroupFeedback(group: Group, feedback: String) {
|
||||
transaction {
|
||||
PeerEvaluationContents.upsert {
|
||||
it[peerEvaluationId] = evaluation.id
|
||||
it[groupId] = group.id
|
||||
it[this.content] = feedback
|
||||
}
|
||||
}
|
||||
contents.refresh()
|
||||
}
|
||||
|
||||
fun upsertIndividualFeedback(from: Student, to: Student?, grade: String, feedback: String) {
|
||||
transaction {
|
||||
to?.let {
|
||||
StudentToStudentEvaluation.upsert {
|
||||
it[peerEvaluationId] = evaluation.id
|
||||
it[studentIdFrom] = from.id
|
||||
it[studentIdTo] = to.id
|
||||
it[this.grade] = grade
|
||||
it[this.note] = feedback
|
||||
}
|
||||
} ?: StudentToGroupEvaluation.upsert {
|
||||
it[peerEvaluationId] = evaluation.id
|
||||
it[studentId] = from.id
|
||||
it[this.grade] = grade
|
||||
it[this.note] = feedback
|
||||
}
|
||||
}
|
||||
contents.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package com.jaytux.grader.viewmodel
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.jaytux.grader.app
|
||||
import com.jaytux.grader.data.v2.*
|
||||
import com.jaytux.grader.ui.AssignmentsTabHeader
|
||||
import com.jaytux.grader.ui.GroupsTabHeader
|
||||
import com.jaytux.grader.ui.StudentsTabHeader
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import org.jetbrains.exposed.v1.core.Expression
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.dao.with
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import kotlin.time.Clock
|
||||
|
||||
class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
|
||||
data class GroupData(val group: Group, val members: List<Pair<Student, String?>>)
|
||||
data class CriterionData(val criterion: Criterion, val gradeType: UiGradeType) {
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun from(c: Criterion) = CriterionData(c, when(c.gradeType) {
|
||||
GradeType.CATEGORIC -> UiGradeType.Categoric(c.categoricGrade!!.options.toList(), c.categoricGrade!!)
|
||||
GradeType.NUMERIC -> UiGradeType.Numeric(c.numericGrade!!)
|
||||
GradeType.PERCENTAGE -> UiGradeType.Percentage
|
||||
GradeType.NONE -> UiGradeType.FreeText
|
||||
})
|
||||
}
|
||||
}
|
||||
data class AssignmentData(val assignment: BaseAssignment, val global: CriterionData, val criteria: List<CriterionData>)
|
||||
data class GradeSummary(val assignment: BaseAssignment, val asMember: Group?, val overridden: Boolean, val grade: Grade?)
|
||||
enum class Tab(val renderTab: @Composable () -> Unit, val addText: String) {
|
||||
STUDENTS(::StudentsTabHeader, "Student"),
|
||||
GROUPS(::GroupsTabHeader, "Group"),
|
||||
ASSIGNMENTS(::AssignmentsTabHeader, "Assignment")
|
||||
}
|
||||
|
||||
val studentList = RawDbState { edition.students.sortedBy { it.name }.toList() }
|
||||
val groupList = RawDbState {
|
||||
edition.groups.with(Group::students, GroupStudent::student).sortedBy { it.name }.map {
|
||||
GroupData(it, it.students.map { gs -> gs.student to gs.role }.sortedBy { it.first.name })
|
||||
}
|
||||
}
|
||||
val assignmentList = RawDbState {
|
||||
edition.assignments.sortedBy { it.number }.map {
|
||||
AssignmentData(it, CriterionData.from(it.globalCriterion), it.nonBaseCriteria.map { c ->
|
||||
CriterionData.from(c)
|
||||
})
|
||||
}
|
||||
}
|
||||
val usedRoles = RawDbState {
|
||||
GroupStudents.select(GroupStudents.role).mapNotNull { it[GroupStudents.role] }.distinct()
|
||||
}
|
||||
|
||||
val categoricGrades = RawDbState {
|
||||
CategoricGrade.all().map {
|
||||
UiGradeType.Categoric(it.options.toList(), it)
|
||||
}
|
||||
}
|
||||
|
||||
val numericGrades = RawDbState {
|
||||
NumericGrade.all().map { UiGradeType.Numeric(it) }
|
||||
}
|
||||
|
||||
val studentGrades = RawDbFocusableState { st: Student ->
|
||||
val groupIds = st.groups.map { it.group.id }.toSet()
|
||||
|
||||
edition.assignments.map { asg ->
|
||||
val (grade, memberOf, override) = when(asg.type) {
|
||||
AssignmentType.GROUP -> {
|
||||
val asGroup = asg.globalCriterion.feedbacks.find { it.asGroupFeedback?.id in groupIds }
|
||||
val solo = null // asg.globalCriterion.feedbacks.find { it.forStudentsOverrideIfGroup.any { over -> over.student == st } } // TODO
|
||||
val gr = (solo ?: asGroup)?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||
gr to asGroup?.asGroupFeedback app (solo != null)
|
||||
}
|
||||
AssignmentType.SOLO -> {
|
||||
val gr = asg.globalCriterion.feedbacks.find { it.asSoloFeedback == st }
|
||||
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||
gr to null app false
|
||||
}
|
||||
AssignmentType.PEER_EVALUATION -> {
|
||||
val asGroup = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id in groupIds }
|
||||
val solo = asg.globalCriterion.feedbacks.find { it.forStudentsOverrideIfPeer.any { over -> over.student == st } }
|
||||
val gr = (solo ?: asGroup)?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||
gr to asGroup?.asPeerEvaluationFeedback app (solo != null)
|
||||
}
|
||||
}
|
||||
|
||||
GradeSummary(asg, memberOf, override, grade)
|
||||
}
|
||||
}
|
||||
val studentGroups = RawDbFocusableState { st: Student ->
|
||||
st.groups.map { it.group to it.role }
|
||||
}
|
||||
|
||||
val groupAvailableStudents = RawDbFocusableState { grp: Group ->
|
||||
val exclude = grp.students.map { it.student.id }.toSet()
|
||||
edition.students.filterNot { it.id in exclude }
|
||||
}
|
||||
val groupGrades = RawDbFocusableState { g: Group ->
|
||||
edition.assignments.filter{ it.type != AssignmentType.SOLO }.map { asg ->
|
||||
val grade = when(asg.type) {
|
||||
AssignmentType.GROUP -> {
|
||||
val asGroup = asg.globalCriterion.feedbacks.find { it.asGroupFeedback?.id == g.id }
|
||||
asGroup?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||
}
|
||||
AssignmentType.PEER_EVALUATION -> {
|
||||
val asGroup = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id == g.id }
|
||||
asGroup?.let { Grade.fromAssignment(asg.globalCriterion, it) }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
asg to grade
|
||||
}
|
||||
}
|
||||
|
||||
private val _selectedTab = mutableStateOf(Tab.STUDENTS)
|
||||
private val _focusIndex = mutableStateOf(-1)
|
||||
val selectedTab = _selectedTab.immutable()
|
||||
val focusIndex = _focusIndex.immutable()
|
||||
|
||||
fun switchTo(tab: Tab) {
|
||||
_selectedTab.value = tab
|
||||
_focusIndex.value = -1
|
||||
}
|
||||
|
||||
fun focus(idx: Int) {
|
||||
_focusIndex.value = idx
|
||||
|
||||
when(_selectedTab.value) {
|
||||
Tab.STUDENTS -> {
|
||||
val st = studentList.entities.value[idx]
|
||||
studentGrades.focus(st)
|
||||
studentGroups.focus(st)
|
||||
}
|
||||
Tab.GROUPS -> {
|
||||
val grp = groupList.entities.value[idx].group
|
||||
groupAvailableStudents.focus(grp)
|
||||
groupGrades.focus(grp)
|
||||
}
|
||||
Tab.ASSIGNMENTS -> {}
|
||||
}
|
||||
}
|
||||
|
||||
fun focus(group: Group) {
|
||||
val idx = groupList.entities.value.indexOfFirst { it.group.id == group.id }
|
||||
if(idx != -1) {
|
||||
switchTo(Tab.GROUPS)
|
||||
focus(idx)
|
||||
}
|
||||
}
|
||||
|
||||
fun focus(student: Student) {
|
||||
val idx = studentList.entities.value.indexOfFirst { it.id == student.id }
|
||||
if(idx != -1) {
|
||||
switchTo(Tab.STUDENTS)
|
||||
focus(idx)
|
||||
}
|
||||
}
|
||||
|
||||
fun unfocus() {
|
||||
_focusIndex.value = -1
|
||||
|
||||
studentGrades.unfocus()
|
||||
studentGroups.unfocus()
|
||||
}
|
||||
|
||||
fun mkStudent(name: String, contact: String, note: String) {
|
||||
transaction {
|
||||
val s = Student.new {
|
||||
this.name = name
|
||||
this.contact = contact
|
||||
this.note = note
|
||||
}
|
||||
EditionStudents.insert {
|
||||
it[EditionStudents.editionId] = edition.id
|
||||
it[EditionStudents.studentId] = s.id
|
||||
}
|
||||
}
|
||||
unfocus()
|
||||
studentList.refresh()
|
||||
}
|
||||
|
||||
fun modStudent(student: Student, name: String?, contact: String?, note: String?) {
|
||||
transaction {
|
||||
student.name = name ?: student.name
|
||||
student.contact = contact ?: student.contact
|
||||
student.note = note ?: student.note
|
||||
}
|
||||
studentList.refresh()
|
||||
studentGroups.refresh()
|
||||
studentGrades.refresh()
|
||||
}
|
||||
|
||||
fun rmStudent(student: Student) {
|
||||
transaction {
|
||||
student.delete()
|
||||
}
|
||||
unfocus()
|
||||
studentList.refresh()
|
||||
}
|
||||
|
||||
fun mkGroup(name: String) {
|
||||
transaction {
|
||||
Group.new {
|
||||
this.name = name
|
||||
this.edition = this@EditionVM.edition
|
||||
}
|
||||
}
|
||||
unfocus()
|
||||
groupList.refresh()
|
||||
}
|
||||
|
||||
fun modGroup(group: Group, name: String?) {
|
||||
transaction {
|
||||
group.name = name ?: group.name
|
||||
}
|
||||
groupList.refresh()
|
||||
}
|
||||
|
||||
fun addStudentToGroup(student: Student, group: Group, role: String?) {
|
||||
transaction {
|
||||
GroupStudent.new {
|
||||
this.student = student
|
||||
this.group = group
|
||||
this.role = role
|
||||
}
|
||||
}
|
||||
groupList.refresh()
|
||||
studentGroups.refresh()
|
||||
groupAvailableStudents.refresh()
|
||||
}
|
||||
|
||||
fun setStudentRole(student: Student, group: Group, role: String?) {
|
||||
transaction {
|
||||
GroupStudent.find { (GroupStudents.studentId eq student.id) and (GroupStudents.groupId eq group.id) }.firstOrNull()?.let {
|
||||
it.role = role
|
||||
}
|
||||
}
|
||||
groupList.refresh()
|
||||
groupAvailableStudents.refresh()
|
||||
usedRoles.refresh()
|
||||
}
|
||||
|
||||
fun rmStudentFromGroup(student: Student, group: Group) {
|
||||
transaction {
|
||||
GroupStudent.find { (GroupStudents.studentId eq student.id) and (GroupStudents.groupId eq group.id) }.firstOrNull()?.delete()
|
||||
}
|
||||
groupList.refresh()
|
||||
groupAvailableStudents.refresh()
|
||||
}
|
||||
|
||||
fun rmGroup(group: Group) {
|
||||
transaction {
|
||||
group.delete()
|
||||
}
|
||||
unfocus()
|
||||
groupList.refresh()
|
||||
}
|
||||
|
||||
private fun Transaction.mkBaseAssignment(name: String, type: AssignmentType): BaseAssignment {
|
||||
val asg = BaseAssignment.new {
|
||||
this.name = name
|
||||
this.assignment = ""
|
||||
this.deadline = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
this.number = assignmentList.entities.value.size
|
||||
this.edition = this@EditionVM.edition
|
||||
this.type = type
|
||||
}
|
||||
|
||||
val crit = Criterion.new {
|
||||
this.assignment = asg
|
||||
this.name = "(Default Criterion)"
|
||||
this.desc = "Default criterion for assignment ${asg.name}"
|
||||
this.gradeType = GradeType.NONE
|
||||
}
|
||||
|
||||
asg.globalCriterion = crit
|
||||
return asg
|
||||
}
|
||||
|
||||
private fun postCreateAsg() {
|
||||
focus(assignmentList.entities.value.size)
|
||||
assignmentList.refresh()
|
||||
}
|
||||
|
||||
fun mkGroupAssignment(name: String) {
|
||||
transaction {
|
||||
val asg = mkBaseAssignment(name, AssignmentType.GROUP)
|
||||
GroupAssignment.new { this.base = asg }
|
||||
}
|
||||
postCreateAsg()
|
||||
}
|
||||
|
||||
fun mkSoloAssignment(name: String) {
|
||||
transaction {
|
||||
val asg = mkBaseAssignment(name, AssignmentType.SOLO)
|
||||
SoloAssignment.new { this.base = asg }
|
||||
}
|
||||
postCreateAsg()
|
||||
}
|
||||
|
||||
fun mkPeerEvaluation(name: String) {
|
||||
transaction {
|
||||
val asg = mkBaseAssignment(name, AssignmentType.PEER_EVALUATION)
|
||||
PeerEvaluation.new { this.base = asg }
|
||||
}
|
||||
postCreateAsg()
|
||||
}
|
||||
|
||||
fun mkAssignment(name: String, type: AssignmentType) {
|
||||
when(type) {
|
||||
AssignmentType.GROUP -> mkGroupAssignment(name)
|
||||
AssignmentType.SOLO -> mkSoloAssignment(name)
|
||||
AssignmentType.PEER_EVALUATION -> mkPeerEvaluation(name)
|
||||
}
|
||||
}
|
||||
|
||||
fun modAssignment(assignment: BaseAssignment, name: String?, deadline: LocalDateTime?) {
|
||||
transaction {
|
||||
assignment.name = name ?: assignment.name
|
||||
assignment.deadline = deadline ?: assignment.deadline
|
||||
}
|
||||
assignmentList.refresh()
|
||||
}
|
||||
|
||||
fun setDesc(assignment: AssignmentData, desc: String) {
|
||||
transaction {
|
||||
assignment.global.criterion.desc = desc
|
||||
}
|
||||
assignmentList.refresh()
|
||||
}
|
||||
|
||||
fun mkCriterion(assignment: BaseAssignment, name: String, desc: String, gradeType: UiGradeType) {
|
||||
transaction {
|
||||
val crit = Criterion.new {
|
||||
this.assignment = assignment
|
||||
this.name = name
|
||||
this.desc = desc
|
||||
this.gradeType = when(gradeType) {
|
||||
is UiGradeType.Categoric -> GradeType.CATEGORIC
|
||||
is UiGradeType.Numeric -> GradeType.NUMERIC
|
||||
UiGradeType.Percentage -> GradeType.PERCENTAGE
|
||||
UiGradeType.FreeText -> GradeType.NONE
|
||||
}
|
||||
}
|
||||
|
||||
when(gradeType) {
|
||||
is UiGradeType.Categoric -> crit.categoricGrade = gradeType.grade
|
||||
is UiGradeType.Numeric -> crit.numericGrade = gradeType.grade
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
assignmentList.refresh()
|
||||
}
|
||||
|
||||
fun modCriterion(crit: Criterion, name: String?, desc: String?, gradeType: UiGradeType?) {
|
||||
transaction {
|
||||
crit.name = name ?: crit.name
|
||||
crit.desc = desc ?: crit.desc
|
||||
crit.gradeType = when(gradeType) {
|
||||
null -> crit.gradeType
|
||||
is UiGradeType.Categoric -> GradeType.CATEGORIC
|
||||
is UiGradeType.Numeric -> GradeType.NUMERIC
|
||||
UiGradeType.Percentage -> GradeType.PERCENTAGE
|
||||
UiGradeType.FreeText -> GradeType.NONE
|
||||
}
|
||||
|
||||
when(gradeType) {
|
||||
is UiGradeType.Categoric -> crit.categoricGrade = gradeType.grade
|
||||
is UiGradeType.Numeric -> crit.numericGrade = gradeType.grade
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
assignmentList.refresh()
|
||||
}
|
||||
|
||||
fun mkScale(name: String, options: List<String>) {
|
||||
transaction {
|
||||
val grade = CategoricGrade.new { this.name = name }
|
||||
options.forEachIndexed { idx, opt ->
|
||||
CategoricGradeOption.new {
|
||||
this.grade = grade
|
||||
this.option = opt
|
||||
this.index = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
categoricGrades.refresh()
|
||||
}
|
||||
|
||||
fun mkNumericScale(name: String, max: Double) {
|
||||
transaction {
|
||||
NumericGrade.new {
|
||||
this.name = name
|
||||
this.max = max
|
||||
}
|
||||
}
|
||||
numericGrades.refresh()
|
||||
}
|
||||
|
||||
fun rmAssignment(assignment: BaseAssignment) {
|
||||
transaction {
|
||||
assignment.delete()
|
||||
(assignment.asPeerEvaluation ?: assignment.asGroupAssignment ?: assignment.asSoloAssignment)?.delete()
|
||||
}
|
||||
unfocus()
|
||||
assignmentList.refresh()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.jaytux.grader.viewmodel
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.jaytux.grader.data.v2.BaseFeedback
|
||||
import com.jaytux.grader.data.v2.CategoricGrade
|
||||
import com.jaytux.grader.data.v2.CategoricGradeOption
|
||||
import com.jaytux.grader.data.v2.CategoricGradeOptions
|
||||
import com.jaytux.grader.data.v2.Criterion
|
||||
import com.jaytux.grader.data.v2.GradeType
|
||||
import com.jaytux.grader.data.v2.NumericGrade
|
||||
import com.jaytux.grader.maxN
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
|
||||
sealed class Grade {
|
||||
data class FreeText(val text: String) : Grade()
|
||||
data class Percentage(val percentage: Double) : Grade()
|
||||
data class Numeric(val value: Double, val grade: NumericGrade) : Grade()
|
||||
data class Categoric(val value: CategoricGradeOption, val options: List<CategoricGradeOption>, val grade: CategoricGrade) : Grade()
|
||||
|
||||
@Composable
|
||||
fun render(modifier: Modifier = Modifier) = when(this) {
|
||||
is FreeText -> Text(text.maxN(15), modifier)
|
||||
is Categoric -> Text(value.option, modifier)
|
||||
is Numeric -> Text("$value / ${grade.max}", modifier)
|
||||
is Percentage -> Text("$percentage%", modifier)
|
||||
}
|
||||
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun fromAssignment(asg: Criterion, fdb: BaseFeedback): Grade = when(asg.gradeType) {
|
||||
GradeType.CATEGORIC ->
|
||||
Categoric(fdb.gradeCategoric!!, asg.categoricGrade!!.options.toList(), asg.categoricGrade!!)
|
||||
|
||||
GradeType.NUMERIC -> Numeric(fdb.gradeNumeric!!, asg.numericGrade!!)
|
||||
GradeType.PERCENTAGE -> Percentage(fdb.gradeNumeric!!)
|
||||
GradeType.NONE -> FreeText(fdb.gradeFreeText!!)
|
||||
}
|
||||
|
||||
fun defaultFreeText() = FreeText("")
|
||||
fun defaultPercentage() = Percentage(0.0)
|
||||
fun defaultNumeric(grade: NumericGrade) = Numeric(0.0, grade)
|
||||
fun defaultCategoric(grade: CategoricGrade, options: List<CategoricGradeOption>) = Categoric(options.first(), options, grade)
|
||||
|
||||
fun default(type: GradeType, cat: CategoricGrade?, num: NumericGrade?) = when(type) {
|
||||
GradeType.CATEGORIC -> transaction {
|
||||
cat!!
|
||||
defaultCategoric(cat, cat.options.toList())
|
||||
}
|
||||
GradeType.NUMERIC -> defaultNumeric(num!!)
|
||||
GradeType.PERCENTAGE -> defaultPercentage()
|
||||
GradeType.NONE -> defaultFreeText()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.jaytux.grader.viewmodel
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.jaytux.grader.data.v2.BaseAssignment
|
||||
import com.jaytux.grader.data.v2.BaseFeedback
|
||||
import com.jaytux.grader.data.v2.BaseFeedbacks
|
||||
import com.jaytux.grader.data.v2.CategoricGrade
|
||||
import com.jaytux.grader.data.v2.Course
|
||||
import com.jaytux.grader.data.v2.Criterion
|
||||
import com.jaytux.grader.data.v2.Edition
|
||||
import com.jaytux.grader.data.v2.GradeType
|
||||
import com.jaytux.grader.data.v2.Group
|
||||
import com.jaytux.grader.data.v2.GroupAssignment
|
||||
import com.jaytux.grader.data.v2.GroupFeedbacks
|
||||
import com.jaytux.grader.data.v2.GroupStudent
|
||||
import com.jaytux.grader.data.v2.NumericGrade
|
||||
import com.jaytux.grader.data.v2.Student
|
||||
import com.jaytux.grader.data.v2.StudentOverrideFeedback
|
||||
import com.jaytux.grader.data.v2.StudentOverrideFeedbacks
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.dao.with
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.upsertReturning
|
||||
|
||||
class GroupsGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
|
||||
data class GroupData(val group: Group, val students: List<Pair<Student, String?>>)
|
||||
data class FeedbackItem(val base: BaseFeedback, val grade: Grade, val feedback: String) {
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun fromDb(f: BaseFeedback): FeedbackItem = when(f.criterion.gradeType) {
|
||||
GradeType.CATEGORIC -> {
|
||||
val categoric = f.criterion.categoricGrade!!
|
||||
val options = categoric.options.toList()
|
||||
Grade.Categoric(f.gradeCategoric ?: options.first(), options, categoric)
|
||||
}
|
||||
GradeType.NUMERIC -> Grade.Numeric(f.gradeNumeric ?: 0.0, f.criterion.numericGrade!!)
|
||||
GradeType.PERCENTAGE -> Grade.Percentage(f.gradeNumeric ?: 0.0)
|
||||
GradeType.NONE -> Grade.FreeText(f.gradeFreeText ?: "")
|
||||
}.let { FeedbackItem(f, it, f.feedback) }
|
||||
}
|
||||
}
|
||||
data class FeedbackData(val groupLevel: FeedbackItem?, val overrides: List<Pair<Student, FeedbackItem?>>)
|
||||
data class CritData(val criterion: Criterion, val cat: CategoricGrade?, val num: NumericGrade?) {
|
||||
companion object {
|
||||
context(trns: Transaction)
|
||||
fun fromDb(c: Criterion) = CritData(c, c.categoricGrade, c.numericGrade)
|
||||
}
|
||||
}
|
||||
|
||||
private val _focus = mutableStateOf(-1)
|
||||
val focus = _focus.immutable()
|
||||
|
||||
val asGroup = transaction { base.asGroupAssignment!! }
|
||||
val global = transaction { CritData.fromDb(base.globalCriterion) }
|
||||
val groupList = RawDbState {
|
||||
edition.groups.with(Group::students, GroupStudent::student).map { group ->
|
||||
GroupData(group, group.students.map { Pair(it.student, it.role) })
|
||||
}
|
||||
}
|
||||
|
||||
val globalGrade = RawDbFocusableSingleState { group: Group ->
|
||||
val g = base.globalCriterion.feedbacks.find { it.asGroupFeedback?.id == group.id }?.let { FeedbackItem.fromDb(it) }
|
||||
val overrides = g?.let { gl -> getOverrides(group, gl.base) } ?: group.students.map { it.student to null }
|
||||
FeedbackData(g, overrides)
|
||||
}
|
||||
|
||||
val gradeList = RawDbFocusableState { group: Group ->
|
||||
base.nonBaseCriteria.map { crit ->
|
||||
val groupLevel = crit.feedbacks.find { it.asGroupFeedback?.id == group.id }?.let { FeedbackItem.fromDb(it) }
|
||||
val overrides = groupLevel?.let { gl -> getOverrides(group, gl.base) } ?: group.students.map { it.student to null }
|
||||
|
||||
CritData.fromDb(crit) to FeedbackData(groupLevel, overrides)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Transaction.getOverrides(group: Group, fd: BaseFeedback): List<Pair<Student, FeedbackItem?>> {
|
||||
// val feedbacks = fd.forStudentsOverrideIfGroup.filter { it.group.id == group.id }.associateBy { it.student.id }
|
||||
//
|
||||
// return group.students.map {
|
||||
// it.student to feedbacks[it.student.id]?.let { sof -> FeedbackItem.fromDb(sof.feedback) }
|
||||
// }
|
||||
return listOf() // TODO!!!
|
||||
}
|
||||
|
||||
fun focusGroup(idx: Int) {
|
||||
_focus.value = idx
|
||||
val group = groupList.entities.value[idx].group
|
||||
globalGrade.focus(group)
|
||||
gradeList.focus(group)
|
||||
}
|
||||
|
||||
fun focusPrev() {
|
||||
if (focus.value > 0) {
|
||||
focusGroup(focus.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun focusNext() {
|
||||
if (focus.value < groupList.entities.value.size - 1) {
|
||||
focusGroup(focus.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
context(trns: Transaction)
|
||||
private fun BaseFeedback.set(grade: Grade, feedback: String) {
|
||||
this.feedback = feedback
|
||||
when(grade) {
|
||||
is Grade.Categoric -> this.gradeCategoric = grade.value
|
||||
is Grade.FreeText -> this.gradeFreeText = grade.text
|
||||
is Grade.Numeric -> this.gradeNumeric = grade.value
|
||||
is Grade.Percentage -> this.gradeNumeric = grade.percentage
|
||||
}
|
||||
}
|
||||
|
||||
fun modGroupFeedback(crit: Criterion, group: Group, grade: Grade, feedback: String) {
|
||||
transaction {
|
||||
val existing = group.feedbacks.find { f -> f.criterion.id == crit.id }
|
||||
if(existing != null) {
|
||||
existing.set(grade, feedback)
|
||||
}
|
||||
else {
|
||||
val fdb = BaseFeedback.new {
|
||||
criterion = crit
|
||||
set(grade, feedback)
|
||||
}
|
||||
GroupFeedbacks.insert {
|
||||
it[GroupFeedbacks.feedbackId] = fdb.id
|
||||
it[GroupFeedbacks.groupId] = group.id
|
||||
}
|
||||
}
|
||||
}
|
||||
globalGrade.refresh()
|
||||
gradeList.refresh()
|
||||
}
|
||||
|
||||
fun modOverrideFeedback(crit: Criterion, group: Group, student: Student, groupLevel: FeedbackItem, grade: Grade, feedback: String) {
|
||||
transaction {
|
||||
val existing = groupLevel.base.forStudentsOverrideIfGroup.find { it.student.id == student.id }
|
||||
if(existing != null) {
|
||||
existing.feedback.set(grade, feedback)
|
||||
}
|
||||
else {
|
||||
val fdb = BaseFeedback.new {
|
||||
criterion = crit
|
||||
set(grade, feedback)
|
||||
}
|
||||
StudentOverrideFeedback.new {
|
||||
this.group = group
|
||||
this.student = student
|
||||
this.feedback = fdb
|
||||
this.overrides = groupLevel.base
|
||||
}
|
||||
}
|
||||
}
|
||||
globalGrade.refresh()
|
||||
gradeList.refresh()
|
||||
}
|
||||
|
||||
fun rmOverrideFeedback(crit: Criterion, group: Group, student: Student) {
|
||||
transaction {
|
||||
crit.feedbacks.find {
|
||||
it.asGroupFeedback!!.id == group.id // find relevant group-level feedback
|
||||
}?.forStudentsOverrideIfGroup?.find {
|
||||
it.student.id == student.id // find override for the student
|
||||
}?.delete()
|
||||
}
|
||||
globalGrade.refresh()
|
||||
gradeList.refresh()
|
||||
}
|
||||
}
|
||||
@@ -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,152 @@
|
||||
package com.jaytux.grader.viewmodel
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.backhandler.BackHandler
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.jaytux.grader.ui.ChevronLeft
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class Navigator private constructor(
|
||||
private var _start: IDestination,
|
||||
private val _typeMap: Map<KClass<out IDestination>, RenderData>
|
||||
) : ViewModel() {
|
||||
interface IDestination
|
||||
|
||||
private data class Entry<T : IDestination>(val dest: T, val token: NavToken)
|
||||
inner class NavToken {
|
||||
fun navTo(target: IDestination) { this@Navigator.navTo(target) }
|
||||
fun back() { this@Navigator.back() }
|
||||
inline fun <reified T : IDestination> backTo() { this@Navigator.backTo<T>() }
|
||||
fun rewriteHistory(t: IDestination) { this@Navigator.rewriteHistory(t) }
|
||||
}
|
||||
internal data class RenderData(
|
||||
val header: @Composable (IDestination) -> Unit,
|
||||
val renderer: @Composable (IDestination, NavToken) -> Unit
|
||||
)
|
||||
|
||||
private val _stack = mutableStateOf(listOf<Entry<*>>(Entry(_start, NavToken())))
|
||||
|
||||
fun navTo(target: IDestination) {
|
||||
_stack.value += Entry(target, NavToken())
|
||||
}
|
||||
|
||||
fun back() {
|
||||
if(_stack.value.size > 1) _stack.value = _stack.value.dropLast(1)
|
||||
}
|
||||
|
||||
fun <T : IDestination> backTo(cls: KClass<T>) {
|
||||
val idx = _stack.value.indexOfLast { entry -> cls.isInstance(entry.dest) }
|
||||
if(idx != -1 && idx != _stack.value.lastIndex) {
|
||||
_stack.value = _stack.value.take(idx + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun rewriteHistory(t: IDestination) {
|
||||
_stack.value = listOf(Entry(t, NavToken()))
|
||||
_start = t
|
||||
}
|
||||
|
||||
inline fun <reified T : IDestination> backTo() = backTo(T::class)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun DisplayScaffold() {
|
||||
val state = remember { SnackbarHostState() }
|
||||
val stack by _stack
|
||||
val (top, render) = remember(stack) {
|
||||
val top = stack.last()
|
||||
val render = _typeMap[top.dest::class]
|
||||
?: throw IllegalStateException("No renderer for destination of type ${top.dest::class.simpleName}")
|
||||
top to render
|
||||
}
|
||||
val snackVM = viewModel<SnackVM>()
|
||||
snackVM.Launcher(state)
|
||||
|
||||
BackHandler { back() }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
title = { render.header(top.dest) },
|
||||
navigationIcon = {
|
||||
IconButton({ back() }, enabled = top != _start) {
|
||||
Icon(ChevronLeft, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(state)
|
||||
}
|
||||
) { insets ->
|
||||
Surface(Modifier.padding(insets), color = MaterialTheme.colorScheme.surface) {
|
||||
render.renderer(top.dest, top.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DslMarker
|
||||
annotation class NavigatorDslMarker
|
||||
|
||||
@NavigatorDslMarker
|
||||
class Builder internal constructor(
|
||||
private val _onBuild: (IDestination, Map<KClass<out IDestination>, RenderData>) -> Navigator
|
||||
) {
|
||||
private val _typeMap = mutableMapOf<KClass<out IDestination>, RenderData>()
|
||||
private lateinit var _start: IDestination
|
||||
|
||||
fun <T : IDestination> composable(cls: KClass<T>, title: @Composable (T) -> Unit, renderer: @Composable (T, NavToken) -> Unit) {
|
||||
_typeMap[cls]?.let {
|
||||
throw IllegalArgumentException("Destination of type ${cls.simpleName} is already registered.")
|
||||
} ?: run {
|
||||
_typeMap[cls] = RenderData({
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
title(it as T)
|
||||
}) { d, t ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
renderer(d as T, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : IDestination> composable(noinline title: @Composable (T) -> Unit, noinline renderer: @Composable (T, NavToken) -> Unit) {
|
||||
composable(T::class, title, renderer)
|
||||
}
|
||||
|
||||
fun start(start: IDestination) {
|
||||
if(this::_start.isInitialized) throw IllegalStateException("Start destination is already set.")
|
||||
_start = start
|
||||
}
|
||||
|
||||
internal fun build(): Navigator {
|
||||
if(!this::_start.isInitialized) throw IllegalStateException("Start destination is not set.")
|
||||
return _onBuild(_start, _typeMap)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun build(block: Builder.() -> Unit): Navigator =
|
||||
Builder { start, map -> Navigator(start, map) }
|
||||
.apply { block() }.build()
|
||||
|
||||
@Composable
|
||||
fun NavHost(initial: IDestination, block: Builder.() -> Unit) {
|
||||
val vm = viewModel {
|
||||
build {
|
||||
block()
|
||||
start(initial)
|
||||
}
|
||||
}
|
||||
|
||||
vm.DisplayScaffold()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.jaytux.grader.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.jaytux.grader.data.v2.BaseAssignment
|
||||
import com.jaytux.grader.data.v2.Course
|
||||
import com.jaytux.grader.data.v2.Edition
|
||||
|
||||
class PeerEvalsGradingVM(val course: Course, val edition: Edition, val base: BaseAssignment) : ViewModel() {
|
||||
}
|
||||
@@ -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,20 @@
|
||||
package com.jaytux.grader.viewmodel
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.jaytux.grader.data.v2.BaseFeedback
|
||||
import com.jaytux.grader.data.v2.CategoricGrade
|
||||
import com.jaytux.grader.data.v2.CategoricGradeOption
|
||||
import com.jaytux.grader.data.v2.Criterion
|
||||
import com.jaytux.grader.data.v2.GradeType
|
||||
import com.jaytux.grader.data.v2.NumericGrade
|
||||
import com.jaytux.grader.maxN
|
||||
import org.jetbrains.exposed.v1.core.Transaction
|
||||
|
||||
sealed class UiGradeType {
|
||||
object FreeText : UiGradeType()
|
||||
object Percentage : UiGradeType()
|
||||
data class Numeric(val grade: NumericGrade) : UiGradeType()
|
||||
data class Categoric(val options: List<CategoricGradeOption>, val grade: CategoricGrade) : UiGradeType()
|
||||
}
|
||||
@@ -1,33 +1,38 @@
|
||||
[versions]
|
||||
androidx-lifecycle = "2.8.4"
|
||||
compose-multiplatform = "1.8.1"
|
||||
androidx-lifecycle = "2.9.6"
|
||||
compose-multiplatform = "1.9.0"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.1.0"
|
||||
kotlin = "2.3.0"
|
||||
kotlinx-coroutines = "1.10.1"
|
||||
exposed = "0.59.0"
|
||||
material3 = "1.7.3"
|
||||
exposed = "1.1.1"
|
||||
material3 = "1.9.0"
|
||||
ui-android = "1.7.8"
|
||||
foundation-layout-android = "1.7.8"
|
||||
rtf = "1.0.0-rc11"
|
||||
filekit = "0.10.0-beta04"
|
||||
directories = "26"
|
||||
androidx-activity-compose = "1.12.2"
|
||||
|
||||
[libraries]
|
||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
|
||||
compose-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-multiplatform" }
|
||||
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
|
||||
exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" }
|
||||
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }
|
||||
exposed-migration = { group = "org.jetbrains.exposed", name = "exposed-migration", version.ref = "exposed" }
|
||||
exposed-migration = { group = "org.jetbrains.exposed", name = "exposed-migration-core", version.ref = "exposed" }
|
||||
exposed-migration-jdbc = { group = "org.jetbrains.exposed", name = "exposed-migration-jdbc", version.ref = "exposed" }
|
||||
exposed-kotlin-datetime = { group = "org.jetbrains.exposed", name = "exposed-kotlin-datetime", version.ref = "exposed" }
|
||||
sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.34.0" }
|
||||
sl4j = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.12" }
|
||||
material3-core = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "material3" }
|
||||
material3-desktop = { group = "org.jetbrains.compose.material3", name = "material3-desktop", version.ref = "material3" }
|
||||
material-icons = { group = "org.jetbrains.compose.material", name = "material-icons-extended", version.ref = "material3" }
|
||||
androidx-ui-android = { group = "androidx.compose.ui", name = "ui-android", version.ref = "ui-android" }
|
||||
androidx-foundation-layout-android = { group = "androidx.compose.foundation", name = "foundation-layout-android", version.ref = "foundation-layout-android" }
|
||||
rtfield = { group = "com.mohamedrejeb.richeditor", name = "richeditor-compose", version.ref = "rtf" }
|
||||
@@ -35,6 +40,7 @@ filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.re
|
||||
filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" }
|
||||
filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" }
|
||||
filekit-coil = { group = "io.github.vinceglb", name = "filekit-coil", version.ref = "filekit" }
|
||||
directories = { group = "dev.dirs", name = "directories", version.ref = "directories" }
|
||||
|
||||
[plugins]
|
||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||
|
||||
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
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
12
gradlew
vendored
12
gradlew
vendored
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -86,8 +86,7 @@ done
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
@@ -115,7 +114,6 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -173,7 +171,6 @@ fi
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
@@ -206,15 +203,14 @@ fi
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
|
||||
3
gradlew.bat
vendored
3
gradlew.bat
vendored
@@ -70,11 +70,10 @@ goto fail
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
|
||||
Reference in New Issue
Block a user