UI changes, slight database changes, autocompleting text field

This commit is contained in:
jay-tux 2025-02-23 19:54:40 +01:00
parent 2c4de6d8aa
commit c8b605353c
Signed by: jay-tux
GPG Key ID: 84302006B056926E
10 changed files with 788 additions and 146 deletions

View File

@ -34,7 +34,7 @@ fun App() {
} }
} }
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
Box(Modifier.padding(10.dp)) { Box {
stack.last().content { stack += (it) } stack.last().content { stack += (it) }
} }
} }

View File

@ -32,12 +32,14 @@ object Students : UUIDTable("students") {
val note = text("note") val note = text("note")
} }
object GroupStudents : CompositeIdTable("grpStudents") { object GroupStudents : UUIDTable("grpStudents") {
val groupId = reference("group_id", Groups.id) val groupId = reference("group_id", Groups.id)
val studentId = reference("student_id", Students.id) val studentId = reference("student_id", Students.id)
val role = varchar("role", 50).nullable() val role = varchar("role", 50).nullable()
override val primaryKey = PrimaryKey(groupId, studentId) init {
uniqueIndex(groupId, studentId) // can't figure out how to make this a composite key
}
} }
object EditionStudents : Table("editionStudents") { object EditionStudents : Table("editionStudents") {

View File

@ -23,7 +23,7 @@ class Edition(id: EntityID<UUID>) : Entity<UUID>(id) {
val groups by Group referrersOn Groups.editionId val groups by Group referrersOn Groups.editionId
val soloStudents by Student via EditionStudents val soloStudents by Student via EditionStudents
val soloAssignments by SoloAssignment referrersOn SoloAssignments.editionId val soloAssignments by SoloAssignment referrersOn SoloAssignments.editionId
// val groupAssignments by GroupAssignment referrersOn GroupAssignments.editionId val groupAssignments by GroupAssignment referrersOn GroupAssignments.editionId
} }
class Group(id: EntityID<UUID>) : Entity<UUID>(id) { class Group(id: EntityID<UUID>) : Entity<UUID>(id) {
@ -32,6 +32,15 @@ class Group(id: EntityID<UUID>) : Entity<UUID>(id) {
var edition by Edition referencedOn Groups.editionId var edition by Edition referencedOn Groups.editionId
var name by Groups.name var name by Groups.name
val students by Student via GroupStudents 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) { class Student(id: EntityID<UUID>) : Entity<UUID>(id) {

View File

@ -0,0 +1,115 @@
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.OutlinedTextField
import androidx.compose.material3.*
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.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.jaytux.grader.viewmodel.GroupAssignmentState
@Composable
fun GroupAssignmentView(state: GroupAssignmentState) {
val (course, edition) = state.editionCourse
val name by state.name
val task by state.task
val allFeedback by state.feedback.entities
var idx by remember { mutableStateOf(0) }
Column(Modifier.padding(10.dp)) {
PaneHeader(name, "group assignment", course, edition)
if(allFeedback.any { it.second.feedback == null }) {
Text("Groups in bold have no feedback yet.", fontStyle = FontStyle.Italic)
}
else {
Text("All groups have feedback.", fontStyle = FontStyle.Italic)
}
TabRow(idx) {
Tab(idx == 0, { idx = 0 }) { Text("Assignment") }
allFeedback.forEachIndexed { i, it ->
val (group, feedback) = it
Tab(idx == i + 1, { idx = i + 1 }) {
Text(group.name, fontWeight = feedback.feedback?.let { FontWeight.Normal } ?: FontWeight.Bold)
}
}
}
if(idx == 0) {
var updTask by remember { mutableStateOf(task) }
OutlinedTextField(updTask, { updTask = it }, Modifier.fillMaxWidth().weight(1f), singleLine = false, minLines = 5, label = { Text("Task") })
CancelSaveRow(updTask != task, { updTask = task }, "Reset", "Update") { state.updateTask(updTask) }
}
else {
val (group, feedback, individual) = allFeedback[idx - 1].second
groupFeedback(state, allFeedback[idx - 1].second)
}
}
}
@Composable
fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalGFeedback) {
val (group, feedback, individual) = fdbk
var grade by remember(fdbk) { mutableStateOf(feedback?.grade ?: "") }
var msg by remember(fdbk) { mutableStateOf(TextFieldValue(feedback?.feedback ?: "")) }
var idx by remember(fdbk) { mutableStateOf(0) }
val suggestions by state.autofill.entities
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("Group feedback", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
}
}
itemsIndexed(individual.toList()) { i, (student, details) ->
val (role, _) = details
Surface(
Modifier.fillMaxWidth().clickable { idx = i + 1 },
tonalElevation = if (idx == i + 1) 50.dp else 0.dp,
shape = MaterialTheme.shapes.medium
) {
Text("${student.name} (${role ?: "no role"})", Modifier.padding(5.dp))
}
}
}
}
Column(Modifier.weight(0.75f).padding(10.dp)) {
if(idx == 0) {
Row {
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f))
Spacer(Modifier.weight(0.6f))
Button({ state.upsertGroupFeedback(group, msg.text, grade) }, Modifier.weight(0.2f).align(Alignment.CenterVertically),
enabled = grade.isNotBlank() || msg.text.isNotBlank()) {
Text("Save")
}
}
AutocompleteLineField(
msg, { msg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") }
) { filter ->
suggestions.filter { x -> x.trim().startsWith(filter.trim()) }
}
}
else {
//
}
}
}
}

View File

@ -2,11 +2,8 @@ package com.jaytux.grader.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@ -18,11 +15,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp 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.UiRoute import com.jaytux.grader.UiRoute
import com.jaytux.grader.data.Edition import com.jaytux.grader.data.Edition
import com.jaytux.grader.viewmodel.CourseListState import com.jaytux.grader.viewmodel.CourseListState
@ -34,26 +27,14 @@ fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) {
val data by state.courses.entities val data by state.courses.entities
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
if(data.isEmpty()) { ListOrEmpty(
Box(Modifier.fillMaxSize()) { data,
Column(Modifier.align(Alignment.Center)) { { Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) },
Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) { Text("Add a course") },
Button({ showDialog = true }, Modifier.align(Alignment.CenterHorizontally)) { { showDialog = true },
Text("Add a course") addAfterLazy = false
} ) { _, it ->
} CourseWidget(state.getEditions(it), { state.delete(it) }, push)
}
}
else {
LazyColumn(Modifier.fillMaxSize()) {
items(data) { CourseWidget(state.getEditions(it), { state.delete(it) }, push) }
item {
Button({ showDialog = true }, Modifier.fillMaxWidth()) {
Text("Add course")
}
}
}
} }
if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) } if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) }

View File

@ -21,82 +21,126 @@ import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.rememberDialogState
import com.jaytux.grader.data.* import com.jaytux.grader.data.*
import com.jaytux.grader.viewmodel.EditionState import com.jaytux.grader.viewmodel.EditionState
import com.jaytux.grader.viewmodel.GroupAssignmentState
import com.jaytux.grader.viewmodel.GroupState
import com.jaytux.grader.viewmodel.StudentState
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 }
@Composable @Composable
fun EditionView(state: EditionState) = Row { fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) {
var isGroup by remember { mutableStateOf(false) } var isGroup by remember { mutableStateOf(false) }
var idx by remember { mutableStateOf<Current?>(null) }
val students by state.students.entities val students by state.students.entities
val groups by state.groups.entities val groups by state.groups.entities
val solo by state.solo.entities val solo by state.solo.entities
val groupAs by state.groupAs//.entities val groupAs by state.groupAs.entities
TabLayout( val toggle = { i: Int, p: Panel ->
listOf("Students", "Groups"), idx = if(idx?.p == p && idx?.i == i) null else Current(p, i)
if(isGroup) 1 else 0, }
{ isGroup = it == 1 },
{ Text(it) },
Modifier.weight(0.25f) Surface(Modifier.weight(0.25f), tonalElevation = 5.dp) {
) { TabLayout(
Column(Modifier.fillMaxSize()) { listOf("Students", "Groups"),
if(isGroup) { if (isGroup) 1 else 0,
Box(Modifier.weight(0.5f)) { { isGroup = it == 1 },
GroupsWidget(state.course, state.edition, groups, {}) { state.newGroup(it) } { Text(it) }
} ) {
Box(Modifier.weight(0.5f)) { GroupAssignmentsWidget(groupAs, {}) {} } Column(Modifier.fillMaxSize()) {
} if (isGroup) {
else { Box(Modifier.weight(0.5f)) {
Box(Modifier.weight(0.5f)) { GroupsWidget(
StudentsWidget(state.course, state.edition, students, {}) { name, note, contact, addToEdition -> state.course,
state.newStudent(name, note, contact, addToEdition) state.edition,
groups,
idx.groupIdx(),
{ toggle(it, Panel.Group) }) {
state.newGroup(it)
}
}
Box(Modifier.weight(0.5f)) {
GroupAssignmentsWidget(
state.course, state.edition, groupAs, idx.groupAsIdx(), { toggle(it, Panel.GroupAs) }
) {
state.newGroupAssignment(it)
}
}
} else {
Box(Modifier.weight(0.5f)) {
StudentsWidget(
state.course, state.edition, students, idx.studentIdx(), { toggle(it, Panel.Student) }
) { 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.newSoloAssignment(it)
}
} }
} }
Box(Modifier.weight(0.5f)) { AssignmentsWidget(solo, {}) {} }
} }
} }
} }
Box(Modifier.weight(0.75f)) {} 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 -> {}
}
}
}
}
@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,
dialog: @Composable (onExit: () -> Unit) -> Unit
) = Column(Modifier.padding(10.dp)) {
Text(header, style = MaterialTheme.typography.headlineMedium)
var showDialog by remember { mutableStateOf(false) }
ListOrEmpty(
data,
{ Text("Course ${course.name} (edition ${edition.name})\nhas no $hasNoX yet.", Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) },
{ Text("Add $addX") },
{ showDialog = true }
) { idx, it ->
Surface(
Modifier.fillMaxWidth().clickable { onSelect(idx) },
tonalElevation = if (selected == idx) 50.dp else 0.dp,
shape = MaterialTheme.shapes.medium
) {
singleWidget(it)
}
}
if(showDialog) dialog { showDialog = false }
} }
@Composable @Composable
fun StudentsWidget( fun StudentsWidget(
course: Course, course: Course, edition: Edition, students: List<Student>, selected: Int?, onSelect: (Int) -> Unit,
edition: Edition,
students: List<Student>,
onSelect: (Int) -> Unit,
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit
) = Column(Modifier.padding(10.dp)) { ) = EditionSideWidget(
Text("Student list", style = MaterialTheme.typography.headlineMedium) course, edition, "Student list", "students", "a student", students, selected, onSelect,
var showDialog by remember { mutableStateOf(false) } { Text(it.name, Modifier.padding(5.dp)) }
if(students.isEmpty()) { ) { onExit ->
Box(Modifier.fillMaxSize()) { StudentDialog(course, edition, onExit, onAdd)
Column(Modifier.align(Alignment.Center)) {
Text(
"Course ${course.name} (edition ${edition.name})\nhas no students yet.",
Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
Button({ showDialog = true }, Modifier.align(Alignment.CenterHorizontally)) {
Text("Add a student")
}
}
}
}
else {
LazyColumn(Modifier.padding(5.dp).weight(1f)) {
itemsIndexed(students) { idx, it ->
Surface(Modifier.fillMaxWidth().clickable { onSelect(idx) }) {
Text(it.name, Modifier.padding(5.dp))
}
}
}
Button({ showDialog = true }, Modifier.fillMaxWidth()) {
Text("Add a student")
}
}
if(showDialog) StudentDialog(course, edition, { showDialog = false }, onAdd)
} }
@Composable @Composable
@ -135,52 +179,33 @@ fun StudentDialog(
@Composable @Composable
fun GroupsWidget( fun GroupsWidget(
course: Course, course: Course, edition: Edition, groups: List<Group>, selected: Int?, onSelect: (Int) -> Unit,
edition: Edition,
groups: List<Group>,
onSelect: (Int) -> Unit,
onAdd: (name: String) -> Unit onAdd: (name: String) -> Unit
) = Column(Modifier.padding(10.dp)) { ) = EditionSideWidget(
Text("Group list", style = MaterialTheme.typography.headlineMedium) course, edition, "Group list", "groups", "a group", groups, selected, onSelect,
var showDialog by remember { mutableStateOf(false) } { Text(it.name, Modifier.padding(5.dp)) }
) { onExit ->
if(groups.isEmpty()) { AddStringDialog("Group name", groups.map { it.name }, onExit) { onAdd(it) }
Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center)) {
Text(
"Course ${course.name} (edition ${edition.name})\nhas no groups yet.",
Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
Button({ showDialog = true }, Modifier.align(Alignment.CenterHorizontally)) {
Text("Add a group")
}
}
}
}
else {
LazyColumn(Modifier.padding(5.dp).weight(1f)) {
itemsIndexed(groups) { idx, it ->
Surface(Modifier.fillMaxWidth().clickable { onSelect(idx) }) {
Text(it.name, Modifier.padding(5.dp))
}
}
}
Button({ showDialog = true }, Modifier.fillMaxWidth()) {
Text("Add a group")
}
}
if(showDialog) AddStringDialog("Group name", groups.map { it.name }, { showDialog = false }) { onAdd(it) }
} }
@Composable @Composable
fun AssignmentsWidget(assignments: List<SoloAssignment>, onSelect: (Int) -> Unit, onAdd: (name: String) -> Unit) { fun AssignmentsWidget(
// course: Course, edition: Edition, assignments: List<SoloAssignment>, selected: Int?,
onSelect: (Int) -> Unit, onAdd: (name: String) -> Unit
) = EditionSideWidget(
course, edition, "Assignment list", "assignments", "an assignment", assignments, selected, onSelect,
{ Text(it.name, Modifier.padding(5.dp)) }
) { onExit ->
AddStringDialog("Assignment title", assignments.map { it.name }, onExit) { onAdd(it) }
} }
@Composable @Composable
fun GroupAssignmentsWidget(assignments: List<GroupAssignment>, onSelect: (Int) -> Unit, onAdd: (name: String) -> Unit) { fun GroupAssignmentsWidget(
// course: Course, edition: Edition, assignments: List<GroupAssignment>, selected: Int?,
onSelect: (Int) -> Unit, onAdd: (name: String) -> Unit
) = EditionSideWidget(
course, edition, "Group assignment list", "group assignments", "an assignment", assignments, selected, onSelect,
{ Text(it.name, Modifier.padding(5.dp)) }
) { onExit ->
AddStringDialog("Assignment title", assignments.map { it.name }, onExit) { onAdd(it) }
} }

View File

@ -0,0 +1,140 @@
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.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.viewmodel.GroupState
import com.jaytux.grader.viewmodel.StudentState
@Composable
fun StudentView(state: StudentState) {
val groups by state.groups.entities
val courses by state.courseEditions.entities
Column(Modifier.padding(10.dp)) {
PaneHeader(state.student.name, "student", state.editionCourse)
Row {
Column(Modifier.padding(10.dp).weight(0.45f)) {
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)
}
Box(Modifier.weight(0.55f)) {}
}
Row {
Column(Modifier.weight(0.5f)) {
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.5f)) {
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
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)
}
}
}
}
}
}
@Composable
fun GroupView(state: GroupState) {
val members by state.members.entities
val available by state.availableStudents.entities
val allRoles by state.roles.entities
val (course, edition) = state.course
var pickRole: Pair<String?, (String?) -> Unit>? by remember { mutableStateOf(null) }
Column(Modifier.padding(10.dp)) {
PaneHeader(state.group.name, "group", course, edition)
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 {
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)) {
IconButton({ state.addStudent(it) }) {
Icon(ChevronLeft, "Add student")
}
Text(it.name, Modifier.weight(0.75f).align(Alignment.CenterVertically), style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
pickRole?.let {
val (curr, onPick) = it
RolePicker(allRoles, curr, { pickRole = null }, { role -> onPick(role); pickRole = null })
}
}
@Composable
fun RolePicker(used: List<String>, curr: String?, onClose: () -> Unit, onSave: (String?) -> Unit) = DialogWindow(
onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(400.dp, 500.dp), position = WindowPosition(Alignment.Center))
) {
Surface(Modifier.fillMaxSize().padding(10.dp)) {
Box(Modifier.fillMaxSize()) {
var role by remember { mutableStateOf(curr ?: "") }
Column {
Text("Used roles:")
LazyColumn(Modifier.weight(1.0f).padding(5.dp)) {
items(used) {
Surface(Modifier.fillMaxWidth().clickable { role = it }, tonalElevation = 5.dp) {
Text(it, Modifier.padding(5.dp))
}
Spacer(Modifier.height(5.dp))
}
}
OutlinedTextField(role, { role = it }, Modifier.fillMaxWidth())
CancelSaveRow(true, onClose) {
onSave(role.ifBlank { null })
onClose()
}
}
}
}
}

View File

@ -1,28 +1,43 @@
package com.jaytux.grader.ui package com.jaytux.grader.ui
import androidx.compose.foundation.background import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.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.text.TextRange
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogWindow import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.rememberDialogState
import com.jaytux.grader.data.Course
import com.jaytux.grader.data.Edition
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable @Composable
fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, onSave: () -> Unit) { fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "Cancel", saveText: String = "Save", onSave: () -> Unit) {
Row { Row {
Button({ onCancel() }, Modifier.weight(0.45f)) { Text("Cancel") } Button({ onCancel() }, Modifier.weight(0.45f)) { Text(cancelText) }
Spacer(Modifier.weight(0.1f)) Spacer(Modifier.weight(0.1f))
Button({ onSave() }, Modifier.weight(0.45f), enabled = canSave) { Text("Save") } Button({ onSave() }, Modifier.weight(0.45f), enabled = canSave) { Text(saveText) }
} }
} }
@ -65,3 +80,178 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, onS
} }
} }
} }
@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
) {
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()
}
}
}
}
}
@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(
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)))
}
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) })
}
}
}
}
}

View File

@ -4,14 +4,13 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import com.jaytux.grader.data.* import com.jaytux.grader.data.*
import org.jetbrains.exposed.dao.Entity import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.Transaction import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import java.util.*
class RawDbState<T: Entity<UUID>>(private val loader: (Transaction.() -> List<T>)) { fun <T> MutableState<T>.immutable(): State<T> = this
private fun <T> MutableState<T>.immutable(): State<T> = this@immutable
class RawDbState<T: Any>(private val loader: (Transaction.() -> List<T>)) {
private val rawEntities by lazy { private val rawEntities by lazy {
mutableStateOf(transaction { loader() }) mutableStateOf(transaction { loader() })
@ -57,8 +56,8 @@ class EditionState(val edition: Edition) {
val course = transaction { edition.course } val course = transaction { edition.course }
val students = RawDbState { edition.soloStudents.toList() } val students = RawDbState { edition.soloStudents.toList() }
val groups = RawDbState { edition.groups.toList() } val groups = RawDbState { edition.groups.toList() }
val groupAs = mutableStateOf(listOf<GroupAssignment>())
val solo = RawDbState { edition.soloAssignments.toList() } val solo = RawDbState { edition.soloAssignments.toList() }
val groupAs = RawDbState { edition.groupAssignments.toList() }
fun newStudent(name: String, contact: String, note: String, addToEdition: Boolean) { fun newStudent(name: String, contact: String, note: String, addToEdition: Boolean) {
transaction { transaction {
@ -78,4 +77,181 @@ class EditionState(val edition: Edition) {
groups.refresh() groups.refresh()
} }
} }
fun newSoloAssignment(name: String) {
transaction {
SoloAssignment.new { this.name = name; this.edition = this@EditionState.edition; assignment = "" }
solo.refresh()
}
}
fun newGroupAssignment(name: String) {
transaction {
GroupAssignment.new { this.name = name; this.edition = this@EditionState.edition; assignment = "" }
groupAs.refresh()
}
}
} }
class StudentState(val student: Student, edition: Edition) {
val editionCourse = transaction { edition.course to edition }
val groups = RawDbState { student.groups.map { it to (it.edition.course.name to it.edition.name) }.toList() }
val courseEditions = RawDbState { student.courses.map{ it to it.course }.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 }.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 })
}.toList() }
val course = transaction { group.edition.course to group.edition }
val roles = RawDbState {
GroupStudents.select(GroupStudents.role).where{ GroupStudents.role.isNotNull() }
.withDistinct(true).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 LocalFeedback(val feedback: String, val grade: String)
data class LocalGFeedback(
val group: Group,
val feedback: LocalFeedback?,
val individuals: Map<Student, Pair<String?, 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()
val feedback = RawDbState { loadFeedback() }
val autofill = RawDbState {
val forGroups = GroupFeedbacks.selectAll().where { GroupFeedbacks.groupAssignmentId eq assignment.id }.flatMap {
it[GroupFeedbacks.feedback].split('\n')
}.toList()
val forIndividuals = IndividualFeedbacks.selectAll().where { IndividualFeedbacks.groupAssignmentId eq assignment.id }.flatMap {
it[IndividualFeedbacks.feedback].split('\n')
}.toList()
(forGroups + forIndividuals).distinct()
}
private fun Transaction.loadFeedback(): List<Pair<Group, LocalGFeedback>> {
val individuals = IndividualFeedbacks.selectAll().where {
IndividualFeedbacks.groupAssignmentId eq assignment.id
}.map {
it[IndividualFeedbacks.studentId] to LocalFeedback(it[IndividualFeedbacks.feedback], it[IndividualFeedbacks.grade])
}.associate { it }
val groupFeedbacks = GroupFeedbacks.selectAll().where {
GroupFeedbacks.groupAssignmentId eq assignment.id
}.map {
it[GroupFeedbacks.groupId] to (it[GroupFeedbacks.feedback] to it[GroupFeedbacks.grade])
}.associate { it }
val groups = Group.find {
(Groups.editionId eq assignment.edition.id)
}.map { group ->
val students = group.studentRoles.associate { sR ->
val student = sR.student
val role = sR.role
val feedback = individuals[student.id]
student to (role to feedback)
}
groupFeedbacks[group.id]?.let { (f, g) ->
group to LocalGFeedback(group, LocalFeedback(f, g), students)
} ?: (group to LocalGFeedback(group, null, students))
}
return groups
}
fun upsertGroupFeedback(group: Group, msg: String, grd: String) {
transaction {
GroupFeedbacks.upsert {
it[groupAssignmentId] = assignment.id
it[groupId] = group.id
it[this.feedback] = msg
it[this.grade] = grd
}
}
feedback.refresh()
}
fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String) {
transaction {
IndividualFeedbacks.upsert {
it[groupAssignmentId] = assignment.id
it[groupId] = group.id
it[studentId] = student.id
it[this.feedback] = msg
it[this.grade] = grd
}
}
feedback.refresh()
}
fun updateTask(t: String) {
transaction {
assignment.assignment = t
}
_task.value = t
}
}

View File

@ -6,6 +6,8 @@ kotlin = "2.1.0"
kotlinx-coroutines = "1.10.1" kotlinx-coroutines = "1.10.1"
exposed = "0.59.0" exposed = "0.59.0"
material3 = "1.7.3" material3 = "1.7.3"
ui-android = "1.7.8"
foundation-layout-android = "1.7.8"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@ -22,6 +24,8 @@ sqlite = { group = "org.xerial", name = "sqlite-jdbc", version = "3.34.0" }
sl4j = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.12" } sl4j = { group = "org.slf4j", name = "slf4j-simple", version = "2.0.12" }
material3-core = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "material3" } material3-core = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "material3" }
material3-desktop = { group = "org.jetbrains.compose.material3", name = "material3-desktop", version.ref = "material3" } material3-desktop = { group = "org.jetbrains.compose.material3", name = "material3-desktop", version.ref = "material3" }
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" }
[plugins] [plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }