More grading updates

This commit is contained in:
2026-06-02 17:29:13 +02:00
parent bdc56748dd
commit a48ca56156
12 changed files with 446 additions and 268 deletions
@@ -9,6 +9,8 @@ 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.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.getValue
@@ -18,6 +20,9 @@ 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
import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import java.util.UUID
object Database {
val dataDir: String = ProjectDirectories.from("com", "jaytux", "grader").dataDir.also {
@@ -43,6 +48,8 @@ object Database {
it[CategoricGrades.id]
}
var passId: EntityID<UUID>? = null
var bId: EntityID<UUID>? = null
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 }
@@ -50,7 +57,15 @@ object Database {
this[CategoricGradeOptions.option] = it.first
this[CategoricGradeOptions.gradeId] = it.second
this[CategoricGradeOptions.index] = it.third
}.forEach {
when(it[CategoricGradeOptions.option]) {
"Pass" -> passId = it[CategoricGradeOptions.id]
"B (Good)" -> bId = it[CategoricGradeOptions.id]
}
}
CategoricGrades.update(where = { CategoricGrades.id eq pf }) { it[CategoricGrades.defaultOption] = passId!! }
CategoricGrades.update(where = { CategoricGrades.id eq af }) { it[CategoricGrades.defaultOption] = bId!! }
}
if(NumericGrade.count() == 0L) {
@@ -142,6 +142,7 @@ object PeerEvaluationS2SEvaluations : UUIDTable("peerEvalS2SEvals") {
object CategoricGrades : UUIDTable("categoricGrades") {
val name = varchar("name", 50).uniqueIndex()
val defaultOption = reference("default_option_id", CategoricGradeOptions.id)
}
object CategoricGradeOptions : UUIDTable("categoricGradeOpts") {
@@ -101,6 +101,7 @@ class CategoricGrade(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : EntityClass<UUID, CategoricGrade>(CategoricGrades)
var name by CategoricGrades.name
var default by CategoricGradeOption referencedOn CategoricGrades.defaultOption
val options by CategoricGradeOption referrersOn CategoricGradeOptions.gradeId orderBy CategoricGradeOptions.index
}
@@ -20,6 +20,9 @@ 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.data.v2.CategoricGrade
import com.jaytux.grader.data.v2.CategoricGradeOption
import com.jaytux.grader.data.v2.CategoricGradeOptions
import com.jaytux.grader.viewmodel.EditionVM
import com.jaytux.grader.viewmodel.Navigator
import com.jaytux.grader.viewmodel.UiGradeType
@@ -47,6 +50,8 @@ fun AssignmentsView(vm: EditionVM, token: Navigator.NavToken) = Row(Modifier.fil
var addingRubric by remember { mutableStateOf(false) }
var editingRubric by remember { mutableStateOf(-1) }
var updatingGrade by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(false) }
val navToGrading = lambda@{
if(assignment == null) return@lambda
@@ -58,7 +63,7 @@ fun AssignmentsView(vm: EditionVM, token: Navigator.NavToken) = Row(Modifier.fil
}
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
ListOrEmpty(assignments, { Text("No groups yet.") }) { idx, it ->
ListOrEmpty(assignments, { Text("No assignments yet.") }) { idx, it ->
QuickAssignment(idx, it, vm)
}
}
@@ -73,8 +78,21 @@ fun AssignmentsView(vm: EditionVM, token: Navigator.NavToken) = Row(Modifier.fil
val peerEvalData by vm.asPeerEvaluation.entity
var updatingPeerEvalGrade by remember { mutableStateOf(false) }
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)
Column {
Row(Modifier.height(IntrinsicSize.Min)) {
EditableText(
assignment.assignment.name, style = MaterialTheme.typography.headlineMedium,
canSave = { it.isNotBlank() && (it == assignment.assignment.name || !assignments.any { x -> x.assignment.name == it }) }
) {
vm.modAssignment(assignment.assignment, it, null)
}
Spacer(Modifier.width(10.dp))
IconButton(Delete, "Delete assignment", Modifier.align(Alignment.CenterVertically)) {
deleting = true
}
}
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) {
@@ -192,6 +210,15 @@ fun AssignmentsView(vm: EditionVM, token: Navigator.NavToken) = Row(Modifier.fil
}
}
}
if(deleting) {
if(assignment == null) deleting = false
else {
ConfirmDeleteDialog("an assignment", { deleting = false }, { vm.rmAssignment(assignment.assignment) }) {
Text("${assignment.assignment.type.display} \"${assignment.assignment.name}\"")
}
}
}
}
val fmt = LocalDateTime.Format {
@@ -300,7 +327,7 @@ fun AddCriterionDialog(current: EditionVM.CriterionData?, vm: EditionVM, taken:
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 }
GradeTypePicker(type, categories, numeric, vm::mkScale, vm::modScale, vm::mkNumericScale, Modifier.weight(1f)) { type = it }
CancelSaveRow(name.isNotBlank() && (name !in taken || name == current?.criterion?.name), onClose) {
onSave(name, desc, type)
@@ -331,7 +358,7 @@ fun SetGradingDialog(name: String, current: UiGradeType, vm: EditionVM, onClose:
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 }
GradeTypePicker(type, categories, numeric, vm::mkScale, vm::modScale, vm::mkNumericScale, Modifier.weight(1f)) { type = it }
CancelSaveRow(true, onClose) {
onSave(type)
@@ -349,7 +376,9 @@ fun SetGradingDialog(name: String, current: UiGradeType, vm: EditionVM, onClose:
@Composable
fun GradeTypePicker(
type: UiGradeType, categories: List<UiGradeType.Categoric>, numeric: List<UiGradeType.Numeric>,
mkCat: (String, List<String>) -> Unit, mkNum: (String, Double) -> Unit,
mkCat: (String, List<String>, Int) -> Unit,
modCat: (cat: CategoricGrade, add: List<String>, default: Int) -> Unit,
mkNum: (String, Double) -> Unit,
modifier: Modifier = Modifier,
onUpdate: (UiGradeType) -> Unit
) = Column(modifier) {
@@ -394,19 +423,26 @@ fun GradeTypePicker(
}
}
(type as? UiGradeType.Categoric)?.let {
var updating by remember(type, categories) { mutableStateOf<UiGradeType.Categoric?>(null) }
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
)
Row(Modifier.fillMaxWidth().clickable { selectedCategory = idx; onUpdate(it) }.padding(10.dp)) {
Column(Modifier.weight(1f)) {
Text(it.grade.name, fontWeight = FontWeight.Bold)
Text(
"(${it.options.size} options; default ${it.default?.option ?: "none"})",
Modifier.padding(start = 10.dp),
fontStyle = FontStyle.Italic
)
}
IconButton(Edit, modifier = Modifier.align(Alignment.CenterVertically)) {
updating = it
}
}
}
}
@@ -417,6 +453,11 @@ fun GradeTypePicker(
}
}
}
if(updating != null) ModCatScaleDialog(updating!!, { updating = null }) { categoric, add, i ->
modCat(categoric.grade, add, i)
updating = null
}
} ?: (type as? UiGradeType.Numeric)?.let {
LazyColumn(Modifier.weight(1f)) {
itemsIndexed(numeric) { idx, it ->
@@ -445,8 +486,8 @@ fun GradeTypePicker(
if(adding) {
when(type) {
is UiGradeType.Categoric -> AddCatScaleDialog(categories.map { it.grade.name }, { adding = false }) { name, options ->
mkCat(name, options)
is UiGradeType.Categoric -> AddCatScaleDialog(categories.map { it.grade.name }, { adding = false }) { name, options, idx ->
mkCat(name, options, idx)
}
is UiGradeType.Numeric -> AddNumScaleDialog(numeric.map { it.grade.name }, { adding = false }) { name, max ->
mkNum(name, max)
@@ -457,7 +498,7 @@ fun GradeTypePicker(
}
@Composable
fun AddCatScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String, List<String>) -> Unit) = DialogWindow(
fun AddCatScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String, List<String>, Int) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
) {
@@ -465,6 +506,7 @@ fun AddCatScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String,
var name by remember { mutableStateOf("") }
var options by remember { mutableStateOf(listOf<String>()) }
var adding by remember { mutableStateOf("") }
var default by remember { mutableStateOf(0) }
Surface(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
@@ -474,8 +516,15 @@ fun AddCatScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String,
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 } }) {
Column(Modifier.weight(1f).align(Alignment.CenterVertically)) {
Text(it)
if(idx == default) Text("(default option)", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
}
IconButton({ default = idx }, Modifier.align(Alignment.CenterVertically)) {
if(default == idx) Icon(CheckboxChecked, "Default option")
else Icon(CheckboxUnchecked, "Set as default")
}
IconButton({ options = options.filterNot { o -> o == it } }, Modifier.align(Alignment.CenterVertically)) {
Icon(Delete, "Delete grading option")
}
}
@@ -489,8 +538,77 @@ fun AddCatScaleDialog(taken: List<String>, onClose: () -> Unit, onSave: (String,
}
}
}
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
onSave(name, options)
CancelSaveRow(name.isNotBlank() && name !in taken && options.isNotEmpty() && default in options.indices, onClose) {
onSave(name, options, default)
onClose()
}
}
}
}
LaunchedEffect(Unit) { focus.requestFocus() }
}
@Composable
fun ModCatScaleDialog(
current: UiGradeType.Categoric, onClose: () -> Unit,
onSave: (UiGradeType.Categoric, List<String>, Int) -> Unit
) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(750.dp, 600.dp), position = WindowPosition(Alignment.Center))
) {
val focus = remember { FocusRequester() }
val name = current.grade.name
var default by remember(current) {
mutableStateOf(maxOf(current.options.indexOfFirst { it.id.value == current.default?.id?.value }, 0))
}
var options by remember(current) { mutableStateOf(current.options) }
var added by remember(current) { mutableStateOf(listOf<String>()) }
var adding by remember(current) { mutableStateOf("") }
Surface(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
Column(Modifier.align(Alignment.Center)) {
OutlinedTextField(name, {}, Modifier.fillMaxWidth(), label = { Text("Grading system name") }, singleLine = true, enabled = false)
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)) {
Column(Modifier.weight(1f).align(Alignment.CenterVertically)) {
Text(it.option)
if(idx == default) Text("(default option)", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
}
IconButton({ default = idx }, Modifier.align(Alignment.CenterVertically)) {
if(default == idx) Icon(CheckboxChecked, "Default option")
else Icon(CheckboxUnchecked, "Set as default")
}
}
}
itemsIndexed(added) { idx, it ->
Row(Modifier.fillMaxWidth().padding(5.dp)) {
Column(Modifier.weight(1f).align(Alignment.CenterVertically)) {
Text(it)
if(idx + options.size == default) Text("(default option)", Modifier.padding(start = 10.dp), fontStyle = FontStyle.Italic)
}
IconButton({ default = idx + options.size }, Modifier.align(Alignment.CenterVertically)) {
if(default == idx) Icon(CheckboxChecked, "Default option")
else Icon(CheckboxUnchecked, "Set as default")
}
}
}
item {
Row {
OutlinedTextField(adding, { adding = it }, Modifier.weight(1f).align(Alignment.CenterVertically).padding(5.dp), label = { Text("New option") }, isError = adding in options.map { it.option } || adding in added, singleLine = true)
Button({ added = added + adding; adding = "" }, Modifier.align(Alignment.CenterVertically).padding(5.dp), enabled = adding.isNotBlank() && adding !in options.map { it.option } && adding !in added) {
Text("Add")
}
}
}
}
CancelSaveRow(true, onClose) {
onSave(current, added, default)
onClose()
}
}
@@ -23,6 +23,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -48,6 +49,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.jaytux.grader.data.v2.Group
import com.jaytux.grader.data.v2.Student
import com.jaytux.grader.startEmail
import com.jaytux.grader.ui.EditableText
import com.jaytux.grader.viewmodel.EditionVM
import com.jaytux.grader.viewmodel.SnackVM
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
@@ -63,8 +65,10 @@ fun GroupsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
var swappingRole by remember { mutableStateOf(-1) }
val group = remember(groups, focus) { if(focus != -1) groups[focus] else null }
val groupNames = remember(groups) { groups.map { it.group.name } }
val grades by vm.groupGrades.entities
val snacks = viewModel<SnackVM> { SnackVM() }
var deleting by remember { mutableStateOf(false) }
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
ListOrEmpty(groups, { Text("No groups yet.") }) { idx, it ->
@@ -80,14 +84,21 @@ fun GroupsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
}
else {
Column(Modifier.padding(10.dp)) {
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
Text(group.group.name, style = MaterialTheme.typography.headlineMedium)
EditableName(
group.group.name, groupNames,
{ vm.modGroup(group.group, it) },
{ deleting = true },
style = MaterialTheme.typography.headlineMedium
) {
if (group.members.any { it.first.contact.isNotBlank() }) {
IconButton({ startEmail(group.members.mapNotNull { it.first.contact.ifBlank { null } }) { snacks.show(it) } }) {
Icon(Mail, "Send email", Modifier.fillMaxHeight())
IconButton(Mail, "Send email", Modifier.align(Alignment.CenterVertically)) {
startEmail(group.members.mapNotNull { it.first.contact.ifBlank { null } }) {
snacks.show(it)
}
}
}
}
Spacer(Modifier.height(5.dp))
Row(Modifier.padding(5.dp)) {
var showTargetBorder by remember { mutableStateOf(false) }
@@ -224,6 +235,15 @@ fun GroupsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
swappingRole = -1
}
}
if(deleting) {
if(group == null) deleting = false
else {
ConfirmDeleteDialog("a group", { deleting = false }, { vm.rmGroup(group.group) }) {
Text(group.group.name)
}
}
}
}
private class DDTarget<T>(val onStart: () -> Unit, val onEnd: () -> Unit, val validator: (Transferable) -> T?, val handle: (T) -> Unit) : DragAndDropTarget {
@@ -1430,3 +1430,84 @@ val Mail: ImageVector by lazy {
}
}.build()
}
val CheckboxUnchecked: ImageVector by lazy {
ImageVector.Builder(
name = "checkbox-unchecked",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = SolidColor(Color.Black)
) {
moveTo(3f, 6.25f)
curveTo(3f, 4.45507f, 4.45507f, 3f, 6.25f, 3f)
horizontalLineTo(17.75f)
curveTo(19.5449f, 3f, 21f, 4.45507f, 21f, 6.25f)
verticalLineTo(17.75f)
curveTo(21f, 19.5449f, 19.5449f, 21f, 17.75f, 21f)
horizontalLineTo(6.25f)
curveTo(4.45507f, 21f, 3f, 19.5449f, 3f, 17.75f)
verticalLineTo(6.25f)
close()
moveTo(6.25f, 4.5f)
curveTo(5.2835f, 4.5f, 4.5f, 5.2835f, 4.5f, 6.25f)
verticalLineTo(17.75f)
curveTo(4.5f, 18.7165f, 5.2835f, 19.5f, 6.25f, 19.5f)
horizontalLineTo(17.75f)
curveTo(18.7165f, 19.5f, 19.5f, 18.7165f, 19.5f, 17.75f)
verticalLineTo(6.25f)
curveTo(19.5f, 5.2835f, 18.7165f, 4.5f, 17.75f, 4.5f)
horizontalLineTo(6.25f)
close()
}
}.build()
}
val CheckboxChecked: ImageVector by lazy {
ImageVector.Builder(
name = "checkbox-checked",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = SolidColor(Color.Black)
) {
moveTo(6.25f, 3f)
curveTo(4.45507f, 3f, 3f, 4.45507f, 3f, 6.25f)
verticalLineTo(17.75f)
curveTo(3f, 19.5449f, 4.45507f, 21f, 6.25f, 21f)
horizontalLineTo(17.75f)
curveTo(19.5449f, 21f, 21f, 19.5449f, 21f, 17.75f)
verticalLineTo(6.25f)
curveTo(21f, 4.45507f, 19.5449f, 3f, 17.75f, 3f)
horizontalLineTo(6.25f)
close()
moveTo(4.5f, 6.25f)
curveTo(4.5f, 5.2835f, 5.2835f, 4.5f, 6.25f, 4.5f)
horizontalLineTo(17.75f)
curveTo(18.7165f, 4.5f, 19.5f, 5.2835f, 19.5f, 6.25f)
verticalLineTo(17.75f)
curveTo(19.5f, 18.7165f, 18.7165f, 19.5f, 17.75f, 19.5f)
horizontalLineTo(6.25f)
curveTo(5.2835f, 19.5f, 4.5f, 18.7165f, 4.5f, 17.75f)
verticalLineTo(6.25f)
close()
moveTo(17.28f, 9.28064f)
curveTo(17.5731f, 8.98791f, 17.5734f, 8.51304f, 17.2806f, 8.21998f)
curveTo(16.9879f, 7.92691f, 16.513f, 7.92664f, 16.22f, 8.21936f)
lineTo(9.99658f, 14.4356f)
lineTo(7.78084f, 12.2197f)
curveTo(7.48795f, 11.9268f, 7.01308f, 11.9268f, 6.72018f, 12.2196f)
curveTo(6.42727f, 12.5125f, 6.42726f, 12.9874f, 6.72014f, 13.2803f)
lineTo(9.46591f, 16.0262f)
curveTo(9.75868f, 16.319f, 10.2333f, 16.3192f, 10.5263f, 16.0266f)
lineTo(17.28f, 9.28064f)
close()
}
}.build()
}
@@ -16,6 +16,7 @@ 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.material.Divider
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -48,6 +49,7 @@ fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
val students by vm.studentList.entities
val focus by vm.focusIndex
val snacks = viewModel<SnackVM> { SnackVM() }
var deleting by remember { mutableStateOf(false) }
Surface(Modifier.weight(0.25f).fillMaxHeight(), tonalElevation = 7.dp) {
ListOrEmpty(students, { Text("No students yet.") }) { idx, it ->
@@ -69,42 +71,25 @@ fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
Surface(Modifier.padding(10.dp).fillMaxWidth(), tonalElevation = 10.dp, shadowElevation = 2.dp, shape = MaterialTheme.shapes.medium) {
Column(Modifier.padding(10.dp)) {
Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
Text(students[focus].name, style = MaterialTheme.typography.headlineSmall)
if(students[focus].contact.isNotBlank()) {
IconButton({ startEmail(listOf(students[focus].contact)) { snacks.show(it) } }) {
Icon(Mail, "Send email", Modifier.fillMaxHeight())
}
EditableText(
students[focus].name, style = MaterialTheme.typography.headlineSmall,
canSave = { it.isNotBlank() && (it == students[focus].name || !students.any { x -> x.name == it }) }
) {
vm.modStudent(students[focus], it, null, null)
}
Spacer(Modifier.width(10.dp))
IconButton(Delete, "Delete student", Modifier.align(Alignment.CenterVertically)) {
deleting = true
}
}
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 })
EditableText(students[focus].contact, Modifier.align(Alignment.CenterVertically), displayAdapt = { it.ifBlank { "No contact info." } }) {
vm.modStudent(students[focus], null, it, null)
}
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 })
IconButton(Mail, "Send email") {
startEmail(listOf(students[focus].contact)) { snacks.show(it) }
}
}
@@ -163,6 +148,9 @@ fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
}
items(grades ?: listOf()) {
Column(Modifier.padding(10.dp)) {
Divider()
}
Column(Modifier.padding(10.dp)) {
Row {
Text(it.assignment.name, Modifier.weight(0.66f))
@@ -193,6 +181,15 @@ fun StudentsView(vm: EditionVM) = Row(Modifier.fillMaxSize()) {
}
}
}
if(deleting) {
if(focus == -1) deleting = false
else {
ConfirmDeleteDialog("a student", { deleting = false }, { vm.rmStudent(students[focus]) }) {
Text(students[focus].name)
}
}
}
}
@Composable
@@ -1,200 +0,0 @@
package com.jaytux.grader.ui
//@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()
// }
// }
// }
// }
//}
@@ -7,6 +7,9 @@ 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.material.ContentAlpha
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.ProvideTextStyle
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -16,12 +19,15 @@ 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.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified
import androidx.compose.ui.window.*
import com.jaytux.grader.maxN
import com.jaytux.grader.viewmodel.Grade
@@ -275,3 +281,65 @@ fun GradePicker(grade: Grade, modifier: Modifier = Modifier, key: Any = Unit, on
}
}
}
@Composable
fun EditableText(
text: String, modifier: Modifier = Modifier, key: Any = Unit, style: TextStyle = LocalTextStyle.current,
label: (@Composable () -> Unit)? = null, displayAdapt: ((String) -> String)? = null,
canSave: (String) -> Boolean = { true },
onUpdate: (String) -> Unit
) {
var editing by remember(text, key) { mutableStateOf(false) }
if(editing) {
var current by remember(text, key) { mutableStateOf(text) }
val enableSave = canSave(current)
Row(modifier) {
OutlinedTextField(current, { current = it }, Modifier.align(Alignment.CenterVertically), label = label, textStyle = style, isError = !enableSave)
Spacer(Modifier.width(5.dp))
IconButton(Check, "Confirm edit", Modifier.align(Alignment.CenterVertically), enableSave, iconHeight = style.fontSize.toDp()) {
onUpdate(current)
editing = false
}
Spacer(Modifier.width(5.dp))
IconButton(Close, "Cancel edit", Modifier.align(Alignment.CenterVertically), iconHeight = style.fontSize.toDp()) {
editing = false
}
}
}
else {
Row(modifier) {
Text(displayAdapt?.let { it(text) } ?: text, Modifier.align(Alignment.CenterVertically), style = style)
Spacer(Modifier.width(5.dp))
IconButton(Edit, "Edit", iconHeight = style.fontSize.toDp()) {
editing = true
}
}
}
}
@Composable
fun IconButton(icon: ImageVector, contentDescription: String? = null, modifier: Modifier = Modifier, enabled: Boolean = true, iconHeight: Dp = Dp.Unspecified, onClick: () -> Unit) =
IconButton(onClick, modifier, enabled) {
if(iconHeight.isUnspecified) Icon(icon, contentDescription, modifier = Modifier.height(LocalTextStyle.current.fontSize.toDp()))
else Icon(icon, contentDescription, modifier = Modifier.height(iconHeight))
}
@Composable
fun EditableName(
name: String, taken: List<String>, onUpdate: (String) -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current, displayAdapt: ((String) -> String)? = null,
addBeforeDelete: (@Composable RowScope.() -> Unit)? = null
) = Row(modifier) {
ProvideTextStyle(style) {
EditableText(
name, style = style, canSave = { it.isNotBlank() && (it == name || it !in taken) }, onUpdate = onUpdate,
displayAdapt = displayAdapt
)
addBeforeDelete?.invoke(this@Row)
IconButton(Delete, "Delete", Modifier.align(Alignment.CenterVertically)) {
onDelete()
}
}
}
@@ -57,7 +57,7 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
val categoricGrades = RawDbState {
CategoricGrade.all().map {
UiGradeType.Categoric(it.options.toList(), it)
UiGradeType.Categoric(it.options.toList(), it.default, it)
}
}
@@ -66,24 +66,35 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
}
val studentGrades = RawDbFocusableState { st: Student ->
println("Loading grade summary for student ${st.name}")
val groupIds = st.groups.map { it.group.id }.toSet()
edition.assignments.map { asg ->
val (grade, memberOf, override) = when(asg.type) {
AssignmentType.GROUP -> {
val asGroup = asg.globalCriterion.feedbacks.find { it.asGroupFeedback?.id in groupIds }
val solo = asg.globalCriterion.feedbacks.find { it.forStudentsOverrideIfGroup.any { over -> over.student == st } }
val gr = (solo ?: asGroup)?.let { Grade.fromAssignment(asg.globalCriterion, it) }
gr to asGroup?.asGroupFeedback app (solo != null)
val (asGroup, raw) = asg.globalCriterion.feedbacks.find { it.asGroupFeedback?.id in groupIds }?.let { Grade.fromAssignment(asg.globalCriterion, it) to it } ?: (null to null)
val solo = run findSolo@{
for(groupLevel in asg.globalCriterion.feedbacks) {
for(override in groupLevel.forStudentsOverrideIfGroup) {
if(override.student.id == st.id) return@findSolo Grade.fromAssignment(asg.globalCriterion, override.feedback)
}
}
null
}
val gr = (solo ?: asGroup)//?.let { Grade.fromAssignment(asg.globalCriterion, it) }
println(" -> For group assignment ${asg.name}: $gr (solo override: $solo, group feedback: $asGroup)")
gr to raw?.asGroupFeedback app (solo != null)
}
AssignmentType.SOLO -> {
val eval = asg.globalCriterion.feedbacks.find { it.asSoloFeedback == st }
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
println(" -> For solo assignment ${asg.name}: $eval")
eval to null app false
}
AssignmentType.PEER_EVALUATION -> {
val eval = asg.globalCriterion.feedbacks.find { it.asPeerEvaluationFeedback?.id == st.id }
?.let { Grade.fromAssignment(asg.globalCriterion, it) }
println(" -> For peer evaluation assignment ${asg.name}: $eval")
eval to null app false
}
}
@@ -129,6 +140,43 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
val selectedTab = _selectedTab.immutable()
val focusIndex = _focusIndex.immutable()
init {
transaction {
var count0 = 0
StudentOverrideFeedback.all().forEach {
val group = it.group.name
val student = it.student.name
val assignment = it.feedback.criterion
val assName = assignment.assignment.name
val ogGrade = Grade.fromAssignment(assignment, it.overrides)
val updGrade = Grade.fromAssignment(assignment, it.feedback)
println("OVERRIDE: '$student' in '$group' for '$assName' ('${assignment.name}'): $ogGrade -> $updGrade")
count0++
}
println(" --> Direct lookup: $count0 overrides")
var count1 = 0
GroupAssignment.all().forEach { asg ->
val assignment = asg.base
val baseCrit = assignment.globalCriterion
val overrides = baseCrit.feedbacks.flatMap { it.forStudentsOverrideIfGroup }
if(overrides.isNotEmpty()) {
println("Assignment '${assignment.name}' has ${overrides.size} overrides:")
overrides.forEach {
val group = it.group.name
val student = it.student.name
val assName = assignment.name
val ogGrade = Grade.fromAssignment(baseCrit, it.overrides)
val updGrade = Grade.fromAssignment(baseCrit, it.feedback)
println(" - OVERRIDE: '$student' in '$group' for '$assName': $ogGrade -> $updGrade")
count1++
}
}
}
println(" --> GroupAssignment lookup: $count1 overrides")
}
}
fun switchTo(tab: Tab) {
_selectedTab.value = tab
_focusIndex.value = -1
@@ -397,20 +445,38 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
assignmentList.refresh()
}
fun mkScale(name: String, options: List<String>) {
fun mkScale(name: String, options: List<String>, default: Int) {
transaction {
val grade = CategoricGrade.new { this.name = name }
options.forEachIndexed { idx, opt ->
CategoricGradeOption.new {
val x = CategoricGradeOption.new {
this.grade = grade
this.option = opt
this.index = idx
}
if(idx == default) grade.default = x
}
}
categoricGrades.refresh()
}
fun modScale(grade: CategoricGrade, add: List<String>, default: Int) {
transaction {
val currMax = grade.options.maxOfOrNull { it.index } ?: 0
add.forEachIndexed { idx, opt ->
CategoricGradeOption.new {
this.grade = grade
this.option = opt
this.index = idx + currMax
}
}
val default = grade.options.first { it.index == default }
grade.default = default
}
categoricGrades.refresh()
}
fun mkNumericScale(name: String, max: Double) {
transaction {
NumericGrade.new {
@@ -441,8 +507,14 @@ class EditionVM(val edition: Edition, val course: Course) : ViewModel() {
fun rmAssignment(assignment: BaseAssignment) {
transaction {
assignment.delete()
assignment.criteria.forEach {
it.feedbacks.forEach { f ->
f.delete()
}
it.delete()
}
(assignment.asPeerEvaluation ?: assignment.asGroupAssignment ?: assignment.asSoloAssignment)?.delete()
assignment.delete()
}
unfocus()
assignmentList.refresh()
@@ -39,10 +39,15 @@ sealed class Grade {
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.CATEGORIC -> {
val option = fdb.gradeCategoric ?: asg.categoricGrade!!.default
Categoric(option, asg.categoricGrade!!.options.toList(), asg.categoricGrade!!)
}
GradeType.NUMERIC -> Numeric(fdb.gradeNumeric!!, asg.numericGrade!!)
GradeType.NUMERIC -> {
val grade = fdb.gradeNumeric ?: -1.0
Numeric(grade, asg.numericGrade!!)
}
GradeType.PERCENTAGE -> Percentage(fdb.gradeNumeric!!)
GradeType.NONE -> FreeText(fdb.gradeFreeText!!)
}
@@ -16,12 +16,12 @@ 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()
data class Categoric(val options: List<CategoricGradeOption>, val default: CategoricGradeOption?, val grade: CategoricGrade) : UiGradeType()
companion object {
context(trns: Transaction)
fun from(type: GradeType, categoric: CategoricGrade?, numeric: NumericGrade?) = when(type) {
GradeType.CATEGORIC -> Categoric(categoric!!.options.toList(), categoric)
GradeType.CATEGORIC -> Categoric(categoric!!.options.toList(), categoric.default, categoric)
GradeType.NUMERIC -> Numeric(numeric!!)
GradeType.PERCENTAGE -> Percentage
GradeType.NONE -> FreeText