diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/Util.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/Util.kt index 3d4f8a5..030f877 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/Util.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/Util.kt @@ -1,9 +1,23 @@ 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) + "..." } else { this } +} + +fun RichTextState.toClipboard(clip: ClipboardManager) { + clip.setText(AnnotatedString(this.toMarkdown())) +} + +fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) { + scope.launch { setMarkdown(clip.getText()?.text ?: "") } } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt index 6e29dde..abea207 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt @@ -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 @@ -144,4 +145,94 @@ 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()) } + } + } + } + } + } } \ 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 a0c5190..7cc9759 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt @@ -27,14 +27,16 @@ fun CoursesView(state: CourseListState, push: (UiRoute) -> Unit) { val data by state.courses.entities var showDialog by remember { mutableStateOf(false) } - 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) + Box(Modifier.padding(15.dp)) { + ListOrEmpty( + data, + { Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) }, + { Text("Add a course") }, + { showDialog = true }, + addAfterLazy = false + ) { _, it -> + CourseWidget(state.getEditions(it), { state.delete(it) }, push) + } } if(showDialog) AddStringDialog("Course name", data.map { it.name }, { showDialog = false }) { state.new(it) } 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 29ac010..ffc0177 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt @@ -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(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 EditionSideWidget( - course: Course, edition: Edition, header: String, hasNoX: String, addX: String, - data: List, 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, available: List, + selected: Int, onSelect: (Int) -> Unit, + onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit, + onImport: (List) -> 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(null) } - var deleting by remember { mutableStateOf(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 } + 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) } } - deleter?.let { d -> - deleting?.let { x -> + else if(deleting != -1) { + ConfirmDeleteDialog( + "a student", + { deleting = -1 }, + { onDelete(students[deleting]) } + ) { Text(students[deleting].name) } + } +} + +@Composable +fun GroupPanel( + course: Course, edition: Edition, groups: List, + 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, + 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, () -> Unit, String, (AssignmentType, String) -> Unit) -> Unit = + { label, taken, onClose, current, onSave -> DialogWindow( - onCloseRequest = { deleting = null }, + onCloseRequest = onClose, 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 + 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() + } } } } } } } - } -} -@Composable -fun StudentsWidget( - course: Course, edition: Edition, students: List, selected: Int?, onSelect: (Int) -> Unit, - availableStudents: List, onImport: (List) -> 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) + 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 @@ -293,43 +369,4 @@ fun StudentDialog( } } } -} - -@Composable -fun GroupsWidget( - course: Course, edition: Edition, groups: List, 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, 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, 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) } } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/RichText.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/RichText.kt index 6c59e62..3be15cf 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/RichText.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/RichText.kt @@ -1,189 +1,201 @@ 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, ) { - LazyRow( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - ) { - item { - RichTextStyleButton( - onClick = { - state.toggleSpanStyle( - SpanStyle( - fontWeight = FontWeight.Bold + val clip = LocalClipboardManager.current + val scope = rememberCoroutineScope() + + Row(modifier.fillMaxWidth()) { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + item { + RichTextStyleButton( + onClick = { + state.toggleSpanStyle( + SpanStyle( + fontWeight = FontWeight.Bold + ) ) - ) - }, - isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold, - icon = Icons.Outlined.FormatBold - ) - } + }, + isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold, + icon = Icons.Outlined.FormatBold + ) + } - item { - RichTextStyleButton( - onClick = { - state.toggleSpanStyle( - SpanStyle( - fontStyle = FontStyle.Italic + item { + RichTextStyleButton( + onClick = { + state.toggleSpanStyle( + SpanStyle( + fontStyle = FontStyle.Italic + ) ) - ) - }, - isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic, - icon = Icons.Outlined.FormatItalic - ) - } + }, + isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic, + icon = Icons.Outlined.FormatItalic + ) + } - item { - RichTextStyleButton( - onClick = { - state.toggleSpanStyle( - SpanStyle( - textDecoration = TextDecoration.Underline + item { + RichTextStyleButton( + onClick = { + state.toggleSpanStyle( + SpanStyle( + textDecoration = TextDecoration.Underline + ) ) - ) - }, - isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true, - icon = Icons.Outlined.FormatUnderlined - ) - } + }, + isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true, + icon = Icons.Outlined.FormatUnderlined + ) + } - item { - RichTextStyleButton( - onClick = { - state.toggleSpanStyle( - SpanStyle( - textDecoration = TextDecoration.LineThrough + item { + RichTextStyleButton( + onClick = { + state.toggleSpanStyle( + SpanStyle( + textDecoration = TextDecoration.LineThrough + ) ) - ) - }, - isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true, - icon = Icons.Outlined.FormatStrikethrough - ) - } + }, + isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true, + icon = Icons.Outlined.FormatStrikethrough + ) + } - item { - RichTextStyleButton( - onClick = { - state.toggleSpanStyle( - SpanStyle( - fontSize = 28.sp + item { + RichTextStyleButton( + onClick = { + state.toggleSpanStyle( + SpanStyle( + fontSize = 28.sp + ) ) - ) - }, - isSelected = state.currentSpanStyle.fontSize == 28.sp, - icon = Icons.Outlined.FormatSize - ) - } + }, + isSelected = state.currentSpanStyle.fontSize == 28.sp, + icon = Icons.Outlined.FormatSize + ) + } - item { - RichTextStyleButton( - onClick = { - state.toggleSpanStyle( - SpanStyle( - color = Color.Red + item { + RichTextStyleButton( + onClick = { + state.toggleSpanStyle( + SpanStyle( + color = Color.Red + ) ) - ) - }, - isSelected = state.currentSpanStyle.color == Color.Red, - icon = Icons.Filled.Circle, - tint = Color.Red - ) - } + }, + isSelected = state.currentSpanStyle.color == Color.Red, + icon = Icons.Filled.Circle, + tint = Color.Red + ) + } - item { - RichTextStyleButton( - onClick = { - state.toggleSpanStyle( - SpanStyle( - background = Color.Yellow + item { + RichTextStyleButton( + onClick = { + state.toggleSpanStyle( + SpanStyle( + background = Color.Yellow + ) ) - ) - }, - isSelected = state.currentSpanStyle.background == Color.Yellow, - icon = Icons.Outlined.Circle, - tint = Color.Yellow - ) + }, + isSelected = state.currentSpanStyle.background == Color.Yellow, + icon = Icons.Outlined.Circle, + tint = Color.Yellow + ) + } + + item { + Box( + Modifier + .height(24.dp) + .width(1.dp) + .background(Color(0xFF393B3D)) + ) + } + + item { + RichTextStyleButton( + onClick = { + state.toggleUnorderedList() + }, + isSelected = state.isUnorderedList, + icon = Icons.AutoMirrored.Outlined.FormatListBulleted, + ) + } + + item { + RichTextStyleButton( + onClick = { + state.toggleOrderedList() + }, + isSelected = state.isOrderedList, + icon = Icons.Outlined.FormatListNumbered, + ) + } + + item { + Box( + Modifier + .height(24.dp) + .width(1.dp) + .background(Color(0xFF393B3D)) + ) + } + + item { + RichTextStyleButton( + onClick = { + state.toggleCodeSpan() + }, + isSelected = state.isCodeSpan, + icon = Icons.Outlined.Code, + ) + } } - item { - Box( - Modifier - .height(24.dp) - .width(1.dp) - .background(Color(0xFF393B3D)) - ) + IconButton({ state.toClipboard(clip) }) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy markdown") } - - item { - RichTextStyleButton( - onClick = { - state.toggleUnorderedList() - }, - isSelected = state.isUnorderedList, - icon = Icons.AutoMirrored.Outlined.FormatListBulleted, - ) - } - - item { - RichTextStyleButton( - onClick = { - state.toggleOrderedList() - }, - isSelected = state.isOrderedList, - icon = Icons.Outlined.FormatListNumbered, - ) - } - - item { - Box( - Modifier - .height(24.dp) - .width(1.dp) - .background(Color(0xFF393B3D)) - ) - } - - item { - RichTextStyleButton( - onClick = { - state.toggleCodeSpan() - }, - isSelected = state.isCodeSpan, - icon = Icons.Outlined.Code, - ) + IconButton({ state.loadClipboard(clip, scope) }) { + Icon(Icons.Default.ContentPaste, contentDescription = "Paste markdown") } } } diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Views.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Views.kt index 8617489..7831368 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Views.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Views.kt @@ -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 + 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") } 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 a2dd106..10feea6 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt @@ -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, 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, 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 ListOrEmpty( + data: List, + 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 ListOrEmpty( data: List, @@ -92,41 +146,12 @@ fun 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 ListOrEmpty( @@ -348,4 +373,25 @@ fun DateTimePicker( 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") + } + } } \ 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 ac755bd..0f8cb5f 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt @@ -17,6 +17,34 @@ import java.util.* fun MutableState.immutable(): State = this fun SizedIterable.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 = assignment.id + } + class SAssignment(val assignment: SoloAssignment) : Assignment() { + override fun name(): String = assignment.name + override fun id(): EntityID = assignment.id + } + + abstract fun name(): String + abstract fun id(): EntityID + + companion object { + fun from(assignment: GroupAssignment) = GAssignment(assignment) + fun from(assignment: SoloAssignment) = SAssignment(assignment) + + fun merge(groups: List, solos: List): List { + 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(private val loader: (Transaction.() -> List)) { 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) { 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> { + 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() }