UI fixes and additions

This commit is contained in:
jay-tux 2025-03-04 15:59:48 +01:00
parent d0ddd54710
commit b69b46afee
Signed by: jay-tux
GPG Key ID: 84302006B056926E
8 changed files with 675 additions and 372 deletions

View File

@ -1,5 +1,11 @@
package com.jaytux.grader 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 { fun String.maxN(n: Int): String {
return if (this.length > n) { return if (this.length > n) {
this.substring(0, n - 3) + "..." this.substring(0, n - 3) + "..."
@ -7,3 +13,11 @@ fun String.maxN(n: Int): String {
this this
} }
} }
fun RichTextState.toClipboard(clip: ClipboardManager) {
clip.setText(AnnotatedString(this.toMarkdown()))
}
fun RichTextState.loadClipboard(clip: ClipboardManager, scope: CoroutineScope) {
scope.launch { setMarkdown(clip.getText()?.text ?: "") }
}

View File

@ -13,6 +13,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.jaytux.grader.viewmodel.GroupAssignmentState import com.jaytux.grader.viewmodel.GroupAssignmentState
import com.jaytux.grader.viewmodel.SoloAssignmentState
import com.mohamedrejeb.richeditor.model.rememberRichTextState import com.mohamedrejeb.richeditor.model.rememberRichTextState
import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor
@ -145,3 +146,93 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SoloAssignmentView(state: SoloAssignmentState) {
val name by state.name
val (course, edition) = state.editionCourse
val task by state.task
val deadline by state.deadline
val suggestions by state.autofill.entities
val grades by state.feedback.entities
var idx by remember(state) { mutableStateOf(0) }
Column(Modifier.padding(10.dp)) {
PaneHeader(name, "individual assignment", course, edition)
Row {
Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) {
LazyColumn(Modifier.fillMaxHeight().padding(10.dp)) {
item {
Surface(
Modifier.fillMaxWidth().clickable { idx = 0 },
tonalElevation = if (idx == 0) 50.dp else 0.dp,
shape = MaterialTheme.shapes.medium
) {
Text("Assignment", Modifier.padding(5.dp), fontStyle = FontStyle.Italic)
}
}
itemsIndexed(grades.toList()) { i, (student, _) ->
Surface(
Modifier.fillMaxWidth().clickable { idx = i + 1 },
tonalElevation = if (idx == i + 1) 50.dp else 0.dp,
shape = MaterialTheme.shapes.medium
) {
Text(student.name, Modifier.padding(5.dp))
}
}
}
}
Column(Modifier.weight(0.75f).padding(10.dp)) {
if (idx == 0) {
val updTask = rememberRichTextState()
LaunchedEffect(task) { updTask.setMarkdown(task) }
Row {
DateTimePicker(deadline, { state.updateDeadline(it) })
}
RichTextStyleRow(state = updTask)
OutlinedRichTextEditor(
state = updTask,
modifier = Modifier.fillMaxWidth().weight(1f),
singleLine = false,
minLines = 5,
label = { Text("Task") }
)
CancelSaveRow(
true,
{ updTask.setMarkdown(task) },
"Reset",
"Update"
) { state.updateTask(updTask.toMarkdown()) }
} else {
val (student, fg) = grades[idx - 1]
var sGrade by remember { mutableStateOf(fg?.grade ?: "") }
var sMsg by remember { mutableStateOf(TextFieldValue(fg?.feedback ?: "")) }
Row {
Text("Grade: ", Modifier.align(Alignment.CenterVertically))
OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f))
Spacer(Modifier.weight(0.6f))
Button(
{ state.upsertFeedback(student, sMsg.text, sGrade) },
Modifier.weight(0.2f).align(Alignment.CenterVertically),
enabled = sGrade.isNotBlank() || sMsg.text.isNotBlank()
) {
Text("Save")
}
}
AutocompleteLineField(
sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") }
) { filter ->
suggestions.filter { x -> x.trim().startsWith(filter.trim()) }
}
}
}
}
}
}

View File

@ -27,14 +27,16 @@ 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) }
ListOrEmpty( Box(Modifier.padding(15.dp)) {
data, ListOrEmpty(
{ Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) }, data,
{ Text("Add a course") }, { Text("You have no courses yet.", Modifier.align(Alignment.CenterHorizontally)) },
{ showDialog = true }, { Text("Add a course") },
addAfterLazy = false { showDialog = true },
) { _, it -> addAfterLazy = false
CourseWidget(state.getEditions(it), { state.delete(it) }, push) ) { _, it ->
CourseWidget(state.getEditions(it), { state.delete(it) }, push)
}
} }
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

@ -4,9 +4,6 @@ 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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.unit.dp
import androidx.compose.ui.window.* import androidx.compose.ui.window.*
import com.jaytux.grader.data.* import com.jaytux.grader.data.*
import com.jaytux.grader.viewmodel.EditionState import com.jaytux.grader.viewmodel.*
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 } enum class OpenPanel(val tabName: String) {
data class Current(val p: Panel, val i: Int) Student("Students"), Group("Groups"), Assignment("Assignments")
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 } data class Navigators(
fun Current?.groupAsIdx() = this?.let { if(p == Panel.GroupAs) i else null } val student: (Student) -> Unit,
val group: (Group) -> Unit,
val assignment: (Assignment) -> Unit
)
@Composable @Composable
fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) { fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) {
var isGroup by remember { mutableStateOf(false) } val course = state.course; val edition = state.edition
var idx by remember { mutableStateOf<Current?>(null) }
val students by state.students.entities val students by state.students.entities
val availableStudents by state.availableStudents.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
val available by state.availableStudents.entities val mergedAssignments by remember(solo, groupAs) {
mutableStateOf(Assignment.merge(groupAs, solo))
val toggle = { i: Int, p: Panel ->
idx = if(idx?.p == p && idx?.i == i) null else Current(p, i)
} }
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) { Surface(Modifier.weight(0.25f), tonalElevation = 5.dp) {
TabLayout( TabLayout(
listOf("Students", "Groups"), OpenPanel.entries,
if (isGroup) 1 else 0, tab.ordinal,
{ isGroup = it == 1 }, { tab = OpenPanel.entries[it]; selected = -1 },
{ Text(it) } { Text(it.tabName) }
) { ) {
Column(Modifier.fillMaxSize()) { when(tab) {
if (isGroup) { OpenPanel.Student -> StudentPanel(
Box(Modifier.weight(0.5f)) { course, edition, students, availableStudents, selected,
GroupsWidget( { selected = it },
state.course, { name, note, contact, add -> state.newStudent(name, contact, note, add) },
state.edition, { students -> state.addToCourse(students) },
groups, { s, name -> state.setStudentName(s, name) }
idx.groupIdx(), ) { s -> state.delete(s) }
{ toggle(it, Panel.Group) },
{ state.delete(it) }, OpenPanel.Group -> GroupPanel(
{ state.newGroup(it) }) { group, name -> course, edition, groups, selected,
state.setGroupName(group, name) { selected = it },
} { name -> state.newGroup(name) },
} { g, name -> state.setGroupName(g, name) }
Box(Modifier.weight(0.5f)) { ) { g -> state.delete(g) }
GroupAssignmentsWidget(
state.course, state.edition, groupAs, idx.groupAsIdx(), { toggle(it, Panel.GroupAs) }, OpenPanel.Assignment -> AssignmentPanel(
{ state.delete(it) }, course, edition, mergedAssignments, selected,
{ state.newGroupAssignment(it) }) { assignment, title -> { selected = it },
state.setGroupAssignmentTitle( { type, name -> state.newAssignment(type, name) },
assignment, { a, name -> state.setAssignmentTitle(a, name) }
title ) { a -> state.delete(a) }
)
}
}
} 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)
}
}
}
} }
} }
} }
Box(Modifier.weight(0.75f)) { Box(Modifier.weight(0.75f)) {
idx?.let { i -> if(selected != -1) {
when(i.p) { when(tab) {
Panel.Student -> StudentView(StudentState(students[i.i], state.edition)) OpenPanel.Student -> StudentView(StudentState(students[selected], edition), navs)
Panel.Group -> GroupView(GroupState(groups[i.i])) OpenPanel.Group -> GroupView(GroupState(groups[selected]), navs)
Panel.GroupAs -> GroupAssignmentView(GroupAssignmentState(groupAs[i.i])) OpenPanel.Assignment -> {
else -> {} when(val a = mergedAssignments[selected]) {
is Assignment.SAssignment -> SoloAssignmentView(SoloAssignmentState(a.assignment))
is Assignment.GAssignment -> GroupAssignmentView(GroupAssignmentState(a.assignment))
}
}
} }
} }
} }
} }
@Composable @Composable
fun <T> EditionSideWidget( fun StudentPanel(
course: Course, edition: Edition, header: String, hasNoX: String, addX: String, course: Course, edition: Edition, students: List<Student>, available: List<Student>,
data: List<T>, selected: Int?, onSelect: (Int) -> Unit, selected: Int, onSelect: (Int) -> Unit,
singleWidget: @Composable (T) -> Unit, onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit,
editDialog: @Composable ((current: T, onExit: () -> Unit) -> Unit)? = null, onImport: (List<Student>) -> Unit, onUpdate: (Student, String) -> Unit, onDelete: (Student) -> Unit
deleter: ((T) -> Unit)? = null,
dialog: @Composable (onExit: () -> Unit) -> Unit
) = Column(Modifier.padding(10.dp)) { ) = Column(Modifier.padding(10.dp)) {
Text(header, style = MaterialTheme.typography.headlineMedium)
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
var current by remember { mutableStateOf<T?>(null) } var deleting by remember { mutableStateOf(-1) }
var deleting by remember { mutableStateOf<T?>(null) } var editing by remember { mutableStateOf(-1) }
Text("Student list (${students.size})", style = MaterialTheme.typography.headlineMedium)
ListOrEmpty( ListOrEmpty(
data, students,
{ Text("Course ${course.name} (edition ${edition.name})\nhas no $hasNoX yet.", Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) }, { Text(
{ Text("Add $addX") }, "Course ${course.name} (edition ${edition.name})\nhas no students yet.",
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
) },
{ Text("Add a student") },
{ showDialog = true } { showDialog = true }
) { idx, it -> ) { idx, it ->
Surface( SelectEditDeleteRow(
Modifier.fillMaxWidth().clickable { onSelect(idx) }, selected == idx,
tonalElevation = if (selected == idx) 50.dp else 0.dp, { onSelect(idx) }, { onSelect(-1) },
shape = MaterialTheme.shapes.medium { editing = idx }, { deleting = idx }
) { ) {
Row { Text(it.name, Modifier.padding(5.dp))
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")
}
}
}
} }
} }
if(showDialog) dialog { showDialog = false } if(showDialog) {
editDialog?.let { d -> StudentDialog(course, edition, { showDialog = false }, available, onImport, onAdd)
current?.let { c -> }
d(c) { current = null } else if(editing != -1) {
AddStringDialog("Student name", students.map { it.name }, { editing = -1 }, students[editing].name) {
onUpdate(students[editing], it)
} }
} }
deleter?.let { d -> else if(deleting != -1) {
deleting?.let { x -> ConfirmDeleteDialog(
"a student",
{ deleting = -1 },
{ onDelete(students[deleting]) }
) { Text(students[deleting].name) }
}
}
@Composable
fun GroupPanel(
course: Course, edition: Edition, groups: List<Group>,
selected: Int, onSelect: (Int) -> Unit,
onAdd: (String) -> Unit, onUpdate: (Group, String) -> Unit, onDelete: (Group) -> Unit
) = Column(Modifier.padding(10.dp)) {
var showDialog by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(-1) }
var editing by remember { mutableStateOf(-1) }
Text("Group list (${groups.size})", style = MaterialTheme.typography.headlineMedium)
ListOrEmpty(
groups,
{ Text(
"Course ${course.name} (edition ${edition.name})\nhas no groups yet.",
Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
) },
{ Text("Add a group") },
{ showDialog = true }
) { idx, it ->
SelectEditDeleteRow(
selected == idx,
{ onSelect(idx) }, { onSelect(-1) },
{ editing = idx }, { deleting = idx }
) {
Text(it.name, Modifier.padding(5.dp))
}
}
if(showDialog) {
AddStringDialog("Group name", groups.map{ it.name }, { showDialog = false }) { onAdd(it) }
}
else if(editing != -1) {
AddStringDialog("Group name", groups.map { it.name }, { editing = -1 }, groups[editing].name) {
onUpdate(groups[editing], it)
}
}
else if(deleting != -1) {
ConfirmDeleteDialog(
"a group",
{ deleting = -1 },
{ onDelete(groups[deleting]) }
) { Text(groups[deleting].name) }
}
}
@Composable
fun AssignmentPanel(
course: Course, edition: Edition, assignments: List<Assignment>,
selected: Int, onSelect: (Int) -> Unit,
onAdd: (AssignmentType, String) -> Unit, onUpdate: (Assignment, String) -> Unit,
onDelete: (Assignment) -> Unit
) = Column(Modifier.padding(10.dp)) {
var showDialog by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf(-1) }
var editing by remember { mutableStateOf(-1) }
val dialog: @Composable (String, List<String>, () -> Unit, String, (AssignmentType, String) -> Unit) -> Unit =
{ label, taken, onClose, current, onSave ->
DialogWindow( DialogWindow(
onCloseRequest = { deleting = null }, onCloseRequest = onClose,
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center)) state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
) { ) {
Surface(Modifier.width(400.dp).height(300.dp), tonalElevation = 5.dp) { var name by remember(current) { mutableStateOf(current) }
Box(Modifier.fillMaxSize().padding(10.dp)) { var tab by remember { mutableStateOf(AssignmentType.Solo) }
Column(Modifier.align(Alignment.Center)) {
Text("You are about to delete $addX.", Modifier.padding(10.dp)) Surface(Modifier.fillMaxSize()) {
singleWidget(x) TabLayout(
CancelSaveRow(true, { deleting = null }, "Cancel", "Delete") { AssignmentType.entries,
d(x) tab.ordinal,
deleting = null { 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 Text("Assignment list (${assignments.size})", style = MaterialTheme.typography.headlineMedium)
fun StudentsWidget(
course: Course, edition: Edition, students: List<Student>, selected: Int?, onSelect: (Int) -> Unit, ListOrEmpty(
availableStudents: List<Student>, onImport: (List<Student>) -> Unit, deleter: (Student) -> Unit, assignments,
onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit { Text(
) = EditionSideWidget( "Course ${course.name} (edition ${edition.name})\nhas no assignments yet.",
course, edition, "Student list (${students.size})", "students", "a student", students, selected, onSelect, Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center
{ Text(it.name, Modifier.padding(5.dp)) }, ) },
deleter = deleter { Text("Add an assignment") },
) { onExit -> { showDialog = true }
StudentDialog(course, edition, onExit, availableStudents, onImport, onAdd) ) { 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 @Composable
@ -294,42 +370,3 @@ fun StudentDialog(
} }
} }
} }
@Composable
fun GroupsWidget(
course: Course, edition: Edition, groups: List<Group>, selected: Int?, onSelect: (Int) -> Unit,
deleter: (Group) -> Unit, onAdd: (name: String) -> Unit, onUpdate: (Group, String) -> Unit
) = EditionSideWidget(
course, edition, "Group list (${groups.size})", "groups", "a group", groups, selected, onSelect,
{ Text(it.name, Modifier.padding(5.dp)) },
{ current, onExit -> AddStringDialog("Group name", groups.map { it.name }, onExit, current.name) { onUpdate(current, it) } },
deleter
) { onExit ->
AddStringDialog("Group name", groups.map { it.name }, onExit) { onAdd(it) }
}
@Composable
fun AssignmentsWidget(
course: Course, edition: Edition, assignments: List<SoloAssignment>, selected: Int?,
onSelect: (Int) -> Unit, deleter: (SoloAssignment) -> Unit, onAdd: (name: String) -> Unit, onUpdate: (SoloAssignment, String) -> Unit
) = EditionSideWidget(
course, edition, "Assignment list", "assignments", "an assignment", assignments, selected, onSelect,
{ Text(it.name, Modifier.padding(5.dp)) },
{ current, onExit -> AddStringDialog("Assignment title", assignments.map { it.name }, onExit, current.name) { onUpdate(current, it) } },
deleter
) { onExit ->
AddStringDialog("Assignment title", assignments.map { it.name }, onExit) { onAdd(it) }
}
@Composable
fun GroupAssignmentsWidget(
course: Course, edition: Edition, assignments: List<GroupAssignment>, selected: Int?,
onSelect: (Int) -> Unit, deleter: (GroupAssignment) -> Unit, onAdd: (name: String) -> Unit, onUpdate: (GroupAssignment, String) -> Unit
) = EditionSideWidget(
course, edition, "Group assignment list", "group assignments", "an assignment", assignments, selected, onSelect,
{ Text(it.name, Modifier.padding(5.dp)) },
{ current, onExit -> AddStringDialog("Assignment title", assignments.map { it.name }, onExit, current.name) { onUpdate(current, it) } },
deleter
) { onExit ->
AddStringDialog("Assignment title", assignments.map { it.name }, onExit) { onAdd(it) }
}

View File

@ -1,189 +1,201 @@
package com.jaytux.grader.ui package com.jaytux.grader.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
import androidx.compose.material.icons.filled.Circle 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.material.icons.outlined.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.SpanStyle
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 import com.mohamedrejeb.richeditor.model.RichTextState
@OptIn(ExperimentalRichTextApi::class)
@Composable @Composable
fun RichTextStyleRow( fun RichTextStyleRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: RichTextState, state: RichTextState,
) { ) {
LazyRow( val clip = LocalClipboardManager.current
verticalAlignment = Alignment.CenterVertically, val scope = rememberCoroutineScope()
modifier = modifier
) { Row(modifier.fillMaxWidth()) {
item { LazyRow(
RichTextStyleButton( verticalAlignment = Alignment.CenterVertically,
onClick = { modifier = Modifier.weight(1f)
state.toggleSpanStyle( ) {
SpanStyle( item {
fontWeight = FontWeight.Bold RichTextStyleButton(
onClick = {
state.toggleSpanStyle(
SpanStyle(
fontWeight = FontWeight.Bold
)
) )
) },
}, isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold,
isSelected = state.currentSpanStyle.fontWeight == FontWeight.Bold, icon = Icons.Outlined.FormatBold
icon = Icons.Outlined.FormatBold )
) }
}
item { item {
RichTextStyleButton( RichTextStyleButton(
onClick = { onClick = {
state.toggleSpanStyle( state.toggleSpanStyle(
SpanStyle( SpanStyle(
fontStyle = FontStyle.Italic fontStyle = FontStyle.Italic
)
) )
) },
}, isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic,
isSelected = state.currentSpanStyle.fontStyle == FontStyle.Italic, icon = Icons.Outlined.FormatItalic
icon = Icons.Outlined.FormatItalic )
) }
}
item { item {
RichTextStyleButton( RichTextStyleButton(
onClick = { onClick = {
state.toggleSpanStyle( state.toggleSpanStyle(
SpanStyle( SpanStyle(
textDecoration = TextDecoration.Underline textDecoration = TextDecoration.Underline
)
) )
) },
}, isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true,
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.Underline) == true, icon = Icons.Outlined.FormatUnderlined
icon = Icons.Outlined.FormatUnderlined )
) }
}
item { item {
RichTextStyleButton( RichTextStyleButton(
onClick = { onClick = {
state.toggleSpanStyle( state.toggleSpanStyle(
SpanStyle( SpanStyle(
textDecoration = TextDecoration.LineThrough textDecoration = TextDecoration.LineThrough
)
) )
) },
}, isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true,
isSelected = state.currentSpanStyle.textDecoration?.contains(TextDecoration.LineThrough) == true, icon = Icons.Outlined.FormatStrikethrough
icon = Icons.Outlined.FormatStrikethrough )
) }
}
item { item {
RichTextStyleButton( RichTextStyleButton(
onClick = { onClick = {
state.toggleSpanStyle( state.toggleSpanStyle(
SpanStyle( SpanStyle(
fontSize = 28.sp fontSize = 28.sp
)
) )
) },
}, isSelected = state.currentSpanStyle.fontSize == 28.sp,
isSelected = state.currentSpanStyle.fontSize == 28.sp, icon = Icons.Outlined.FormatSize
icon = Icons.Outlined.FormatSize )
) }
}
item { item {
RichTextStyleButton( RichTextStyleButton(
onClick = { onClick = {
state.toggleSpanStyle( state.toggleSpanStyle(
SpanStyle( SpanStyle(
color = Color.Red color = Color.Red
)
) )
) },
}, isSelected = state.currentSpanStyle.color == Color.Red,
isSelected = state.currentSpanStyle.color == Color.Red, icon = Icons.Filled.Circle,
icon = Icons.Filled.Circle, tint = Color.Red
tint = Color.Red )
) }
}
item { item {
RichTextStyleButton( RichTextStyleButton(
onClick = { onClick = {
state.toggleSpanStyle( state.toggleSpanStyle(
SpanStyle( SpanStyle(
background = Color.Yellow background = Color.Yellow
)
) )
) },
}, isSelected = state.currentSpanStyle.background == Color.Yellow,
isSelected = state.currentSpanStyle.background == Color.Yellow, icon = Icons.Outlined.Circle,
icon = Icons.Outlined.Circle, tint = Color.Yellow
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 { IconButton({ state.toClipboard(clip) }) {
Box( Icon(Icons.Default.ContentCopy, contentDescription = "Copy markdown")
Modifier
.height(24.dp)
.width(1.dp)
.background(Color(0xFF393B3D))
)
} }
IconButton({ state.loadClipboard(clip, scope) }) {
item { Icon(Icons.Default.ContentPaste, contentDescription = "Paste markdown")
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,
)
} }
} }
} }

View File

@ -17,12 +17,14 @@ 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.Group
import com.jaytux.grader.data.Student
import com.jaytux.grader.maxN import com.jaytux.grader.maxN
import com.jaytux.grader.viewmodel.GroupState import com.jaytux.grader.viewmodel.GroupState
import com.jaytux.grader.viewmodel.StudentState import com.jaytux.grader.viewmodel.StudentState
@Composable @Composable
fun StudentView(state: StudentState) { fun StudentView(state: StudentState, nav: Navigators) {
val groups by state.groups.entities val groups by state.groups.entities
val courses by state.courseEditions.entities val courses by state.courseEditions.entities
val groupGrades by state.groupGrades.entities val groupGrades by state.groupGrades.entities
@ -48,9 +50,9 @@ fun StudentView(state: StudentState) {
Column(Modifier.weight(0.45f)) { Column(Modifier.weight(0.45f)) {
Text("Groups", style = MaterialTheme.typography.headlineSmall) Text("Groups", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(groups, { Text("Not a member of any group") }) { _, it -> ListOrEmpty(groups, { Text("Not a member of any group") }) { _, it ->
Row { val (group, c) = it
val (group, c) = it val (course, ed) = c
val (course, ed) = c Row(Modifier.clickable { nav.group(group) }) {
Text(group.name, style = MaterialTheme.typography.bodyMedium) Text(group.name, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.width(5.dp)) Spacer(Modifier.width(5.dp))
Text( Text(
@ -144,7 +146,7 @@ fun soloGradeWidget(sg: StudentState.LocalSoloGrade) {
} }
@Composable @Composable
fun GroupView(state: GroupState) { fun GroupView(state: GroupState, nav: Navigators) {
val members by state.members.entities val members by state.members.entities
val available by state.availableStudents.entities val available by state.availableStudents.entities
val allRoles by state.roles.entities val allRoles by state.roles.entities
@ -159,7 +161,7 @@ fun GroupView(state: GroupState) {
Text("Students", style = MaterialTheme.typography.headlineSmall) Text("Students", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(members, { Text("No students in this group") }) { _, it -> ListOrEmpty(members, { Text("No students in this group") }) { _, it ->
val (student, role) = it val (student, role) = it
Row { Row(Modifier.clickable { nav.student(student) }) {
Text( Text(
"${student.name} (${role ?: "no role"})", "${student.name} (${role ?: "no role"})",
Modifier.weight(0.75f).align(Alignment.CenterVertically), Modifier.weight(0.75f).align(Alignment.CenterVertically),
@ -177,7 +179,7 @@ fun GroupView(state: GroupState) {
Column(Modifier.weight(0.5f)) { Column(Modifier.weight(0.5f)) {
Text("Available students", style = MaterialTheme.typography.headlineSmall) Text("Available students", style = MaterialTheme.typography.headlineSmall)
ListOrEmpty(available, { Text("No students available") }) { _, it -> 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) }) { IconButton({ state.addStudent(it) }) {
Icon(ChevronLeft, "Add student") Icon(ChevronLeft, "Add student")
} }

View File

@ -9,6 +9,8 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -31,8 +33,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.* import kotlinx.datetime.*
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.format.DateTimeFormat
import kotlinx.datetime.format.byUnicodePattern
import java.util.* import java.util.*
@Composable @Composable
@ -74,7 +74,7 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
Box(Modifier.fillMaxSize().padding(10.dp)) { Box(Modifier.fillMaxSize().padding(10.dp)) {
var name by remember(current) { mutableStateOf(current) } var name by remember(current) { mutableStateOf(current) }
Column(Modifier.align(Alignment.Center)) { 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) { CancelSaveRow(name.isNotBlank() && name !in taken, onClose) {
onSave(name) onSave(name)
onClose() onClose()
@ -84,6 +84,60 @@ fun AddStringDialog(label: String, taken: List<String>, onClose: () -> Unit, cur
} }
} }
@Composable
fun ConfirmDeleteDialog(
deleteAWhat: String,
onExit: () -> Unit,
onDelete: () -> Unit,
render: @Composable () -> Unit
) = DialogWindow(
onCloseRequest = onExit,
state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center))
) {
Surface(Modifier.width(400.dp).height(300.dp), tonalElevation = 5.dp) {
Box(Modifier.fillMaxSize().padding(10.dp)) {
Column(Modifier.align(Alignment.Center)) {
Text("You are about to delete $deleteAWhat.", Modifier.padding(10.dp))
render()
CancelSaveRow(true, onExit, "Cancel", "Delete") {
onDelete()
onExit()
}
}
}
}
}
@Composable
fun <T> ListOrEmpty(
data: List<T>,
onEmpty: @Composable ColumnScope.() -> Unit,
addOptions: @Composable ColumnScope.() -> Unit,
addAfterLazy: Boolean = true,
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
) {
if(data.isEmpty()) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center)) {
onEmpty()
addOptions()
}
}
}
else {
Column {
LazyColumn(Modifier.weight(1f)) {
itemsIndexed(data) { idx, it ->
item(idx, it)
}
if(!addAfterLazy) item { addOptions() }
}
if(addAfterLazy) addOptions()
}
}
}
@Composable @Composable
fun <T> ListOrEmpty( fun <T> ListOrEmpty(
data: List<T>, data: List<T>,
@ -92,41 +146,12 @@ fun <T> ListOrEmpty(
onAdd: () -> Unit, onAdd: () -> Unit,
addAfterLazy: Boolean = true, addAfterLazy: Boolean = true,
item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit item: @Composable LazyItemScope.(idx: Int, it: T) -> Unit
) { ) = ListOrEmpty(
if(data.isEmpty()) { data, emptyText,
Box(Modifier.fillMaxSize()) { { Button(onAdd, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) { addText() } },
Column(Modifier.align(Alignment.Center)) { addAfterLazy,
emptyText() item
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 @Composable
fun <T> ListOrEmpty( fun <T> ListOrEmpty(
@ -349,3 +374,24 @@ fun ItalicAndNormal(italic: String, normal: String) = Row{
Text(italic, fontStyle = FontStyle.Italic) Text(italic, fontStyle = FontStyle.Italic)
Text(normal) 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")
}
}
}

View File

@ -17,6 +17,34 @@ import java.util.*
fun <T> MutableState<T>.immutable(): State<T> = this fun <T> MutableState<T>.immutable(): State<T> = this
fun <T> SizedIterable<T>.sortAsc(vararg columns: Expression<*>) = this.orderBy(*(columns.map { it to SortOrder.ASC }.toTypedArray())) fun <T> SizedIterable<T>.sortAsc(vararg columns: Expression<*>) = this.orderBy(*(columns.map { it to SortOrder.ASC }.toTypedArray()))
enum class AssignmentType { Solo, Group }
sealed class Assignment {
class GAssignment(val assignment: GroupAssignment) : Assignment() {
override fun name(): String = assignment.name
override fun id(): EntityID<UUID> = assignment.id
}
class SAssignment(val assignment: SoloAssignment) : Assignment() {
override fun name(): String = assignment.name
override fun id(): EntityID<UUID> = assignment.id
}
abstract fun name(): String
abstract fun id(): EntityID<UUID>
companion object {
fun from(assignment: GroupAssignment) = GAssignment(assignment)
fun from(assignment: SoloAssignment) = SAssignment(assignment)
fun merge(groups: List<GroupAssignment>, solos: List<SoloAssignment>): List<Assignment> {
val g = groups.map { from(it) }
val s = solos.map { from(it) }
return (g + s).sortedBy {
(it as? GAssignment)?.assignment?.name ?: (it as SAssignment).assignment.name
}
}
}
}
class RawDbState<T: Any>(private val loader: (Transaction.() -> List<T>)) { class RawDbState<T: Any>(private val loader: (Transaction.() -> List<T>)) {
private val rawEntities by lazy { private val rawEntities by lazy {
@ -84,7 +112,12 @@ class EditionState(val edition: Edition) {
if(addToEdition) students.refresh() if(addToEdition) students.refresh()
else availableStudents.refresh() else availableStudents.refresh()
} }
fun setStudentName(student: Student, name: String) {
transaction {
student.name = name
}
students.refresh()
}
fun addToCourse(students: List<Student>) { fun addToCourse(students: List<Student>) {
transaction { transaction {
EditionStudents.batchInsert(students) { EditionStudents.batchInsert(students) {
@ -92,7 +125,7 @@ class EditionState(val edition: Edition) {
this[studentId] = it.id this[studentId] = it.id
} }
} }
availableStudents.refresh(); availableStudents.refresh()
this.students.refresh() this.students.refresh()
} }
@ -139,6 +172,15 @@ class EditionState(val edition: Edition) {
groupAs.refresh() 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) { fun delete(s: Student) {
transaction { transaction {
EditionStudents.deleteWhere { studentId eq s.id } EditionStudents.deleteWhere { studentId eq s.id }
@ -171,6 +213,10 @@ class EditionState(val edition: Edition) {
} }
groupAs.refresh() 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) { class StudentState(val student: Student, edition: Edition) {
@ -337,7 +383,7 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
it[this.grade] = grd it[this.grade] = grd
} }
} }
feedback.refresh() feedback.refresh(); autofill.refresh()
} }
fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String) { fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String) {
@ -350,6 +396,59 @@ class GroupAssignmentState(val assignment: GroupAssignment) {
it[this.grade] = grd it[this.grade] = grd
} }
} }
feedback.refresh(); autofill.refresh()
}
fun updateTask(t: String) {
transaction {
assignment.assignment = t
}
_task.value = t
}
fun updateDeadline(d: LocalDateTime) {
transaction {
assignment.deadline = d
}
_deadline.value = d
}
}
class SoloAssignmentState(val assignment: SoloAssignment) {
data class LocalFeedback(val feedback: String, val grade: String)
val editionCourse = transaction { assignment.edition.course to assignment.edition }
private val _name = mutableStateOf(assignment.name); val name = _name.immutable()
private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable()
val feedback = RawDbState { loadFeedback() }
private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable()
val autofill = RawDbState {
SoloFeedbacks.selectAll().where { SoloFeedbacks.soloAssignmentId eq assignment.id }.map {
it[SoloFeedbacks.feedback].split('\n')
}.flatten().distinct().sorted()
}
private fun Transaction.loadFeedback(): List<Pair<Student, LocalFeedback?>> {
val students = editionCourse.second.soloStudents
val feedbacks = SoloFeedbacks.selectAll().where {
SoloFeedbacks.soloAssignmentId eq assignment.id
}.associate {
it[SoloFeedbacks.studentId] to LocalFeedback(it[SoloFeedbacks.feedback], it[SoloFeedbacks.grade])
}
return students.map { s -> s to feedbacks[s.id] }
}
fun upsertFeedback(student: Student, msg: String, grd: String) {
transaction {
SoloFeedbacks.upsert {
it[soloAssignmentId] = assignment.id
it[studentId] = student.id
it[this.feedback] = msg
it[this.grade] = grd
}
}
feedback.refresh() feedback.refresh()
} }