diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/App.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/App.kt index ac8098a..add8528 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/App.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/App.kt @@ -34,7 +34,7 @@ fun App() { } } Surface(Modifier.fillMaxSize()) { - Box(Modifier.padding(10.dp)) { + Box { stack.last().content { stack += (it) } } } diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt index a04d557..81afb95 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt @@ -32,12 +32,14 @@ object Students : UUIDTable("students") { val note = text("note") } -object GroupStudents : CompositeIdTable("grpStudents") { +object GroupStudents : UUIDTable("grpStudents") { val groupId = reference("group_id", Groups.id) val studentId = reference("student_id", Students.id) 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") { diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt index 4f343b2..b751029 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt @@ -23,7 +23,7 @@ class Edition(id: EntityID) : Entity(id) { val groups by Group referrersOn Groups.editionId val soloStudents by Student via EditionStudents val soloAssignments by SoloAssignment referrersOn SoloAssignments.editionId -// val groupAssignments by GroupAssignment referrersOn GroupAssignments.editionId + val groupAssignments by GroupAssignment referrersOn GroupAssignments.editionId } class Group(id: EntityID) : Entity(id) { @@ -32,6 +32,15 @@ class Group(id: EntityID) : Entity(id) { var edition by Edition referencedOn Groups.editionId var name by Groups.name val students by Student via GroupStudents + val studentRoles by GroupMember referrersOn GroupStudents.groupId +} + +class GroupMember(id: EntityID) : Entity(id) { + companion object : EntityClass(GroupStudents) + + var student by Student referencedOn GroupStudents.studentId + var group by Group referencedOn GroupStudents.groupId + var role by GroupStudents.role } class Student(id: EntityID) : Entity(id) { diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt new file mode 100644 index 0000000..a0bf156 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt @@ -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 { + // + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt index 24f9da3..fc372c4 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt @@ -2,11 +2,8 @@ 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.Icon import androidx.compose.material.IconButton -import androidx.compose.material.OutlinedTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit @@ -18,11 +15,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.unit.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.UiRoute import com.jaytux.grader.data.Edition import com.jaytux.grader.viewmodel.CourseListState @@ -34,26 +27,14 @@ fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) { val data by state.courses.entities var showDialog by remember { mutableStateOf(false) } - if(data.isEmpty()) { - Box(Modifier.fillMaxSize()) { - Column(Modifier.align(Alignment.Center)) { - Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) - Button({ showDialog = true }, Modifier.align(Alignment.CenterHorizontally)) { - Text("Add a course") - } - } - } - } - else { - LazyColumn(Modifier.fillMaxSize()) { - items(data) { CourseWidget(state.getEditions(it), { state.delete(it) }, push) } - - item { - Button({ showDialog = true }, Modifier.fillMaxWidth()) { - Text("Add course") - } - } - } + ListOrEmpty( + data, + { Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) }, + { Text("Add a course") }, + { showDialog = true }, + addAfterLazy = false + ) { _, it -> + CourseWidget(state.getEditions(it), { state.delete(it) }, push) } if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) } diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt index a4f02aa..d7f717a 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt @@ -21,82 +21,126 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberDialogState 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 + +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 -fun EditionView(state: EditionState) = Row { +fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) { var isGroup by remember { mutableStateOf(false) } + var idx by remember { mutableStateOf(null) } val students by state.students.entities val groups by state.groups.entities val solo by state.solo.entities - val groupAs by state.groupAs//.entities + val groupAs by state.groupAs.entities - TabLayout( - listOf("Students", "Groups"), - if(isGroup) 1 else 0, - { isGroup = it == 1 }, - { Text(it) }, - Modifier.weight(0.25f) - ) { - Column(Modifier.fillMaxSize()) { - if(isGroup) { - Box(Modifier.weight(0.5f)) { - GroupsWidget(state.course, state.edition, groups, {}) { state.newGroup(it) } - } - Box(Modifier.weight(0.5f)) { GroupAssignmentsWidget(groupAs, {}) {} } - } - else { - Box(Modifier.weight(0.5f)) { - StudentsWidget(state.course, state.edition, students, {}) { name, note, contact, addToEdition -> - state.newStudent(name, note, contact, addToEdition) + val toggle = { i: Int, p: Panel -> + idx = if(idx?.p == p && idx?.i == i) null else Current(p, i) + } + + + Surface(Modifier.weight(0.25f), tonalElevation = 5.dp) { + TabLayout( + listOf("Students", "Groups"), + if (isGroup) 1 else 0, + { isGroup = it == 1 }, + { Text(it) } + ) { + Column(Modifier.fillMaxSize()) { + if (isGroup) { + Box(Modifier.weight(0.5f)) { + GroupsWidget( + state.course, + 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 EditionSideWidget( + course: Course, edition: Edition, header: String, hasNoX: String, addX: String, + data: List, 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 fun StudentsWidget( - course: Course, - edition: Edition, - students: List, - onSelect: (Int) -> Unit, + course: Course, edition: Edition, students: List, selected: Int?, onSelect: (Int) -> Unit, onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit -) = Column(Modifier.padding(10.dp)) { - Text("Student list", style = MaterialTheme.typography.headlineMedium) - var showDialog by remember { mutableStateOf(false) } - if(students.isEmpty()) { - Box(Modifier.fillMaxSize()) { - 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) +) = EditionSideWidget( + course, edition, "Student list", "students", "a student", students, selected, onSelect, + { Text(it.name, Modifier.padding(5.dp)) } +) { onExit -> + StudentDialog(course, edition, onExit, onAdd) } @Composable @@ -135,52 +179,33 @@ fun StudentDialog( @Composable fun GroupsWidget( - course: Course, - edition: Edition, - groups: List, - onSelect: (Int) -> Unit, + course: Course, edition: Edition, groups: List, selected: Int?, onSelect: (Int) -> Unit, onAdd: (name: String) -> Unit -) = Column(Modifier.padding(10.dp)) { - Text("Group list", style = MaterialTheme.typography.headlineMedium) - var showDialog by remember { mutableStateOf(false) } - - if(groups.isEmpty()) { - 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) } +) = EditionSideWidget( + course, edition, "Group list", "groups", "a group", groups, selected, onSelect, + { Text(it.name, Modifier.padding(5.dp)) } +) { onExit -> + AddStringDialog("Group name", groups.map { it.name }, onExit) { onAdd(it) } } @Composable -fun AssignmentsWidget(assignments: List, onSelect: (Int) -> Unit, onAdd: (name: String) -> Unit) { - // +fun AssignmentsWidget( + course: Course, edition: Edition, assignments: List, 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 -fun GroupAssignmentsWidget(assignments: List, onSelect: (Int) -> Unit, onAdd: (name: String) -> Unit) { - // +fun GroupAssignmentsWidget( + course: Course, edition: Edition, assignments: List, 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) } } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Views.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Views.kt new file mode 100644 index 0000000..cf631b7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Views.kt @@ -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 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, 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() + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt index 788521a..2aca0a5 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt @@ -1,28 +1,43 @@ 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.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment 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.dp import androidx.compose.ui.window.DialogWindow import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberDialogState +import com.jaytux.grader.data.Course +import com.jaytux.grader.data.Edition +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable -fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, onSave: () -> Unit) { +fun CancelSaveRow(canSave: Boolean, onCancel: () -> Unit, cancelText: String = "Cancel", saveText: String = "Save", onSave: () -> Unit) { Row { - Button({ onCancel() }, Modifier.weight(0.45f)) { Text("Cancel") } + Button({ onCancel() }, Modifier.weight(0.45f)) { Text(cancelText) } 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) } } } @@ -64,4 +79,179 @@ fun AddStringDialog(label: String, taken: List, onClose: () -> Unit, onS } } } +} + +@Composable +fun ListOrEmpty( + data: List, + 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 ListOrEmpty( + data: List, + 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) = 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 +) = Column(modifier) { + var suggestions by remember { mutableStateOf(listOf()) } + 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) }) + } + } + } + } } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt index 5170800..bdb091c 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt @@ -4,14 +4,13 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import com.jaytux.grader.data.* -import org.jetbrains.exposed.dao.Entity -import org.jetbrains.exposed.sql.Transaction -import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.transactions.transaction -import java.util.* -class RawDbState>(private val loader: (Transaction.() -> List)) { - private fun MutableState.immutable(): State = this@immutable +fun MutableState.immutable(): State = this + +class RawDbState(private val loader: (Transaction.() -> List)) { private val rawEntities by lazy { mutableStateOf(transaction { loader() }) @@ -57,8 +56,8 @@ class EditionState(val edition: Edition) { val course = transaction { edition.course } val students = RawDbState { edition.soloStudents.toList() } val groups = RawDbState { edition.groups.toList() } - val groupAs = mutableStateOf(listOf()) val solo = RawDbState { edition.soloAssignments.toList() } + val groupAs = RawDbState { edition.groupAssignments.toList() } fun newStudent(name: String, contact: String, note: String, addToEdition: Boolean) { transaction { @@ -78,4 +77,181 @@ class EditionState(val edition: Edition) { groups.refresh() } } -} \ No newline at end of file + + 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> + ) + + 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> { + 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 + } +} + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bebc6e6..1adde27 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,8 @@ kotlin = "2.1.0" kotlinx-coroutines = "1.10.1" exposed = "0.59.0" material3 = "1.7.3" +ui-android = "1.7.8" +foundation-layout-android = "1.7.8" [libraries] 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" } 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" } +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] composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }