UI fixes and additions
This commit is contained in:
parent
d0ddd54710
commit
b69b46afee
|
@ -1,5 +1,11 @@
|
|||
package com.jaytux.grader
|
||||
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.mohamedrejeb.richeditor.model.RichTextState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun String.maxN(n: Int): String {
|
||||
return if (this.length > n) {
|
||||
this.substring(0, n - 3) + "..."
|
||||
|
@ -7,3 +13,11 @@ fun String.maxN(n: Int): String {
|
|||
this
|
||||
}
|
||||
}
|
||||
|
||||
fun RichTextState.toClipboard(clip: ClipboardManager) {
|
||||
clip.setText(AnnotatedString(this.toMarkdown()))
|
||||
}
|
||||
|
||||
fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) {
|
||||
scope.launch { setMarkdown(clip.getText()?.text ?: "") }
|
||||
}
|
|
@ -13,6 +13,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jaytux.grader.viewmodel.GroupAssignmentState
|
||||
import com.jaytux.grader.viewmodel.SoloAssignmentState
|
||||
import com.mohamedrejeb.richeditor.model.rememberRichTextState
|
||||
import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor
|
||||
|
||||
|
@ -145,3 +146,93 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SoloAssignmentView(state: SoloAssignmentState) {
|
||||
val name by state.name
|
||||
val (course, edition) = state.editionCourse
|
||||
val task by state.task
|
||||
val deadline by state.deadline
|
||||
val suggestions by state.autofill.entities
|
||||
val grades by state.feedback.entities
|
||||
|
||||
var idx by remember(state) { mutableStateOf(0) }
|
||||
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
PaneHeader(name, "individual assignment", course, edition)
|
||||
Row {
|
||||
Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) {
|
||||
LazyColumn(Modifier.fillMaxHeight().padding(10.dp)) {
|
||||
item {
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().clickable { idx = 0 },
|
||||
tonalElevation = if (idx == 0) 50.dp else 0.dp,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Text("Assignment", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(grades.toList()) { i, (student, _) ->
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().clickable { idx = i + 1 },
|
||||
tonalElevation = if (idx == i + 1) 50.dp else 0.dp,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Text(student.name, Modifier.padding(5.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.weight(0.75f).padding(10.dp)) {
|
||||
if (idx == 0) {
|
||||
val updTask = rememberRichTextState()
|
||||
|
||||
LaunchedEffect(task) { updTask.setMarkdown(task) }
|
||||
|
||||
Row {
|
||||
DateTimePicker(deadline, { state.updateDeadline(it) })
|
||||
}
|
||||
RichTextStyleRow(state = updTask)
|
||||
OutlinedRichTextEditor(
|
||||
state = updTask,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
singleLine = false,
|
||||
minLines = 5,
|
||||
label = { Text("Task") }
|
||||
)
|
||||
CancelSaveRow(
|
||||
true,
|
||||
{ updTask.setMarkdown(task) },
|
||||
"Reset",
|
||||
"Update"
|
||||
) { state.updateTask(updTask.toMarkdown()) }
|
||||
} else {
|
||||
val (student, fg) = grades[idx - 1]
|
||||
var sGrade by remember { mutableStateOf(fg?.grade ?: "") }
|
||||
var sMsg by remember { mutableStateOf(TextFieldValue(fg?.feedback ?: "")) }
|
||||
Row {
|
||||
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
|
||||
OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f))
|
||||
Spacer(Modifier.weight(0.6f))
|
||||
Button(
|
||||
{ state.upsertFeedback(student, sMsg.text, sGrade) },
|
||||
Modifier.weight(0.2f).align(Alignment.CenterVertically),
|
||||
enabled = sGrade.isNotBlank() || sMsg.text.isNotBlank()
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
|
||||
AutocompleteLineField(
|
||||
sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") }
|
||||
) { filter ->
|
||||
suggestions.filter { x -> x.trim().startsWith(filter.trim()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ 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)) },
|
||||
|
@ -36,6 +37,7 @@ fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) {
|
|||
) { _, it ->
|
||||
CourseWidget(state.getEditions(it), { state.delete(it) }, push)
|
||||
}
|
||||
}
|
||||
|
||||
if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) }
|
||||
}
|
||||
|
|
|
@ -4,9 +4,6 @@ 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.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
|
@ -16,185 +13,264 @@ import androidx.compose.ui.unit.DpSize
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
import com.jaytux.grader.data.*
|
||||
import com.jaytux.grader.viewmodel.EditionState
|
||||
import com.jaytux.grader.viewmodel.GroupAssignmentState
|
||||
import com.jaytux.grader.viewmodel.GroupState
|
||||
import com.jaytux.grader.viewmodel.StudentState
|
||||
import com.jaytux.grader.viewmodel.*
|
||||
|
||||
enum class Panel { Student, Group, Solo, GroupAs }
|
||||
data class Current(val p: Panel, val i: Int)
|
||||
fun Current?.studentIdx() = this?.let { if(p == Panel.Student) i else null }
|
||||
fun Current?.groupIdx() = this?.let { if(p == Panel.Group) i else null }
|
||||
fun Current?.soloIdx() = this?.let { if(p == Panel.Solo) i else null }
|
||||
fun Current?.groupAsIdx() = this?.let { if(p == Panel.GroupAs) i else null }
|
||||
enum class OpenPanel(val tabName: String) {
|
||||
Student("Students"), Group("Groups"), Assignment("Assignments")
|
||||
}
|
||||
|
||||
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)) {
|
||||
var isGroup by remember { mutableStateOf(false) }
|
||||
var idx by remember { mutableStateOf<Current?>(null) }
|
||||
|
||||
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 available by state.availableStudents.entities
|
||||
|
||||
val toggle = { i: Int, p: Panel ->
|
||||
idx = if(idx?.p == p && idx?.i == i) null else Current(p, i)
|
||||
val mergedAssignments by remember(solo, groupAs) {
|
||||
mutableStateOf(Assignment.merge(groupAs, solo))
|
||||
}
|
||||
var selected by remember { mutableStateOf(-1) }
|
||||
var tab by remember { mutableStateOf(OpenPanel.Assignment) }
|
||||
|
||||
val navs = Navigators(
|
||||
student = { tab = OpenPanel.Student; selected = students.indexOfFirst { s -> s.id == it.id } },
|
||||
group = { tab = OpenPanel.Group; selected = groups.indexOfFirst { g -> g.id == it.id } },
|
||||
assignment = { tab = OpenPanel.Assignment; selected = mergedAssignments.indexOfFirst { a -> a.id() == it.id() } }
|
||||
)
|
||||
|
||||
Surface(Modifier.weight(0.25f), tonalElevation = 5.dp) {
|
||||
TabLayout(
|
||||
listOf("Students", "Groups"),
|
||||
if (isGroup) 1 else 0,
|
||||
{ isGroup = it == 1 },
|
||||
{ Text(it) }
|
||||
OpenPanel.entries,
|
||||
tab.ordinal,
|
||||
{ tab = OpenPanel.entries[it]; selected = -1 },
|
||||
{ Text(it.tabName) }
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (isGroup) {
|
||||
Box(Modifier.weight(0.5f)) {
|
||||
GroupsWidget(
|
||||
state.course,
|
||||
state.edition,
|
||||
groups,
|
||||
idx.groupIdx(),
|
||||
{ toggle(it, Panel.Group) },
|
||||
{ state.delete(it) },
|
||||
{ state.newGroup(it) }) { group, name ->
|
||||
state.setGroupName(group, name)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(0.5f)) {
|
||||
GroupAssignmentsWidget(
|
||||
state.course, state.edition, groupAs, idx.groupAsIdx(), { toggle(it, Panel.GroupAs) },
|
||||
{ state.delete(it) },
|
||||
{ state.newGroupAssignment(it) }) { assignment, title ->
|
||||
state.setGroupAssignmentTitle(
|
||||
assignment,
|
||||
title
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.weight(0.5f)) {
|
||||
StudentsWidget(
|
||||
state.course, state.edition, students, idx.studentIdx(), { toggle(it, Panel.Student) },
|
||||
available, { state.addToCourse(it) },
|
||||
{ state.delete(it) },
|
||||
) { name, note, contact, addToEdition ->
|
||||
state.newStudent(name, contact, note, addToEdition)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(0.5f)) {
|
||||
AssignmentsWidget(
|
||||
state.course,
|
||||
state.edition,
|
||||
solo,
|
||||
idx.soloIdx(),
|
||||
{ toggle(it, Panel.Solo) },
|
||||
{ state.delete(it) },
|
||||
{ state.newSoloAssignment(it) }) { assignment, title ->
|
||||
state.setSoloAssignmentTitle(assignment, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
when(tab) {
|
||||
OpenPanel.Student -> StudentPanel(
|
||||
course, edition, students, availableStudents, selected,
|
||||
{ selected = it },
|
||||
{ name, note, contact, add -> state.newStudent(name, contact, note, add) },
|
||||
{ students -> state.addToCourse(students) },
|
||||
{ s, name -> state.setStudentName(s, name) }
|
||||
) { s -> state.delete(s) }
|
||||
|
||||
OpenPanel.Group -> GroupPanel(
|
||||
course, edition, groups, selected,
|
||||
{ selected = it },
|
||||
{ name -> state.newGroup(name) },
|
||||
{ g, name -> state.setGroupName(g, name) }
|
||||
) { g -> state.delete(g) }
|
||||
|
||||
OpenPanel.Assignment -> AssignmentPanel(
|
||||
course, edition, mergedAssignments, selected,
|
||||
{ selected = it },
|
||||
{ type, name -> state.newAssignment(type, name) },
|
||||
{ a, name -> state.setAssignmentTitle(a, name) }
|
||||
) { a -> state.delete(a) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.weight(0.75f)) {
|
||||
idx?.let { i ->
|
||||
when(i.p) {
|
||||
Panel.Student -> StudentView(StudentState(students[i.i], state.edition))
|
||||
Panel.Group -> GroupView(GroupState(groups[i.i]))
|
||||
Panel.GroupAs -> GroupAssignmentView(GroupAssignmentState(groupAs[i.i]))
|
||||
else -> {}
|
||||
if(selected != -1) {
|
||||
when(tab) {
|
||||
OpenPanel.Student -> StudentView(StudentState(students[selected], edition), navs)
|
||||
OpenPanel.Group -> GroupView(GroupState(groups[selected]), navs)
|
||||
OpenPanel.Assignment -> {
|
||||
when(val a = mergedAssignments[selected]) {
|
||||
is Assignment.SAssignment -> SoloAssignmentView(SoloAssignmentState(a.assignment))
|
||||
is Assignment.GAssignment -> GroupAssignmentView(GroupAssignmentState(a.assignment))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> EditionSideWidget(
|
||||
course: Course, edition: Edition, header: String, hasNoX: String, addX: String,
|
||||
data: List<T>, selected: Int?, onSelect: (Int) -> Unit,
|
||||
singleWidget: @Composable (T) -> Unit,
|
||||
editDialog: @Composable ((current: T, onExit: () -> Unit) -> Unit)? = null,
|
||||
deleter: ((T) -> Unit)? = null,
|
||||
dialog: @Composable (onExit: () -> Unit) -> Unit
|
||||
fun StudentPanel(
|
||||
course: Course, edition: Edition, students: List<Student>, available: List<Student>,
|
||||
selected: Int, onSelect: (Int) -> Unit,
|
||||
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit,
|
||||
onImport: (List<Student>) -> Unit, onUpdate: (Student, String) -> Unit, onDelete: (Student) -> Unit
|
||||
) = Column(Modifier.padding(10.dp)) {
|
||||
Text(header, style = MaterialTheme.typography.headlineMedium)
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var current by remember { mutableStateOf<T?>(null) }
|
||||
var deleting by remember { mutableStateOf<T?>(null) }
|
||||
var deleting by remember { mutableStateOf(-1) }
|
||||
var editing by remember { mutableStateOf(-1) }
|
||||
|
||||
Text("Student list (${students.size})", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
ListOrEmpty(
|
||||
data,
|
||||
{ Text("Course ${course.name} (edition ${edition.name})\nhas no $hasNoX yet.", Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) },
|
||||
{ Text("Add $addX") },
|
||||
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 ->
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().clickable { onSelect(idx) },
|
||||
tonalElevation = if (selected == idx) 50.dp else 0.dp,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
SelectEditDeleteRow(
|
||||
selected == idx,
|
||||
{ onSelect(idx) }, { onSelect(-1) },
|
||||
{ editing = idx }, { deleting = idx }
|
||||
) {
|
||||
Row {
|
||||
Box(Modifier.weight(1f).align(Alignment.CenterVertically)) { singleWidget(it) }
|
||||
editDialog?.let { _ ->
|
||||
IconButton({ current = it }, Modifier.align(Alignment.CenterVertically)) {
|
||||
Icon(Icons.Default.Edit, "Edit")
|
||||
}
|
||||
}
|
||||
deleter?.let { d ->
|
||||
IconButton({ deleting = it }, Modifier.align(Alignment.CenterVertically)) {
|
||||
Icon(Icons.Default.Delete, "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(it.name, Modifier.padding(5.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if(showDialog) dialog { showDialog = false }
|
||||
editDialog?.let { d ->
|
||||
current?.let { c ->
|
||||
d(c) { current = null }
|
||||
}
|
||||
}
|
||||
deleter?.let { d ->
|
||||
deleting?.let { x ->
|
||||
DialogWindow(
|
||||
onCloseRequest = { deleting = null },
|
||||
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
Surface(Modifier.width(400.dp).height(300.dp), tonalElevation = 5.dp) {
|
||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
Text("You are about to delete $addX.", Modifier.padding(10.dp))
|
||||
singleWidget(x)
|
||||
CancelSaveRow(true, { deleting = null }, "Cancel", "Delete") {
|
||||
d(x)
|
||||
deleting = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(showDialog) {
|
||||
StudentDialog(course, edition, { showDialog = false }, available, onImport, onAdd)
|
||||
}
|
||||
else if(editing != -1) {
|
||||
AddStringDialog("Student name", students.map { it.name }, { editing = -1 }, students[editing].name) {
|
||||
onUpdate(students[editing], it)
|
||||
}
|
||||
}
|
||||
else if(deleting != -1) {
|
||||
ConfirmDeleteDialog(
|
||||
"a student",
|
||||
{ deleting = -1 },
|
||||
{ onDelete(students[deleting]) }
|
||||
) { Text(students[deleting].name) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StudentsWidget(
|
||||
course: Course, edition: Edition, students: List<Student>, selected: Int?, onSelect: (Int) -> Unit,
|
||||
availableStudents: List<Student>, onImport: (List<Student>) -> Unit, deleter: (Student) -> Unit,
|
||||
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit
|
||||
) = EditionSideWidget(
|
||||
course, edition, "Student list (${students.size})", "students", "a student", students, selected, onSelect,
|
||||
{ Text(it.name, Modifier.padding(5.dp)) },
|
||||
deleter = deleter
|
||||
) { onExit ->
|
||||
StudentDialog(course, edition, onExit, availableStudents, onImport, onAdd)
|
||||
fun GroupPanel(
|
||||
course: Course, edition: Edition, groups: List<Group>,
|
||||
selected: Int, onSelect: (Int) -> Unit,
|
||||
onAdd: (String) -> Unit, onUpdate: (Group, String) -> Unit, onDelete: (Group) -> Unit
|
||||
) = Column(Modifier.padding(10.dp)) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var deleting by remember { mutableStateOf(-1) }
|
||||
var editing by remember { mutableStateOf(-1) }
|
||||
|
||||
Text("Group list (${groups.size})", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
ListOrEmpty(
|
||||
groups,
|
||||
{ Text(
|
||||
"Course ${course.name} (edition ${edition.name})\nhas no groups yet.",
|
||||
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
|
||||
) },
|
||||
{ Text("Add a group") },
|
||||
{ showDialog = true }
|
||||
) { idx, it ->
|
||||
SelectEditDeleteRow(
|
||||
selected == idx,
|
||||
{ onSelect(idx) }, { onSelect(-1) },
|
||||
{ editing = idx }, { deleting = idx }
|
||||
) {
|
||||
Text(it.name, Modifier.padding(5.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if(showDialog) {
|
||||
AddStringDialog("Group name", groups.map{ it.name }, { showDialog = false }) { onAdd(it) }
|
||||
}
|
||||
else if(editing != -1) {
|
||||
AddStringDialog("Group name", groups.map { it.name }, { editing = -1 }, groups[editing].name) {
|
||||
onUpdate(groups[editing], it)
|
||||
}
|
||||
}
|
||||
else if(deleting != -1) {
|
||||
ConfirmDeleteDialog(
|
||||
"a group",
|
||||
{ deleting = -1 },
|
||||
{ onDelete(groups[deleting]) }
|
||||
) { Text(groups[deleting].name) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AssignmentPanel(
|
||||
course: Course, edition: Edition, assignments: List<Assignment>,
|
||||
selected: Int, onSelect: (Int) -> Unit,
|
||||
onAdd: (AssignmentType, String) -> Unit, onUpdate: (Assignment, String) -> Unit,
|
||||
onDelete: (Assignment) -> Unit
|
||||
) = Column(Modifier.padding(10.dp)) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var deleting by remember { mutableStateOf(-1) }
|
||||
var editing by remember { mutableStateOf(-1) }
|
||||
|
||||
val dialog: @Composable (String, List<String>, () -> Unit, String, (AssignmentType, String) -> Unit) -> Unit =
|
||||
{ label, taken, onClose, current, onSave ->
|
||||
DialogWindow(
|
||||
onCloseRequest = onClose,
|
||||
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
var name by remember(current) { mutableStateOf(current) }
|
||||
var tab by remember { mutableStateOf(AssignmentType.Solo) }
|
||||
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
TabLayout(
|
||||
AssignmentType.entries,
|
||||
tab.ordinal,
|
||||
{ tab = AssignmentType.entries[it] },
|
||||
{ Text(it.name) }
|
||||
) {
|
||||
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 ->
|
||||
SelectEditDeleteRow(
|
||||
selected == idx,
|
||||
{ onSelect(idx) }, { onSelect(-1) },
|
||||
{ editing = idx }, { deleting = idx }
|
||||
) {
|
||||
Text(it.name(), Modifier.padding(5.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if(showDialog) {
|
||||
dialog("Assignment name", assignments.map{ it.name() }, { showDialog = false }, "", onAdd)
|
||||
}
|
||||
else if(editing != -1) {
|
||||
AddStringDialog("Assignment name", assignments.map { it.name() }, { editing = -1 }, assignments[editing].name()) {
|
||||
onUpdate(assignments[editing], it)
|
||||
}
|
||||
}
|
||||
else if(deleting != -1) {
|
||||
ConfirmDeleteDialog(
|
||||
"an assignment",
|
||||
{ deleting = -1 },
|
||||
{ onDelete(assignments[deleting]) }
|
||||
) { Text(assignments[deleting].name()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
@ -294,42 +370,3 @@ fun StudentDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupsWidget(
|
||||
course: Course, edition: Edition, groups: List<Group>, selected: Int?, onSelect: (Int) -> Unit,
|
||||
deleter: (Group) -> Unit, onAdd: (name: String) -> Unit, onUpdate: (Group, String) -> Unit
|
||||
) = EditionSideWidget(
|
||||
course, edition, "Group list (${groups.size})", "groups", "a group", groups, selected, onSelect,
|
||||
{ Text(it.name, Modifier.padding(5.dp)) },
|
||||
{ current, onExit -> AddStringDialog("Group name", groups.map { it.name }, onExit, current.name) { onUpdate(current, it) } },
|
||||
deleter
|
||||
) { onExit ->
|
||||
AddStringDialog("Group name", groups.map { it.name }, onExit) { onAdd(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AssignmentsWidget(
|
||||
course: Course, edition: Edition, assignments: List<SoloAssignment>, selected: Int?,
|
||||
onSelect: (Int) -> Unit, deleter: (SoloAssignment) -> Unit, onAdd: (name: String) -> Unit, onUpdate: (SoloAssignment, String) -> Unit
|
||||
) = EditionSideWidget(
|
||||
course, edition, "Assignment list", "assignments", "an assignment", assignments, selected, onSelect,
|
||||
{ Text(it.name, Modifier.padding(5.dp)) },
|
||||
{ current, onExit -> AddStringDialog("Assignment title", assignments.map { it.name }, onExit, current.name) { onUpdate(current, it) } },
|
||||
deleter
|
||||
) { onExit ->
|
||||
AddStringDialog("Assignment title", assignments.map { it.name }, onExit) { onAdd(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupAssignmentsWidget(
|
||||
course: Course, edition: Edition, assignments: List<GroupAssignment>, selected: Int?,
|
||||
onSelect: (Int) -> Unit, deleter: (GroupAssignment) -> Unit, onAdd: (name: String) -> Unit, onUpdate: (GroupAssignment, String) -> Unit
|
||||
) = EditionSideWidget(
|
||||
course, edition, "Group assignment list", "group assignments", "an assignment", assignments, selected, onSelect,
|
||||
{ Text(it.name, Modifier.padding(5.dp)) },
|
||||
{ current, onExit -> AddStringDialog("Assignment title", assignments.map { it.name }, onExit, current.name) { onUpdate(current, it) } },
|
||||
deleter
|
||||
) { onExit ->
|
||||
AddStringDialog("Assignment title", assignments.map { it.name }, onExit) { onAdd(it) }
|
||||
}
|
|
@ -1,42 +1,46 @@
|
|||
package com.jaytux.grader.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
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
|
||||
import androidx.compose.ui.Alignment
|
||||
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.text.ParagraphStyle
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.mohamedrejeb.richeditor.annotation.ExperimentalRichTextApi
|
||||
import com.jaytux.grader.loadClipboard
|
||||
import com.jaytux.grader.toClipboard
|
||||
import com.mohamedrejeb.richeditor.model.RichTextState
|
||||
|
||||
@OptIn(ExperimentalRichTextApi::class)
|
||||
@Composable
|
||||
fun RichTextStyleRow(
|
||||
modifier: Modifier = Modifier,
|
||||
state: RichTextState,
|
||||
) {
|
||||
val clip = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Row(modifier.fillMaxWidth()) {
|
||||
LazyRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
item {
|
||||
RichTextStyleButton(
|
||||
|
@ -186,6 +190,14 @@ fun RichTextStyleRow(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton({ state.toClipboard(clip) }) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy markdown")
|
||||
}
|
||||
IconButton({ state.loadClipboard(clip, scope) }) {
|
||||
Icon(Icons.Default.ContentPaste, contentDescription = "Paste markdown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
@ -17,12 +17,14 @@ 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.Group
|
||||
import com.jaytux.grader.data.Student
|
||||
import com.jaytux.grader.maxN
|
||||
import com.jaytux.grader.viewmodel.GroupState
|
||||
import com.jaytux.grader.viewmodel.StudentState
|
||||
|
||||
@Composable
|
||||
fun StudentView(state: StudentState) {
|
||||
fun StudentView(state: StudentState, nav: Navigators) {
|
||||
val groups by state.groups.entities
|
||||
val courses by state.courseEditions.entities
|
||||
val groupGrades by state.groupGrades.entities
|
||||
|
@ -48,9 +50,9 @@ fun StudentView(state: StudentState) {
|
|||
Column(Modifier.weight(0.45f)) {
|
||||
Text("Groups", style = MaterialTheme.typography.headlineSmall)
|
||||
ListOrEmpty(groups, { Text("Not a member of any group") }) { _, it ->
|
||||
Row {
|
||||
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(
|
||||
|
@ -144,7 +146,7 @@ fun soloGradeWidget(sg: StudentState.LocalSoloGrade) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun GroupView(state: GroupState) {
|
||||
fun GroupView(state: GroupState, nav: Navigators) {
|
||||
val members by state.members.entities
|
||||
val available by state.availableStudents.entities
|
||||
val allRoles by state.roles.entities
|
||||
|
@ -159,7 +161,7 @@ fun GroupView(state: GroupState) {
|
|||
Text("Students", style = MaterialTheme.typography.headlineSmall)
|
||||
ListOrEmpty(members, { Text("No students in this group") }) { _, it ->
|
||||
val (student, role) = it
|
||||
Row {
|
||||
Row(Modifier.clickable { nav.student(student) }) {
|
||||
Text(
|
||||
"${student.name} (${role ?: "no role"})",
|
||||
Modifier.weight(0.75f).align(Alignment.CenterVertically),
|
||||
|
@ -177,7 +179,7 @@ fun GroupView(state: GroupState) {
|
|||
Column(Modifier.weight(0.5f)) {
|
||||
Text("Available students", style = MaterialTheme.typography.headlineSmall)
|
||||
ListOrEmpty(available, { Text("No students available") }) { _, it ->
|
||||
Row(Modifier.padding(5.dp)) {
|
||||
Row(Modifier.padding(5.dp).clickable { nav.student(it) }) {
|
||||
IconButton({ state.addStudent(it) }) {
|
||||
Icon(ChevronLeft, "Add student")
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
|
@ -31,8 +33,6 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.format.DateTimeFormat
|
||||
import kotlinx.datetime.format.byUnicodePattern
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
|
@ -74,7 +74,7 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
|
|||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
var name by remember(current) { mutableStateOf(current) }
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
androidx.compose.material.OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text(label) }, isError = name in taken)
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text(label) }, isError = name in taken)
|
||||
CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
|
||||
onSave(name)
|
||||
onClose()
|
||||
|
@ -84,6 +84,60 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDeleteDialog(
|
||||
deleteAWhat: String,
|
||||
onExit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
render: @Composable () -> Unit
|
||||
) = DialogWindow(
|
||||
onCloseRequest = onExit,
|
||||
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
|
||||
) {
|
||||
Surface(Modifier.width(400.dp).height(300.dp), tonalElevation = 5.dp) {
|
||||
Box(Modifier.fillMaxSize().padding(10.dp)) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
Text("You are about to delete $deleteAWhat.", Modifier.padding(10.dp))
|
||||
render()
|
||||
CancelSaveRow(true, onExit, "Cancel", "Delete") {
|
||||
onDelete()
|
||||
onExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> ListOrEmpty(
|
||||
data: List<T>,
|
||||
onEmpty: @Composable ColumnScope.() -> Unit,
|
||||
addOptions: @Composable ColumnScope.() -> Unit,
|
||||
addAfterLazy: Boolean = true,
|
||||
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
|
||||
) {
|
||||
if(data.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
onEmpty()
|
||||
addOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Column {
|
||||
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>,
|
||||
|
@ -92,41 +146,12 @@ fun <T> ListOrEmpty(
|
|||
onAdd: () -> Unit,
|
||||
addAfterLazy: Boolean = true,
|
||||
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
|
||||
) {
|
||||
if(data.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.align(Alignment.Center)) {
|
||||
emptyText()
|
||||
Button(onAdd, Modifier.align(Alignment.CenterHorizontally)) {
|
||||
addText()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Column {
|
||||
LazyColumn(Modifier.padding(5.dp).weight(1f)) {
|
||||
itemsIndexed(data) { idx, it ->
|
||||
item(idx, it)
|
||||
}
|
||||
|
||||
if(!addAfterLazy) {
|
||||
item {
|
||||
Button(onAdd, Modifier.fillMaxWidth()) {
|
||||
addText()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(addAfterLazy) {
|
||||
Button(onAdd, Modifier.fillMaxWidth()) {
|
||||
addText()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) = ListOrEmpty(
|
||||
data, emptyText,
|
||||
{ Button(onAdd, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) { addText() } },
|
||||
addAfterLazy,
|
||||
item
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun <T> ListOrEmpty(
|
||||
|
@ -349,3 +374,24 @@ fun ItalicAndNormal(italic: String, normal: String) = Row{
|
|||
Text(italic, fontStyle = FontStyle.Italic)
|
||||
Text(normal)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectEditDeleteRow(
|
||||
isSelected: Boolean,
|
||||
onSelect: () -> Unit, onDeselect: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) = Surface(
|
||||
Modifier.fillMaxWidth().clickable { if(isSelected) onDeselect() else onSelect() },
|
||||
tonalElevation = if (isSelected) 50.dp else 0.dp,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,6 +17,34 @@ import java.util.*
|
|||
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 { Solo, Group }
|
||||
sealed class Assignment {
|
||||
class GAssignment(val assignment: GroupAssignment) : Assignment() {
|
||||
override fun name(): String = assignment.name
|
||||
override fun id(): EntityID<UUID> = assignment.id
|
||||
}
|
||||
class SAssignment(val assignment: SoloAssignment) : Assignment() {
|
||||
override fun name(): String = assignment.name
|
||||
override fun id(): EntityID<UUID> = assignment.id
|
||||
}
|
||||
|
||||
abstract fun name(): String
|
||||
abstract fun id(): EntityID<UUID>
|
||||
|
||||
companion object {
|
||||
fun from(assignment: GroupAssignment) = GAssignment(assignment)
|
||||
fun from(assignment: SoloAssignment) = SAssignment(assignment)
|
||||
|
||||
fun merge(groups: List<GroupAssignment>, solos: List<SoloAssignment>): List<Assignment> {
|
||||
val g = groups.map { from(it) }
|
||||
val s = solos.map { from(it) }
|
||||
return (g + s).sortedBy {
|
||||
(it as? GAssignment)?.assignment?.name ?: (it as SAssignment).assignment.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RawDbState<T: Any>(private val loader: (Transaction.() -> List<T>)) {
|
||||
|
||||
private val rawEntities by lazy {
|
||||
|
@ -84,7 +112,12 @@ class EditionState(val edition: Edition) {
|
|||
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) {
|
||||
|
@ -92,7 +125,7 @@ class EditionState(val edition: Edition) {
|
|||
this[studentId] = it.id
|
||||
}
|
||||
}
|
||||
availableStudents.refresh();
|
||||
availableStudents.refresh()
|
||||
this.students.refresh()
|
||||
}
|
||||
|
||||
|
@ -139,6 +172,15 @@ class EditionState(val edition: Edition) {
|
|||
groupAs.refresh()
|
||||
}
|
||||
|
||||
fun newAssignment(type: AssignmentType, name: String) = when(type) {
|
||||
AssignmentType.Solo -> newSoloAssignment(name)
|
||||
AssignmentType.Group -> newGroupAssignment(name)
|
||||
}
|
||||
fun setAssignmentTitle(assignment: Assignment, title: String) = when(assignment) {
|
||||
is Assignment.GAssignment -> setGroupAssignmentTitle(assignment.assignment, title)
|
||||
is Assignment.SAssignment -> setSoloAssignmentTitle(assignment.assignment, title)
|
||||
}
|
||||
|
||||
fun delete(s: Student) {
|
||||
transaction {
|
||||
EditionStudents.deleteWhere { studentId eq s.id }
|
||||
|
@ -171,6 +213,10 @@ class EditionState(val edition: Edition) {
|
|||
}
|
||||
groupAs.refresh()
|
||||
}
|
||||
fun delete(assignment: Assignment) = when(assignment) {
|
||||
is Assignment.GAssignment -> delete(assignment.assignment)
|
||||
is Assignment.SAssignment -> delete(assignment.assignment)
|
||||
}
|
||||
}
|
||||
|
||||
class StudentState(val student: Student, edition: Edition) {
|
||||
|
@ -337,7 +383,7 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
|
|||
it[this.grade] = grd
|
||||
}
|
||||
}
|
||||
feedback.refresh()
|
||||
feedback.refresh(); autofill.refresh()
|
||||
}
|
||||
|
||||
fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String) {
|
||||
|
@ -350,6 +396,59 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
|
|||
it[this.grade] = grd
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
class SoloAssignmentState(val assignment: SoloAssignment) {
|
||||
data class LocalFeedback(val feedback: String, val grade: String)
|
||||
|
||||
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()
|
||||
val feedback = RawDbState { loadFeedback() }
|
||||
private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable()
|
||||
|
||||
val autofill = RawDbState {
|
||||
SoloFeedbacks.selectAll().where { SoloFeedbacks.soloAssignmentId eq assignment.id }.map {
|
||||
it[SoloFeedbacks.feedback].split('\n')
|
||||
}.flatten().distinct().sorted()
|
||||
}
|
||||
|
||||
private fun Transaction.loadFeedback(): List<Pair<Student, LocalFeedback?>> {
|
||||
val students = editionCourse.second.soloStudents
|
||||
val feedbacks = SoloFeedbacks.selectAll().where {
|
||||
SoloFeedbacks.soloAssignmentId eq assignment.id
|
||||
}.associate {
|
||||
it[SoloFeedbacks.studentId] to LocalFeedback(it[SoloFeedbacks.feedback], it[SoloFeedbacks.grade])
|
||||
}
|
||||
|
||||
return students.map { s -> s to feedbacks[s.id] }
|
||||
}
|
||||
|
||||
fun upsertFeedback(student: Student, msg: String, grd: String) {
|
||||
transaction {
|
||||
SoloFeedbacks.upsert {
|
||||
it[soloAssignmentId] = assignment.id
|
||||
it[studentId] = student.id
|
||||
it[this.feedback] = msg
|
||||
it[this.grade] = grd
|
||||
}
|
||||
}
|
||||
feedback.refresh()
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue