diff --git a/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml b/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml index 757b508d2..6a97b1a83 100644 --- a/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml +++ b/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml @@ -47,7 +47,7 @@ onFlowComplete: - tapOn: "Assignment title*" - inputText: "Homework 1" - tapOn: "Assign to" -- tapOn: "TestClass" +- tapOn: "TestClass students" - tapOn: "Description" - inputText: "Please complete this Homework on time." - runFlow: @@ -97,7 +97,7 @@ onFlowComplete: - assertVisible: "In-progress (0)" - assertVisible: "Please complete this Homework on time." - assertVisible: "Assigned to" -- assertVisible: "TestClass.*" +- assertVisible: "TestClass students" - assertVisible: "StudentA User" - assertVisible: "StudentB User" - assertVisible: "StudentC User" @@ -116,7 +116,11 @@ onFlowComplete: env: USERNAME: "studentauser" PASSWORD: "test123" +- assertVisible: "Apps" - tapOn: "Assignments" +- assertVisible: + id: "app_title" + text: "Assignments" - tapOn: "Homework 1" - assertVisible: id: "app_title" @@ -151,7 +155,11 @@ onFlowComplete: env: USERNAME: "studentbuser" PASSWORD: "test123" +- assertVisible: "Apps" - tapOn: "Assignments" +- assertVisible: + id: "app_title" + text: "Assignments" - tapOn: "Homework 1" - assertVisible: id: "app_title" @@ -177,9 +185,15 @@ onFlowComplete: env: USERNAME: "teacherauser" PASSWORD: "test123" +- assertVisible: "Apps" +- tapOn: "Classes" +- assertVisible: "TestClass" - tapOn: "Assignments" +- assertVisible: + id: "app_title" + text: "Assignments" - assertVisible: "Homework 1" -- assertVisible: "TestClass.*" +- assertVisible: "TestClass students" - tapOn: "Homework 1" - assertVisible: "All (3)" - assertVisible: "Completed (1)" @@ -188,7 +202,7 @@ onFlowComplete: #- assertVisible: "Not started (1)" - assertVisible: "Please complete this Homework on time." - assertVisible: "Assigned to" -- assertVisible: "TestClass.*" +- assertVisible: "TestClass students" - assertVisible: "StudentA User" - assertVisible: "StudentB User" - assertVisible: "StudentC User" diff --git a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt index d959d41c2..8de1e0991 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt @@ -261,6 +261,8 @@ import world.respect.shared.domain.opds.getxapiactivityid.GetXapiActivityForPubl import world.respect.shared.domain.xapi.getxapilaunchurl.GetXapiLaunchUrlUseCase import world.respect.shared.domain.xapi.getxapilaunchurl.GetXapiLaunchUrlUseCaseAndroid import world.respect.shared.domain.xapi.xapinanohttpd.XapiNanoHttpdResourceProviderAndroid +import world.respect.shared.viewmodel.studentgrouping.detail.StudentGroupingDetailViewModel +import world.respect.shared.viewmodel.studentgrouping.edit.StudentGroupingEditViewModel const val SHARED_PREF_SETTINGS_NAME = "respect_settings3_" @@ -396,6 +398,8 @@ val appKoinModule = module { viewModelOf(::EnrollmentEditViewModel) viewModelOf(::InviteQrViewModel) viewModelOf(::CreateAccountSetPasswordViewModel) + viewModelOf(::StudentGroupingEditViewModel) + viewModelOf(::StudentGroupingDetailViewModel) single { GetOfflineStorageOptionsUseCaseAndroid( diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt index ad87b7675..2549352ea 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt @@ -61,6 +61,8 @@ import world.respect.app.view.scanqrcode.ScanQRCodeScreen import world.respect.app.view.schooldirectory.edit.SchoolDirectoryEditScreen import world.respect.app.view.schooldirectory.list.SchoolDirectoryListScreen import world.respect.app.view.settings.SettingsScreenForViewModel +import world.respect.app.view.studentgrouping.detail.StudentGroupingDetailScreen +import world.respect.app.view.studentgrouping.edit.StudentGroupingEditScreen import world.respect.app.viewmodel.respectViewModel import world.respect.shared.navigation.AccountList import world.respect.shared.navigation.Acknowledgement @@ -116,6 +118,8 @@ import world.respect.shared.navigation.SchoolDirectoryEdit import world.respect.shared.navigation.SchoolDirectoryList import world.respect.shared.navigation.Settings import world.respect.shared.navigation.SignupScreen +import world.respect.shared.navigation.StudentGroupingDetail +import world.respect.shared.navigation.StudentGroupingEdit import world.respect.shared.navigation.TermsAndCondition import world.respect.shared.navigation.WaitingForApproval import world.respect.shared.viewmodel.acknowledgement.AcknowledgementViewModel @@ -157,6 +161,8 @@ import world.respect.shared.viewmodel.report.list.ReportTemplateListViewModel import world.respect.shared.viewmodel.schooldirectory.edit.SchoolDirectoryEditViewModel import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryListViewModel import world.respect.shared.viewmodel.settings.SettingsViewModel +import world.respect.shared.viewmodel.studentgrouping.detail.StudentGroupingDetailViewModel +import world.respect.shared.viewmodel.studentgrouping.edit.StudentGroupingEditViewModel @Composable @@ -290,6 +296,26 @@ fun AppNavHost( ) } + composable { + val viewModel: StudentGroupingEditViewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController + ) + StudentGroupingEditScreen( + viewModel = viewModel + ) + } + + composable { + val viewModel: StudentGroupingDetailViewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController + ) + StudentGroupingDetailScreen( + viewModel = viewModel + ) + } + composable { val viewModel: EnrollmentListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/assignment/edit/AssignmentEditScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/assignment/edit/AssignmentEditScreen.kt index e5e6e3d4b..add6e4453 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/assignment/edit/AssignmentEditScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/assignment/edit/AssignmentEditScreen.kt @@ -44,7 +44,7 @@ import world.respect.app.components.LangMapTextField import world.respect.app.components.RespectLocalDateTimeField import world.respect.app.components.defaultItemPadding import world.respect.app.components.uiTextStringResource -import world.respect.datalayer.school.model.Clazz +import world.respect.lib.xapi.model.XapiGroup import world.respect.lib.dataloadstate.ext.dataOrNull import world.respect.lib.xapi.ext.copyWithObjectActivityDescription import world.respect.lib.xapi.ext.copyWithObjectActivityName @@ -91,7 +91,7 @@ fun AssignmentEditScreen( uiState: AssignmentEditUiState, onEntityChanged: (XapiStatement) -> Unit, onAssigneeTextChanged: (String) -> Unit, - onAssigneeClassSelected: (Clazz) -> Unit, + onAssigneeClassSelected: (XapiGroup) -> Unit, onClickAddLearningUnit: () -> Unit, onClickRemoveLearningUnit: (XapiActivity) -> Unit, ) { @@ -153,14 +153,14 @@ fun AssignmentEditScreen( expanded = expanded, onDismissRequest = { expanded = false } ) { - uiState.classOptions.forEach { clazz -> + uiState.classOptions.forEach { group -> DropdownMenuItem( text = { - Text(clazz.title) + Text(group.name ?: "") }, onClick = { expanded = false - onAssigneeClassSelected(clazz) + onAssigneeClassSelected(group) }, contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, ) diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/detail/ClazzDetailScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/detail/ClazzDetailScreen.kt index 326a98e2f..1881ee67f 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/detail/ClazzDetailScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/detail/ClazzDetailScreen.kt @@ -1,11 +1,15 @@ package world.respect.app.view.clazz.detail import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert @@ -25,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.paging.compose.collectAsLazyPagingItems import org.jetbrains.compose.resources.stringResource import world.respect.app.components.RespectListSortHeader @@ -32,9 +37,11 @@ import world.respect.app.components.RespectPersonAvatar import world.respect.app.components.respectPagingItems import world.respect.app.components.respectRememberPager import world.respect.datalayer.db.school.ext.fullName +import world.respect.app.components.langMapString import world.respect.lib.dataloadstate.ext.dataOrNull import world.respect.datalayer.school.model.EnrollmentRoleEnum import world.respect.datalayer.school.model.Person +import world.respect.lib.xapi.ext.objectActivityOrNull import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.add_student import world.respect.shared.generated.resources.add_teacher @@ -43,13 +50,13 @@ import world.respect.shared.generated.resources.pending_requests import world.respect.shared.generated.resources.collapse_pending_invites import world.respect.shared.generated.resources.collapse_students import world.respect.shared.generated.resources.collapse_teachers -import world.respect.shared.generated.resources.description +import world.respect.shared.generated.resources.collapse_groups import world.respect.shared.generated.resources.expand_pending_invites import world.respect.shared.generated.resources.expand_students import world.respect.shared.generated.resources.expand_teachers +import world.respect.shared.generated.resources.expand_groups import world.respect.shared.generated.resources.manage_enrollments import world.respect.shared.generated.resources.more_options -import world.respect.shared.generated.resources.pending_requests import world.respect.shared.generated.resources.remove_from_class import world.respect.shared.generated.resources.student import world.respect.shared.generated.resources.students @@ -58,7 +65,9 @@ import world.respect.shared.generated.resources.teachers import world.respect.shared.util.SortOrderOption import world.respect.shared.viewmodel.clazz.detail.ClazzDetailUiState import world.respect.shared.viewmodel.clazz.detail.ClazzDetailViewModel - +import world.respect.shared.generated.resources.create_group +import world.respect.shared.generated.resources.groups +import world.respect.shared.viewmodel.clazz.detail.ClazzDetailViewModel.Companion.STACK_COUNT @Composable fun ClazzDetailScreen( @@ -76,9 +85,12 @@ fun ClazzDetailScreen( onTogglePendingSection = viewModel::onTogglePendingSection, onToggleTeachersSection = viewModel::onToggleTeachersSection, onToggleStudentsSection = viewModel::onToggleStudentsSection, + onToggleStudentGroupingSection = viewModel::onToggleStudentGroupingSection, onClickRemovePersonFromClass = viewModel::onClickRemovePersonFromClass, onClickManageEnrollments = viewModel::onClickManageEnrollments, onClickPerson = viewModel::onClickPerson, + onClickCreateGroup=viewModel::onClickCreateGroup, + onClickGroup = viewModel::onClickGroup ) } @@ -93,9 +105,12 @@ fun ClazzDetailScreen( onTogglePendingSection: () -> Unit, onToggleTeachersSection: () -> Unit, onToggleStudentsSection: () -> Unit, + onToggleStudentGroupingSection: () -> Unit, onClickRemovePersonFromClass: (Person, EnrollmentRoleEnum) -> Unit, onClickManageEnrollments: (Person, EnrollmentRoleEnum) -> Unit, onClickPerson: (Person) -> Unit, + onClickCreateGroup:()-> Unit, + onClickGroup: (String) -> Unit, ) { val teacherPager = respectRememberPager(uiState.teachers) val studentPager = respectRememberPager(uiState.students) @@ -108,6 +123,8 @@ fun ClazzDetailScreen( val pendingTeacherLazyPagingItems = pendingTeacherPager.flow.collectAsLazyPagingItems() val pendingStudentLazyPagingItems = pendingStudentPager.flow.collectAsLazyPagingItems() + val classStatement = uiState.classStatement.dataOrNull() + fun Person?.key(role: EnrollmentRoleEnum, index: Int): Any { return this?.guid?.let { Pair(it, role) @@ -130,7 +147,11 @@ fun ClazzDetailScreen( }, supportingContent = { - Text(text = uiState.clazz.dataOrNull()?.description ?: "") + Text( + text =classStatement?.objectActivityOrNull()?.definition?.description?.let { + langMapString(it) + } ?: "", + ) } ) } @@ -144,7 +165,7 @@ fun ClazzDetailScreen( ) } - if ((uiState.showAddTeacher || uiState.showAddStudent) && + if (uiState.isAdminOrTeacher && (pendingTeacherLazyPagingItems.itemCount + pendingStudentLazyPagingItems.itemCount) > 0 ) { item("pending_header") { @@ -184,7 +205,7 @@ fun ClazzDetailScreen( } if (uiState.isPendingExpanded) { - if (uiState.showAddTeacher) { + if (uiState.isAdminOrTeacher) { respectPagingItems( items = pendingTeacherLazyPagingItems, key = { person, index -> @@ -203,7 +224,7 @@ fun ClazzDetailScreen( } } - if (uiState.showAddStudent) { + if (uiState.isAdminOrTeacher) { respectPagingItems( items = pendingStudentLazyPagingItems, key = { person, index -> @@ -231,7 +252,10 @@ fun ClazzDetailScreen( headlineContent = { Text( modifier = Modifier.padding(top = 24.dp), - text = stringResource(Res.string.teachers) + text = if (teacherLazyPagingItems.itemCount > 0) + "${stringResource(Res.string.teachers)} (${teacherLazyPagingItems.itemCount})" + else + stringResource(Res.string.teachers) ) }, @@ -253,7 +277,7 @@ fun ClazzDetailScreen( } if (uiState.isTeachersExpanded) { - if (uiState.showAddTeacher) { + if (uiState.isAdminOrTeacher) { item("add_teacher") { ListItem( modifier = Modifier.clickable { @@ -282,7 +306,7 @@ fun ClazzDetailScreen( ) { teacher -> PersonListItemWithMenu( person = teacher, - showMenu = uiState.showAddTeacher, + showMenu = uiState.isAdminOrTeacher, onClickRemove = { onClickRemovePersonFromClass(it, EnrollmentRoleEnum.TEACHER) }, onClickManage = { onClickManageEnrollments(it, EnrollmentRoleEnum.TEACHER) }, onClick = onClickPerson, @@ -298,9 +322,10 @@ fun ClazzDetailScreen( headlineContent = { Text( modifier = Modifier.padding(top = 24.dp), - text = stringResource( - resource = Res.string.students - ) + text = if (studentLazyPagingItems.itemCount > 0) + "${stringResource(Res.string.students)} (${studentLazyPagingItems.itemCount})" + else + stringResource(Res.string.students) ) }, @@ -322,7 +347,7 @@ fun ClazzDetailScreen( } if (uiState.isStudentsExpanded) { - if(uiState.showAddStudent) { + if(uiState.isAdminOrTeacher) { item("add_student") { ListItem( modifier = Modifier.clickable { @@ -356,11 +381,110 @@ fun ClazzDetailScreen( onClickRemovePersonFromClass(it, EnrollmentRoleEnum.STUDENT) }, onClickManage = { onClickManageEnrollments(it, EnrollmentRoleEnum.STUDENT) }, - showMenu = uiState.showAddStudent, + showMenu = uiState.isAdminOrTeacher, onClick = onClickPerson, ) } } + + if (uiState.isAdminOrTeacher) { + + item("student_grouping_header") { + ListItem( + modifier = Modifier + .clickable { onToggleStudentGroupingSection() }, + headlineContent = { + Text( + modifier = Modifier.padding(top = 24.dp), + text = if (uiState.groups.isNotEmpty()) + "${stringResource(Res.string.groups)} (${uiState.groups.size})" + else + stringResource(Res.string.groups) + ) + }, + + trailingContent = { + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = if (uiState.isStudentGroupingExpanded) { + stringResource(Res.string.collapse_groups) + } else { + stringResource(Res.string.expand_groups) + }, + modifier = Modifier.size(24.dp) + .rotate( + if (uiState.isStudentGroupingExpanded) 0f else -90f + ) + ) + } + ) + } + + if (uiState.isStudentGroupingExpanded) { + item("student_grouping") { + ListItem( + modifier = Modifier.clickable { + onClickCreateGroup() + }, + leadingContent = { + Icon( + modifier = Modifier.size(40.dp).padding(8.dp), + imageVector = Icons.Filled.Add, + contentDescription = stringResource(resource = Res.string.create_group) + ) + }, + headlineContent = { + Text( + text = + stringResource(resource = Res.string.create_group) + ) + } + ) + } + + items( + items = uiState.groups, + key = { group -> group.account?.name ?: "" } + ) { group -> + val groupId = group.account?.name ?: return@items + + val memberNames = group.member + ?.map { it.name.orEmpty() } + ?: emptyList() + + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClickGroup(groupId) + }, + leadingContent = { + Box( + modifier = Modifier.width(40.dp), + ) { + val displayMembers = memberNames.take(STACK_COUNT) + displayMembers.forEachIndexed { i, name -> + Box( + modifier = Modifier + .offset(x = (i * 12).dp) + .zIndex((displayMembers.size - i).toFloat()) + ) { + RespectPersonAvatar( + name = name, + modifier = Modifier.size(24.dp) + ) + } + } + } + }, + headlineContent = { + Text(text = "${group.name} (${memberNames.size})") + } + ) + } + } + } + } } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/edit/ClazzEditScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/edit/ClazzEditScreen.kt index 724a11942..a1037102c 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/edit/ClazzEditScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/edit/ClazzEditScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -14,10 +13,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import kotlinx.coroutines.Dispatchers import org.jetbrains.compose.resources.stringResource +import world.respect.app.components.LangMapTextField import world.respect.app.components.defaultItemPadding import world.respect.app.components.uiTextStringResource import world.respect.lib.dataloadstate.ext.dataOrNull -import world.respect.datalayer.school.model.Clazz +import world.respect.lib.xapi.ext.copyWithObjectActivityDescription +import world.respect.lib.xapi.ext.copyWithObjectActivityName +import world.respect.lib.xapi.ext.objectActivityOrNull +import world.respect.lib.xapi.model.XapiStatement import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.class_name import world.respect.shared.generated.resources.description @@ -34,59 +37,47 @@ fun ClazzEditScreen( ClazzEditScreen( uiState = uiState, onEntityChanged = viewModel::onEntityChanged, - onClearError = viewModel::onClearError ) } @Composable fun ClazzEditScreen( uiState: ClazzEditUiState, - onEntityChanged: (Clazz) -> Unit = {}, - onClearError: () -> Unit = {}, + onEntityChanged: (XapiStatement) -> Unit = {}, ) { - val clazz = uiState.clazz.dataOrNull() + val statement = uiState.statementData.dataOrNull() val fieldsEnabled = uiState.fieldsEnabled Column( modifier = Modifier.fillMaxSize() .verticalScroll(rememberScrollState()) ) { - OutlinedTextField( + LangMapTextField( modifier = Modifier.fillMaxWidth().defaultItemPadding().testTag("name"), - value = clazz?.title ?: "", + value = statement?.objectActivityOrNull()?.definition?.name ?: emptyMap(), + onValueChange = { value -> + statement?.copyWithObjectActivityName(value)?.also { onEntityChanged(it) } + }, label = { Text(stringResource(Res.string.class_name) + "*") }, - onValueChange = { value -> - clazz?.also { - onEntityChanged(it.copy(title = value)) - } - if (uiState.clazzNameError != null && value.isNotBlank()) { - onClearError() - } - }, - singleLine = true, supportingText = { Text(uiTextStringResource(uiState.clazzNameError ?: Res.string.required.asUiText())) }, enabled = fieldsEnabled, - isError = uiState.clazzNameError != null ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth().defaultItemPadding().testTag(("description")), - value = clazz?.description ?: "", + LangMapTextField( + modifier = Modifier.fillMaxWidth().defaultItemPadding().testTag("description"), + value = statement?.objectActivityOrNull()?.definition?.description ?: emptyMap(), label = { - Text( - stringResource(Res.string.description) - ) + Text(stringResource(Res.string.description)) }, - onValueChange = { newValue -> - clazz?.also { - onEntityChanged(it.copy(description = newValue)) - } - } + onValueChange = { value -> + statement?.copyWithObjectActivityDescription(value)?.also { onEntityChanged(it) } + }, + enabled = fieldsEnabled, ) } } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/list/ClazzListScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/list/ClazzListScreen.kt index 4572dfc92..84f27a2b2 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/list/ClazzListScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/list/ClazzListScreen.kt @@ -4,23 +4,22 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.paging.compose.collectAsLazyPagingItems import world.respect.app.components.RespectEmptyListComponent import world.respect.app.components.RespectListSortHeader import world.respect.app.components.RespectPersonAvatar import world.respect.app.components.SortListMode import world.respect.app.components.defaultItemPadding import world.respect.app.components.defaultSortListMode -import world.respect.app.components.respectPagingItems -import world.respect.app.components.respectRememberPager -import world.respect.datalayer.school.ClassDataSource -import world.respect.datalayer.school.model.Clazz +import world.respect.app.components.langMapString +import world.respect.lib.xapi.ext.objectActivityOrNull +import world.respect.lib.xapi.model.XapiStatement import world.respect.shared.util.SortOrderOption import world.respect.shared.viewmodel.clazz.list.ClazzListUiState import world.respect.shared.viewmodel.clazz.list.ClazzListViewModel @@ -42,15 +41,11 @@ fun ClazzListScreen( @Composable fun ClazzListScreen( uiState: ClazzListUiState, - onClickClazz: (Clazz) -> Unit, + onClickClazz: (XapiStatement) -> Unit, onClickSortOption: (SortOrderOption) -> Unit = { }, sortListMode: SortListMode = defaultSortListMode(), ) { - val pager = respectRememberPager(uiState.classes) - - val lazyPagingItems = pager.flow.collectAsLazyPagingItems() - LazyColumn(modifier = Modifier.fillMaxSize()) { item("header") { @@ -64,29 +59,32 @@ fun ClazzListScreen( ) } - respectPagingItems( - items = lazyPagingItems, - key = { item, index -> item?.guid ?: index.toString() }, - contentType = { ClassDataSource.ENDPOINT_NAME }, - ) { clazz -> + items( + items = uiState.classStatements, + key = { statement -> + statement.objectActivityOrNull()?.id ?: statement.id.toString() + }, + ) { statement -> + val title = statement.objectActivityOrNull()?.definition?.name + ?.takeIf { it.isNotEmpty() }?.let { langMapString(it) } ?:"" ListItem( modifier = Modifier .fillMaxWidth() .clickable { - clazz?.also(onClickClazz) + onClickClazz(statement) }, leadingContent = { - RespectPersonAvatar(name = clazz?.title ?: "") + RespectPersonAvatar(name = title) }, headlineContent = { - Text(text = clazz?.title ?: "") + Text(text = title) } ) } - if(lazyPagingItems.itemCount == 0) { + if(uiState.classStatements.isEmpty()) { item("empty_item") { RespectEmptyListComponent(Modifier.fillMaxWidth().defaultItemPadding()) } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/studentgrouping/detail/StudentGroupingDetailScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/studentgrouping/detail/StudentGroupingDetailScreen.kt new file mode 100644 index 000000000..2d5a1fdb2 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/studentgrouping/detail/StudentGroupingDetailScreen.kt @@ -0,0 +1,139 @@ +package world.respect.app.view.studentgrouping.detail + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.material3.Text +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import org.jetbrains.compose.resources.stringResource +import world.respect.app.components.RespectBasicAlertDialog +import world.respect.app.components.RespectPersonAvatar +import world.respect.app.components.defaultItemPadding +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.cancel +import world.respect.shared.generated.resources.delete +import world.respect.shared.generated.resources.permanently_deleted +import world.respect.shared.generated.resources.permanently_delete_this_group +import world.respect.shared.generated.resources.student +import world.respect.shared.generated.resources.students +import world.respect.shared.viewmodel.studentgrouping.detail.StudentGroupingDetailUiState +import world.respect.shared.viewmodel.studentgrouping.detail.StudentGroupingDetailViewModel + + +@Composable +fun StudentGroupingDetailScreen( + viewModel: StudentGroupingDetailViewModel +) { + val uiState by viewModel.uiState.collectAsState() + StudentGroupingDetailScreen( + uiState = uiState, + onClickDelete = viewModel::onClickDeleteGroup, + ) + + if (uiState.showDeleteGroupDialog) { + RespectBasicAlertDialog( + headlineText = stringResource(Res.string.permanently_deleted), + bodyText = stringResource(Res.string.permanently_delete_this_group), + onConfirm = viewModel::onConfirmDeleteGroup, + onDismissRequest = viewModel::onDismissDeleteGroupDialog, + confirmText = stringResource(Res.string.delete), + dismissText = stringResource(Res.string.cancel), + ) + } +} + +@Composable +fun StudentGroupingDetailScreen( + uiState: StudentGroupingDetailUiState = StudentGroupingDetailUiState(), + onClickDelete: () -> Unit = {}, +) { + + LazyColumn( + modifier = Modifier + .fillMaxSize() + ) { + item { + ListItem( + leadingContent = { + Box(modifier = Modifier.width(40.dp)) { + val displayMembers = uiState.groupMembers.take(3) + displayMembers.forEachIndexed { i, name -> + Box( + modifier = Modifier + .offset(x = (i * 12).dp) + .zIndex((displayMembers.size - i).toFloat()) + ) { + RespectPersonAvatar( + name = name, + modifier = Modifier.size(24.dp) + ) + } + } + } + }, + headlineContent = { + val label = if (uiState.groupMembers.size > 1) + stringResource(Res.string.students) + else + stringResource(Res.string.student) + Text( + text = "${uiState.groupMembers.size} $label", + ) + } + ) + + } + + item("divider1") { + HorizontalDivider() + } + + item { + Column( + modifier = Modifier.fillMaxWidth() + .testTag("delete_group_btn") + .defaultItemPadding() + .clickable { onClickDelete() } + ) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(Res.string.delete) + ) + Text(stringResource(Res.string.delete)) + } + } + + item("divider2") { + HorizontalDivider() + } + + items(uiState.groupMembers.size) { index -> + ListItem( + modifier = Modifier.fillMaxWidth(), + leadingContent = { + RespectPersonAvatar(name = uiState.groupMembers[index]) + }, + headlineContent = { + Text(text = uiState.groupMembers[index]) + } + ) + } + } +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/studentgrouping/edit/StudentGroupingEditScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/studentgrouping/edit/StudentGroupingEditScreen.kt new file mode 100644 index 000000000..dd4884fce --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/studentgrouping/edit/StudentGroupingEditScreen.kt @@ -0,0 +1,129 @@ +package world.respect.app.view.studentgrouping.edit + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ListItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.paging.compose.collectAsLazyPagingItems +import org.jetbrains.compose.resources.stringResource +import world.respect.app.components.RespectPersonAvatar +import world.respect.app.components.defaultItemPadding +import world.respect.app.components.respectPagingItems +import world.respect.app.components.respectRememberPager +import world.respect.app.components.uiTextStringResource +import world.respect.datalayer.db.school.ext.fullName +import world.respect.datalayer.school.model.Person +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.name +import world.respect.shared.generated.resources.required +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.studentgrouping.edit.StudentGroupingEditUiState +import world.respect.shared.viewmodel.studentgrouping.edit.StudentGroupingEditViewModel + + +@Composable +fun StudentGroupingEditScreen( + viewModel: StudentGroupingEditViewModel +) { + val uiState by viewModel.uiState.collectAsState() + StudentGroupingEditScreen( + uiState = uiState, + onStudentCheckedChange = viewModel::onStudentCheckedChange, + onGroupNameChanged = viewModel::onGroupNameChanged + ) +} + +@Composable +fun StudentGroupingEditScreen( + uiState: StudentGroupingEditUiState, + onStudentCheckedChange: (Person, Boolean) -> Unit, + onGroupNameChanged: (String) -> Unit +) { + + val studentPager = respectRememberPager(uiState.students) + val studentLazyPagingItems = studentPager.flow.collectAsLazyPagingItems() + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .defaultItemPadding() + .testTag("name"), + value = uiState.groupName, + onValueChange = { value -> + onGroupNameChanged(value) + }, + isError = uiState.groupNameError != null, + singleLine = true, + label = { + Text(stringResource(Res.string.name) + "*") + }, + supportingText = { + Text( + uiTextStringResource( + uiState.groupNameError ?: Res.string.required.asUiText() + ) + ) + } + ) + } + + respectPagingItems( + items = studentLazyPagingItems, + key = { person, index -> + person?.guid ?: index + } + ) { student -> + + val person = student ?: return@respectPagingItems + + val isSelected = person.guid in uiState.selectedStudentIds + + PersonListItemWithMenu( + person = person, + isSelected = isSelected, + onCheckedChange = { checked -> + onStudentCheckedChange(person, checked) + } + ) + } + } +} + +@Composable +fun PersonListItemWithMenu( + person: Person, + isSelected: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { + onCheckedChange(!isSelected) + }, + leadingContent = { + RespectPersonAvatar(name = person.fullName()) + }, + headlineContent = { + Text(text = person.fullName()) + }, + trailingContent = { + Checkbox( + checked = isSelected, + onCheckedChange = onCheckedChange + ) + } + ) +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml index 166c0c222..34a256535 100644 --- a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml +++ b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml @@ -147,6 +147,10 @@ Students Add Teacher"> Add Student"> + Add Student to create a Group"> + Create group"> + Edit Group"> + Groups"> Accept Invite"> Dismiss invite Pending requests"> @@ -427,6 +431,8 @@ Expand students list Collapse students list + Expand groups list + Collapse groups list Add person Select person @@ -463,6 +469,8 @@ Badge assigned Delete this passkey? + Permanently delete? + Permanently delete this group You could lose access to the account if you dont have another way to sign in. toggle visibility diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/createclass/CreateClassUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/createclass/CreateClassUseCase.kt index ce995e8f8..e295c6e98 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/createclass/CreateClassUseCase.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/createclass/CreateClassUseCase.kt @@ -3,27 +3,22 @@ package world.respect.shared.domain.createclass import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.school.model.ClassInvite import world.respect.datalayer.school.model.ClassInviteModeEnum -import world.respect.datalayer.school.model.Clazz import world.respect.datalayer.school.model.EnrollmentRoleEnum import world.respect.datalayer.school.model.Invite2 /** - * Use case to contain logic for creating a new class. + * Use case to contain logic for creating invites for a new class. * - * Currently: + * Creates invites for each role (teacher direct, student direct, student via parent). * - * 1) Stores the class itself - * 2) Stores invites for the class for each role + * @property dataSource the SchoolDataSource used to store invites */ class CreateClassUseCase( private val dataSource: SchoolDataSource ) { - suspend operator fun invoke( - clazz: Clazz + classActivityId: String ) { - dataSource.classDataSource.store(listOf(clazz)) - dataSource.inviteDataSource.store( listOf( Pair(EnrollmentRoleEnum.TEACHER, ClassInviteModeEnum.DIRECT), @@ -32,10 +27,10 @@ class CreateClassUseCase( ).map { (role, inviteMode) -> ClassInvite( uid = ClassInvite.uidFor( - clazz.guid, role, inviteMode = inviteMode + classActivityId, role, inviteMode = inviteMode ), code = Invite2.newRandomCode(), - classUid = clazz.guid, + classUid = classActivityId, role = role, inviteMode = inviteMode, ) diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/enrollments/UpdateClazzStudentXapiGroupUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/enrollments/UpdateClazzStudentXapiGroupUseCase.kt index 166d54aae..2e2622b2f 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/enrollments/UpdateClazzStudentXapiGroupUseCase.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/enrollments/UpdateClazzStudentXapiGroupUseCase.kt @@ -9,8 +9,11 @@ import world.respect.datalayer.school.ext.asXapiAgent import world.respect.datalayer.school.model.EnrollmentRoleEnum import world.respect.lib.dataloadstate.DataLoadParams import world.respect.lib.dataloadstate.ext.dataOrNull +import world.respect.lib.xapi.ext.mostRecentByTimestampOrNull +import world.respect.lib.xapi.model.XapiActivity import world.respect.lib.xapi.model.XapiStatement import world.respect.lib.xapi.model.XapiVerb +import world.respect.lib.xapi.resources.XapiStatementsResource.GetStatementParams import world.respect.shared.ext.studentsXapiGroup import kotlin.uuid.ExperimentalUuidApi @@ -32,34 +35,42 @@ class UpdateClazzStudentXapiGroupUseCase( ) ).dataOrNull() - val clazz = schoolDataSource.classDataSource.findByGuid( - params = DataLoadParams(), - guid = clazzUid, - ).dataOrNull() + val classStatement = schoolDataSource.xapiResource.statements.get( + listParams = GetStatementParams(activity = clazzUid), + dataLoadParams = DataLoadParams(), + ).dataOrNull()?.statements?.mostRecentByTimestampOrNull() + + val classActivity = classStatement?.`object` as? XapiActivity + + val studentsXapiGroup = classActivity?.studentsXapiGroup() val activePerson = schoolDataSource.personDataSource.findByGuid( loadParams = DataLoadParams(onlyIfCached = true), guid = authenticatedUserPrincipalId.guid, ).dataOrNull() - if(studentsInClass == null || clazz == null || activePerson == null) { - Napier.w("No enrollments: something bad: students=$studentsInClass clazz=$clazz") + + if (studentsInClass == null || studentsXapiGroup == null || activePerson == null) { + Napier.w("No enrollments: something bad: students=$studentsInClass clazz=$classStatement") return } + val studentsGroup = studentsXapiGroup.copy( + member = studentsInClass.map { it.asXapiAgent(schoolUrl) } + ) + schoolDataSource.xapiResource.statements.post( listOf( XapiStatement( actor = activePerson.asXapiAgent(schoolUrl), verb = XapiVerb(id = XapiVerb.ID_SAVED), - `object` = clazz.studentsXapiGroup( - schoolUrl = schoolUrl - ).copy( - member = studentsInClass.map { it.asXapiAgent(schoolUrl) } - ) + `object` = studentsGroup ) ) ) } + companion object{ + const val STUDENTS = "students" + } } \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/xapi/XapiClassExt.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/xapi/XapiClassExt.kt new file mode 100644 index 000000000..dafb4aba6 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/xapi/XapiClassExt.kt @@ -0,0 +1,61 @@ +package world.respect.shared.domain.xapi + +import world.respect.lib.xapi.model.XapiActivity +import world.respect.lib.xapi.model.XapiActivityDefinition +import world.respect.lib.xapi.model.XapiActor +import world.respect.lib.xapi.model.XapiContext +import world.respect.lib.xapi.model.XapiContextActivities +import world.respect.lib.xapi.model.XapiObjectType +import world.respect.lib.xapi.model.XapiStatement +import world.respect.lib.xapi.model.XapiVerb +import kotlin.time.Clock +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Activity type for a class as per README_CLASS_RECIPE.md + */ +const val ACTIVITY_TYPE_CLASS = "http://id.openeel.org/xapi/activity-type/class" + +/** + * Category activity id for the class-management recipe as per README_CLASS_RECIPE.md + */ +const val CATEGORY_CLASS_MANAGEMENT = "https://id.openeel.org/xapi/recipes/class-management" + + +/** + * Create a blank class statement as per the class-management recipe. + */ +@OptIn(ExperimentalUuidApi::class) +fun createBlankClassStatement( + classActivityId: String, + actor: XapiActor +): XapiStatement { + val now = Clock.System.now() + return XapiStatement( + id = Uuid.random(), + actor = actor, + verb = XapiVerb( + id = XapiVerb.ID_SAVED, + ), + `object` = XapiActivity( + objectType = XapiObjectType.Activity, + id = classActivityId, + definition = XapiActivityDefinition( + type = ACTIVITY_TYPE_CLASS, + ) + ), + context = XapiContext( + contextActivities = XapiContextActivities( + category = listOf( + XapiActivity( + id = CATEGORY_CLASS_MANAGEMENT, + objectType = XapiObjectType.Activity + ) + ), + ), + ), + timestamp = now, + version = "1.0.0" + ) +} diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/ext/ClazzExt.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/ext/ClazzExt.kt index ac42f5aee..b8a347308 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/ext/ClazzExt.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/ext/ClazzExt.kt @@ -1,29 +1,26 @@ package world.respect.shared.ext -import io.ktor.http.Url -import world.respect.datalayer.school.model.Clazz import world.respect.lib.xapi.model.XapiAccount +import world.respect.lib.xapi.model.XapiActivity import world.respect.lib.xapi.model.XapiGroup import world.respect.lib.xapi.model.XapiObjectType -import world.respect.libutil.ext.appendEndpointSegments +import world.respect.shared.domain.enrollments.UpdateClazzStudentXapiGroupUseCase.Companion.STUDENTS -fun Clazz.activityId( - schoolUrl: Url -): String { - return schoolUrl.appendEndpointSegments("classes", guid).toString() -} -fun Clazz.studentsXapiGroup( - schoolUrl: Url -): XapiGroup { +/** + * Creates a students XapiGroup from this XapiActivity representing a class. + * The group name is derived from the activity definition name (first available value). + */ +fun XapiActivity.studentsXapiGroup(): XapiGroup { + val className = definition?.name?.values?.firstOrNull() return XapiGroup( - //This needs localized: however we don't currently have localization wrappers for formatted strings - - name = "$title students", + name = "$className $STUDENTS", account = XapiAccount( - homePage = activityId(schoolUrl), - name = "students" + homePage = id, + name = STUDENTS ), objectType = XapiObjectType.Group, ) } + + diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt index 4ea735117..ff1e48210 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt @@ -156,7 +156,7 @@ object ClazzList : RespectAppRoute @Serializable class ClazzDetail( - val guid: String, + val classActivityId: String, ) : RespectAppRoute @Serializable @@ -216,9 +216,20 @@ class AddPersonToClazz( @Serializable data class ClazzEdit( - val guid: String? + val classActivityId: String?, +) : RespectAppRoute + +@Serializable +data class StudentGroupingEdit( + val classUid: String, + val groupId:String? ) : RespectAppRoute +@Serializable +data class StudentGroupingDetail( + val groupId: String +): RespectAppRoute + @Serializable object Report : RespectAppRoute diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/edit/AssignmentEditViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/edit/AssignmentEditViewModel.kt index 1b1bd509f..ceb953d18 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/edit/AssignmentEditViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/edit/AssignmentEditViewModel.kt @@ -3,6 +3,7 @@ package world.respect.shared.viewmodel.assignment.edit import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import io.github.aakira.napier.Napier import io.ktor.http.Url import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -17,8 +18,6 @@ import org.koin.core.component.KoinScopeComponent import org.koin.core.component.inject import org.koin.core.scope.Scope import world.respect.datalayer.SchoolDataSource -import world.respect.datalayer.school.ClassDataSource -import world.respect.datalayer.school.model.Clazz import world.respect.lib.dataloadstate.DataLoadParams import world.respect.lib.dataloadstate.DataLoadState import world.respect.lib.dataloadstate.DataLoadingState @@ -34,15 +33,16 @@ import world.respect.lib.xapi.ext.objectActivityNameOrNull import world.respect.lib.xapi.ext.objectActivityOrNull import world.respect.lib.xapi.ext.removeActivityFromContextActivitiesGrouping import world.respect.lib.xapi.model.XapiActivity +import world.respect.lib.xapi.model.XapiGroup import world.respect.lib.xapi.model.XapiStatement import world.respect.lib.xapi.model.XapiVerb import world.respect.lib.xapi.resources.XapiStatementsResource.GetStatementParams import world.respect.libutil.ext.appendEndpointSegments import world.respect.libutil.ext.isNullOrAllBlank import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.enrollments.UpdateClazzStudentXapiGroupUseCase.Companion.STUDENTS import world.respect.shared.domain.opds.getxapiactivityid.GetXapiActivityForPublicationUseCase import world.respect.shared.domain.xapi.createBlankAssignmentStatement -import world.respect.shared.ext.studentsXapiGroup import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.add_assignment import world.respect.shared.generated.resources.edit_assignment @@ -70,7 +70,7 @@ data class AssignmentEditUiState( val statementData: DataLoadState = DataLoadingState(), val assignee: String = "", val nameError: UiText? = null, - val classOptions: List = emptyList(), + val classOptions: List = emptyList(), val classError: UiText? = null, val learningUnitInfoFlow: (Url) -> Flow> = { flowOf(DataLoadingState()) }, ) { @@ -135,14 +135,30 @@ class AssignmentEditViewModel( launchWithLoadingIndicator( onShowError = { snackBarDispatcher.showSnackBar(Snack(it)) } ) { - val classes = schoolDataSource.classDataSource.list( - DataLoadParams(), - ClassDataSource.GetListParams() - ).dataOrNull() ?: emptyList() + val savedStatements = schoolDataSource.xapiResource.statements.get( + listParams = GetStatementParams( + verb = XapiVerb.ID_SAVED, + ), + dataLoadParams = DataLoadParams(), + ).dataOrNull()?.statements ?: emptyList() + + val studentGroups = savedStatements + .mapNotNull { statement -> + val group = statement.`object` as? XapiGroup + group?.takeIf { it.account?.name == STUDENTS } + } + .filter { group -> + if(group.name == null) { + Napier.w("AssignmentEditViewModel: student group has no name (homePage=${group.account?.homePage})") + } + group.name != null + } + .sortedByDescending { it.member?.size ?: 0 } + .distinctBy { it.account?.homePage } _uiState.update { it.copy( - classOptions = classes, + classOptions = studentGroups, ) } @@ -218,14 +234,17 @@ class AssignmentEditViewModel( ) } - fun onAssigneeClassSelected(clazz: Clazz) { - val statement = _uiState.value.statementData.dataOrNull() ?: return + fun onAssigneeClassSelected(group: XapiGroup) { + val statement = _uiState.value.statementData.dataOrNull() + ?: throw IllegalStateException("onAssigneeClassSelected: statement data cannot be null") + val groupName = group.name + ?: throw IllegalStateException("onAssigneeClassSelected: group name cannot be null") _uiState.update { it.copy( statementData = DataReadyState( - statement.copy(actor = clazz.studentsXapiGroup(schoolUrl)) + statement.copy(actor = group) ), - assignee = clazz.title, + assignee = groupName, classError = null, ) } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/detail/ClazzDetailViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/detail/ClazzDetailViewModel.kt index 6c6e59c3a..0d0c141f7 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/detail/ClazzDetailViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/detail/ClazzDetailViewModel.kt @@ -16,11 +16,11 @@ import org.koin.core.scope.Scope import world.respect.lib.dataloadstate.DataLoadParams import world.respect.lib.dataloadstate.DataLoadState import world.respect.lib.dataloadstate.DataLoadingState +import world.respect.lib.dataloadstate.DataReadyState import world.respect.datalayer.SchoolDataSource import world.respect.lib.dataloadstate.ext.dataOrNull import world.respect.datalayer.school.EnrollmentDataSource import world.respect.datalayer.school.PersonDataSource -import world.respect.datalayer.school.model.Clazz import world.respect.datalayer.school.model.Enrollment import world.respect.datalayer.school.model.EnrollmentRoleEnum import world.respect.datalayer.school.model.Person @@ -32,7 +32,9 @@ import world.respect.libutil.util.time.localDateInCurrentTimeZone import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.account.invite.ApproveOrDeclineInviteRequestUseCase import world.respect.shared.domain.school.SchoolPrimaryKeyGenerator +import world.respect.lib.xapi.ext.objectActivityNameOrNull import world.respect.shared.ext.whenSubscribed +import world.respect.shared.util.ext.asLangMapUiText import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.first_name import world.respect.shared.generated.resources.last_name @@ -58,6 +60,11 @@ import world.respect.datalayer.school.ext.writePermissionFlag import world.respect.datalayer.school.model.ClassInvite import world.respect.datalayer.school.model.ClassInviteModeEnum import world.respect.datalayer.school.writequeue.EnqueueRunPullSyncUseCase +import world.respect.lib.xapi.ext.mostRecentByTimestampOrNull +import world.respect.lib.xapi.model.XapiStatement +import world.respect.lib.xapi.resources.XapiStatementsResource.GetStatementParams +import world.respect.shared.navigation.StudentGroupingDetail +import world.respect.shared.navigation.StudentGroupingEdit import world.respect.shared.domain.enrollments.UpdateClazzStudentXapiGroupUseCase import world.respect.shared.domain.permissions.CheckSchoolPermissionsUseCase import world.respect.shared.ext.tryOrShowSnackbarOnError @@ -66,8 +73,12 @@ import world.respect.shared.viewmodel.app.appstate.FabUiState import world.respect.shared.viewmodel.app.appstate.Snack import world.respect.shared.viewmodel.app.appstate.SnackBarDispatcher import world.respect.shared.viewmodel.clazz.detail.ClazzDetailViewModel.Companion.ALL -import kotlin.getValue import kotlin.time.Clock +import world.respect.lib.xapi.model.VERB_SAVED +import world.respect.lib.xapi.model.XapiGroup +import world.respect.lib.xapi.resources.XapiStatementsResource +import world.respect.lib.xapi.model.XapiGroup.Companion.CLASS +import kotlin.time.Instant data class ClazzDetailUiState( val teachers: IPagingSourceFactory = EmptyPagingSourceFactory() , @@ -83,14 +94,15 @@ data class ClazzDetailUiState( Res.string.first_name, 1, true ), val fieldsEnabled: Boolean = true, - val clazz: DataLoadState = DataLoadingState(), + val classStatement: DataLoadState = DataLoadingState(), val isPendingExpanded: Boolean = true, val isTeachersExpanded: Boolean = true, val isStudentsExpanded: Boolean = true, val inviteCodePrefix: String? = null, - val showAddStudent: Boolean = false, - val showAddTeacher: Boolean = false, - val addPersonPermissions: List = emptyList(), + val isAdminOrTeacher: Boolean = false, + val isStudentGroupingExpanded: Boolean = true, + val groups: List = emptyList(), + val addPersonPermissions: List = emptyList() ) { fun showApproveOption(person: Person): Boolean { @@ -124,12 +136,17 @@ class ClazzDetailViewModel( private val updateClazzStudentXapiGroupUseCase: UpdateClazzStudentXapiGroupUseCase by inject() + private val schoolSelfUrl = accountManager.activeAccount?.school?.self + ?: throw IllegalStateException("schoolSelfUrl is required but activeAccount or school is null") + + private val classActivityId = "${schoolSelfUrl}${CLASS}${route.classActivityId}" + private fun pagingSourceByRole(role: EnrollmentRoleEnum): PagingSourceFactoryHolder { return PagingSourceFactoryHolder { schoolDataSource.personDataSource.listAsPagingSource( loadParams = DataLoadParams(), params = PersonDataSource.GetListParams( - filterByClazzUid = route.guid, + filterByClazzUid = route.classActivityId, filterByEnrolmentRole = role, inClassOnDay = localDateInCurrentTimeZone(), ) @@ -192,21 +209,38 @@ class ClazzDetailViewModel( } viewModelScope.launch { - schoolDataSource.classDataSource.findByGuidAsFlow(route.guid).collect { clazz -> + schoolDataSource.xapiResource.statements.getAsFlow( + listParams = GetStatementParams( + activity = route.classActivityId, + ), + dataLoadParams = DataLoadParams(), + ).collect { dataLoadState -> + val statement = dataLoadState.dataOrNull()?.statements + ?.mostRecentByTimestampOrNull() + _appUiState.update { - it.copy(title = clazz.dataOrNull()?.title?.asUiText()) + it.copy(title = statement?.objectActivityNameOrNull()?.asLangMapUiText()) + } + _uiState.update { + it.copy( + classStatement = statement?.let { stmt -> + DataReadyState(stmt) + } ?: DataLoadingState() + ) } - _uiState.update { it.copy(clazz = clazz) } } } + viewModelScope.launch { + observeGroupsFromXapi() + } + viewModelScope.launch { _uiState.whenSubscribed { accountManager.selectedAccountAndPersonFlow.collect { selectedAccountAndPerson -> _uiState.update { prev -> prev.copy( - showAddStudent = selectedAccountAndPerson?.person?.isAdminOrTeacher() == true, - showAddTeacher = selectedAccountAndPerson?.person?.isAdminOrTeacher() == true, + isAdminOrTeacher = selectedAccountAndPerson?.person?.isAdminOrTeacher() == true ) } @@ -236,7 +270,7 @@ class ClazzDetailViewModel( uid = schoolPrimaryKeyGenerator.primaryKeyGenerator.nextId( Enrollment.TABLE_ID ).toString(), - classUid = route.guid, + classUid = route.classActivityId, role = enrolmentRole, personUid = personToEnrol.guid, beginDate = Clock.System.now().toLocalDateTime( @@ -247,7 +281,7 @@ class ClazzDetailViewModel( ) if(enrolmentRole == EnrollmentRoleEnum.STUDENT) { - updateClazzStudentXapiGroupUseCase(route.guid) + updateClazzStudentXapiGroupUseCase(route.classActivityId) } } } @@ -258,7 +292,9 @@ class ClazzDetailViewModel( fun onClickAddPersonToClazz(roleType: EnrollmentRoleEnum) { viewModelScope.launch { - val clazz = _uiState.value.clazz.dataOrNull() ?: return@launch + val statement = _uiState.value.classStatement.dataOrNull() + ?: throw IllegalStateException("onClickAddPersonToClazz: class statement must be loaded") + val classTitle = statement.objectActivityNameOrNull()?.values?.firstOrNull() ?: "" _navCommandFlow.tryEmit( NavCommand.Navigate( @@ -269,10 +305,10 @@ class ClazzDetailViewModel( resultPopUpTo = route, ), inviteUid = ClassInvite.uidFor( - route.guid, roleType, ClassInviteModeEnum.DIRECT + route.classActivityId, roleType, ClassInviteModeEnum.DIRECT ), - classUid = clazz.guid, - className = clazz.title, + classUid = route.classActivityId, + className = classTitle, addToClassRole = roleType, filterByRole = roleType.relatedPersonRoleEnum, ) @@ -325,9 +361,21 @@ class ClazzDetailViewModel( _uiState.update { it.copy(isStudentsExpanded = !it.isStudentsExpanded) } } + fun onToggleStudentGroupingSection() { + _uiState.update { it.copy(isStudentGroupingExpanded = !it.isStudentGroupingExpanded) } + } + fun onClickEdit() { _navCommandFlow.tryEmit( - NavCommand.Navigate(ClazzEdit(route.guid)) + NavCommand.Navigate(ClazzEdit(route.classActivityId)) + ) + } + + fun onClickGroup(groupId: String) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + StudentGroupingDetail(groupId = groupId) + ) ) } @@ -338,9 +386,10 @@ class ClazzDetailViewModel( loadParams = DataLoadParams(), listParams = EnrollmentDataSource.GetListParams( personUid = person.guid, - classUid = route.guid, + classUid = route.classActivityId, ) - ).dataOrNull() ?: throw IllegalStateException() + ).dataOrNull() + ?: throw IllegalStateException("onClickRemovePersonFromClass: failed to load enrollments") val today = localDateInCurrentTimeZone() val modTime = Clock.System.now() @@ -380,22 +429,91 @@ class ClazzDetailViewModel( ) } + fun onClickCreateGroup() { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + StudentGroupingEdit( + classUid = route.classActivityId, groupId = null + ) + ) + ) + } + fun onClickManageEnrollments(person: Person, role: EnrollmentRoleEnum) { _navCommandFlow.tryEmit( NavCommand.Navigate( EnrollmentList.create( filterByPersonUid = person.guid, role = role, - filterByClassUid = route.guid + filterByClassUid = route.classActivityId ) ) ) } + @OptIn(kotlin.uuid.ExperimentalUuidApi::class) + private suspend fun observeGroupsFromXapi() { + + schoolDataSource.xapiResource.statements.getAsFlow( + listParams = XapiStatementsResource.GetStatementParams( + verb = VERB_SAVED, + activity = classActivityId, + relatedActivities = true, + ), + dataLoadParams = DataLoadParams() + ).collect { dataLoadState -> + + val statementResult = dataLoadState.dataOrNull() + ?: return@collect + + val latestGroups = statementResult.statements + .filter { statement -> + statement.verb.id == VERB_SAVED + } + .sortedByDescending { statement -> + statement.timestamp + ?: statement.stored + ?: Instant.DISTANT_PAST + } + .mapNotNull { statement -> + val group = statement.`object` as? XapiGroup + if (group == null) { + Napier.w("observeGroupsFromXapi: Expected XapiGroup in statement ${statement.id}") + } + group + } + .filter { group -> + val name = group.account?.name + if (name == null) { + Napier.w("observeGroupsFromXapi: Group account name missing") + } + name != null + } + .distinctBy { group -> + group.account?.name + } + .map { group -> + val filteredMembers = group.member?.filter { agent -> + if (agent.name == null) { + Napier.w("observeGroupsFromXapi: member agent has no name in group ${group.account?.name}") + } + agent.name != null + } + group.copy(member = filteredMembers) + } + + _uiState.update { prev -> + prev.copy(groups = latestGroups) + } + } + } + companion object { const val ALL = "All" const val RESULT_KEY_PREFIX = "result_" + const val STACK_COUNT=3 + } } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/edit/ClazzEditViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/edit/ClazzEditViewModel.kt index fff5e8a97..33b54f8aa 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/edit/ClazzEditViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/edit/ClazzEditViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import kotlinx.serialization.json.Json @@ -17,12 +18,18 @@ import world.respect.lib.dataloadstate.DataReadyState import world.respect.datalayer.SchoolDataSource import world.respect.lib.dataloadstate.ext.dataOrNull import world.respect.lib.dataloadstate.ext.isReadyAndSettled -import world.respect.datalayer.school.model.Clazz -import world.respect.datalayer.school.model.Clazz.Companion.DEFAULT_INVITE_CODE_LEN -import world.respect.datalayer.school.model.Clazz.Companion.DEFAULT_INVITE_CODE_MAX +import world.respect.lib.dataloadstate.ext.firstOrNotLoaded +import world.respect.lib.dataloadstate.ext.map +import world.respect.lib.xapi.ext.mostRecentByTimestampOrNull +import world.respect.lib.xapi.ext.objectActivityNameOrNull +import world.respect.lib.xapi.ext.objectActivityOrNull +import world.respect.lib.xapi.model.XapiStatement +import world.respect.lib.xapi.resources.XapiStatementsResource.GetStatementParams +import world.respect.libutil.ext.appendEndpointSegments +import world.respect.libutil.ext.isNullOrAllBlank import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.createclass.CreateClassUseCase -import world.respect.shared.domain.school.SchoolPrimaryKeyGenerator +import world.respect.shared.domain.xapi.createBlankClassStatement import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.add_clazz import world.respect.shared.generated.resources.edit_clazz @@ -36,46 +43,51 @@ import world.respect.shared.util.LaunchDebouncer import world.respect.shared.util.ext.asUiText import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.ActionBarButtonUiState -import kotlin.random.Random import kotlin.time.Clock +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + data class ClazzEditUiState( + val statementData: DataLoadState = DataLoadingState(), val clazzNameError: UiText? = null, - val clazz: DataLoadState = DataLoadingState(), ) { val fieldsEnabled: Boolean - get() = clazz.isReadyAndSettled() + get() = statementData.isReadyAndSettled() + + val hasErrors: Boolean + get() = clazzNameError != null } +@OptIn(ExperimentalUuidApi::class) class ClazzEditViewModel( savedStateHandle: SavedStateHandle, - accountManager: RespectAccountManager, + private val accountManager: RespectAccountManager, private val json: Json, ) : RespectViewModel(savedStateHandle), KoinScopeComponent { override val scope: Scope = accountManager.requireActiveAccountScope() private val schoolDataSource: SchoolDataSource by inject() - private val route: ClazzEdit = savedStateHandle.toRoute() - - private val schoolPrimaryKeyGenerator: SchoolPrimaryKeyGenerator by inject() - private val createClassUseCase: CreateClassUseCase by inject() - - private val guid = route.guid ?: schoolPrimaryKeyGenerator.primaryKeyGenerator.nextId( - Clazz.TABLE_ID - ).toString() + private val route: ClazzEdit = savedStateHandle.toRoute() private val _uiState = MutableStateFlow(ClazzEditUiState()) val uiState = _uiState.asStateFlow() + private val schoolUrl = accountManager.requireActiveSchoolUrl() + + private val classActivityId = route.classActivityId ?: run { + schoolUrl.appendEndpointSegments(ACTIVITY_ID_PATH, Uuid.random().toString()).toString() + } + private val debouncer = LaunchDebouncer(viewModelScope) init { _appUiState.update { prev -> prev.copy( - title = if (route.guid == null) { + title = if (route.classActivityId == null) { Res.string.add_clazz.asUiText() } else { Res.string.edit_clazz.asUiText() @@ -91,87 +103,114 @@ class ClazzEditViewModel( } launchWithLoadingIndicator { - if (route.guid != null) { + if (route.classActivityId != null) { loadEntity( json = json, - serializer = Clazz.serializer(), + serializer = XapiStatement.serializer(), loadFn = { params -> - schoolDataSource.classDataSource.findByGuid(params, guid) + schoolDataSource.xapiResource.statements.get( + listParams = GetStatementParams( + activity = classActivityId, + ), + dataLoadParams = params + ).map { result -> + result.statements.mostRecentByTimestampOrNull()?.let { + listOf(it) + } ?: emptyList() + }.firstOrNotLoaded() }, - uiUpdateFn = { clazz -> - _uiState.update { prev -> prev.copy(clazz = clazz) } + uiUpdateFn = { entity -> + _uiState.update { prev -> + prev.copy( + statementData = entity + ) + } } ) } else { + val actor = accountManager.selectedAccountAndPersonFlow.firstOrNull()?.xapiAgent + ?: throw IllegalStateException("ClazzEditViewModel: no active account/agent available to create a class") + val baseStmt = createBlankClassStatement( + classActivityId = classActivityId, + actor = actor + ) + _uiState.update { prev -> prev.copy( - clazz = DataReadyState( - Clazz( - guid = guid, - title = "", - description = "", - ) - ) + statementData = DataReadyState(baseStmt) ) } } } } - fun onEntityChanged(clazz: Clazz) { - val classToCommit = _uiState.updateAndGet { prev -> - prev.copy(clazz = DataReadyState(clazz)) - }.clazz.dataOrNull() ?: return + fun onEntityChanged(statement: XapiStatement) { + _uiState.update { prev -> + prev.copy( + statementData = DataReadyState(statement), + clazzNameError = prev.clazzNameError?.takeIf { + prev.statementData.dataOrNull()?.objectActivityNameOrNull() == statement.objectActivityNameOrNull() + }, + ) + } debouncer.launch(DEFAULT_SAVED_STATE_KEY) { - savedStateHandle[DEFAULT_SAVED_STATE_KEY] = json.encodeToString(classToCommit) + savedStateHandle[DEFAULT_SAVED_STATE_KEY] = + json.encodeToString(XapiStatement.serializer(), statement) } } fun onClickSave() { - val clazz = _uiState.value.clazz.dataOrNull()?.copy( - lastModified = Clock.System.now() - ) ?: return + val stateToSave = _uiState.updateAndGet { prev -> + val statement = prev.statementData.dataOrNull() - if (clazz.title.isBlank()) { - _uiState.update { prev -> - prev.copy(clazzNameError = Res.string.required_field.asUiText()) - } - return - } else { - _uiState.update { prev -> prev.copy(clazzNameError = null) } + prev.copy( + clazzNameError = Res.string.required_field.asUiText().takeIf { + statement?.objectActivityOrNull()?.definition?.name.isNullOrAllBlank() + }, + ) } + if (stateToSave.hasErrors) + return + + val classStatement = _uiState.value.statementData.dataOrNull() + ?: throw IllegalStateException("onClickSave: statement data cannot be null after validation") + launchWithLoadingIndicator { - try { - if (route.guid == null) { - val newClazz = clazz.copy() + schoolDataSource.xapiResource.statements.post( + listOf( + classStatement.copy( + id = Uuid.random(), + timestamp = Clock.System.now(), + ) + ) + ) + + if (route.classActivityId == null) { - createClassUseCase(newClazz) + createClassUseCase(classActivityId) - _navCommandFlow.tryEmit( - NavCommand.Navigate( - ClazzDetail(guid), popUpTo = route, popUpToInclusive = true - ) + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = ClazzDetail( + classActivityId = classActivityId, + ), + popUpTo = route, + popUpToInclusive = true, ) - } else { - schoolDataSource.classDataSource.store(listOf(clazz)) - _navCommandFlow.tryEmit(NavCommand.PopUp()) - } - } catch (e: Throwable) { - //needs to display snack bar here - e.printStackTrace() + ) + } else { + _navCommandFlow.tryEmit(NavCommand.PopUp()) } } } - private fun generateCode(): String { - return Random.nextInt(DEFAULT_INVITE_CODE_MAX) - .toString() - .padStart(DEFAULT_INVITE_CODE_LEN, '0') - } fun onClearError() { _uiState.update { prev -> prev.copy(clazzNameError = null) } } + companion object{ + const val ACTIVITY_ID_PATH = "xapi/activities/classes" + } } \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/list/ClazzListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/list/ClazzListViewModel.kt index 01fc1f956..28f7a7a5d 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/list/ClazzListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/list/ClazzListViewModel.kt @@ -10,13 +10,16 @@ import org.koin.core.component.KoinScopeComponent import org.koin.core.component.inject import org.koin.core.scope.Scope import world.respect.lib.dataloadstate.DataLoadParams +import world.respect.lib.dataloadstate.ext.dataOrNull import world.respect.datalayer.SchoolDataSource -import world.respect.datalayer.school.ClassDataSource -import world.respect.datalayer.school.model.Clazz -import world.respect.datalayer.shared.paging.EmptyPagingSourceFactory -import world.respect.datalayer.shared.paging.IPagingSourceFactory -import world.respect.datalayer.shared.paging.PagingSourceFactoryHolder +import world.respect.lib.xapi.ext.distinctByMostRecentTimestampForActivityId +import world.respect.lib.xapi.ext.objectActivityOrNull +import world.respect.lib.xapi.model.XapiStatement +import world.respect.lib.xapi.model.XapiVerb +import world.respect.lib.xapi.resources.XapiStatementsResource.GetStatementParams import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.xapi.ACTIVITY_TYPE_CLASS +import world.respect.shared.domain.xapi.CATEGORY_CLASS_MANAGEMENT import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.classes import world.respect.shared.generated.resources.clazz @@ -34,7 +37,7 @@ import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.FabUiState data class ClazzListUiState( - val classes: IPagingSourceFactory = EmptyPagingSourceFactory(), + val classStatements: List = emptyList(), val sortOptions: List = emptyList(), val activeSortOrderOption: SortOrderOption = SortOrderOption( Res.string.first_name, 1, true @@ -59,13 +62,6 @@ class ClazzListViewModel( private val enqueuePullSyncUseCase: EnqueueRunPullSyncUseCase by inject() - private val pagingSourceHolder = PagingSourceFactoryHolder { - schoolDataSource.classDataSource.listAsPagingSource( - loadParams = DataLoadParams(), - params = ClassDataSource.GetListParams() - ) - } - init { _appUiState.update { it.copy( @@ -81,7 +77,6 @@ class ClazzListViewModel( _uiState.update { prev -> prev.copy( - classes = pagingSourceHolder, sortOptions = listOf( SortOrderOption( Res.string.first_name, @@ -112,6 +107,28 @@ class ClazzListViewModel( ) } } + + viewModelScope.launch { + schoolDataSource.xapiResource.statements.getAsFlow( + listParams = GetStatementParams( + activity = CATEGORY_CLASS_MANAGEMENT, + relatedActivities = true, + ), + dataLoadParams = DataLoadParams(), + ).collect { dataLoadState -> + val statements = dataLoadState.dataOrNull()?.statements + ?.filter { stmt -> + stmt.verb.id == XapiVerb.ID_SAVED + && stmt.objectActivityOrNull()?.definition?.type == ACTIVITY_TYPE_CLASS + } + ?.distinctByMostRecentTimestampForActivityId() + ?: emptyList() + + _uiState.update { prev -> + prev.copy(classStatements = statements) + } + } + } } fun onSortOrderChanged(sortOption: SortOrderOption) { @@ -120,17 +137,21 @@ class ClazzListViewModel( } } - fun onClickClazz(clazz: Clazz) { + fun onClickClazz(statement: XapiStatement) { + val activityId = statement.objectActivityOrNull()?.id + ?: throw IllegalStateException("onClickClazz: statement object is not an Activity or has no id") _navCommandFlow.tryEmit( NavCommand.Navigate( - ClazzDetail(clazz.guid) + ClazzDetail( + classActivityId = activityId, + ) ) ) } fun onClickAdd() { _navCommandFlow.tryEmit( - NavCommand.Navigate(ClazzEdit(guid = null)) + NavCommand.Navigate(ClazzEdit(classActivityId = null)) ) } } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/studentgrouping/detail/StudentGroupingDetailViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/studentgrouping/detail/StudentGroupingDetailViewModel.kt new file mode 100644 index 000000000..0235699e7 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/studentgrouping/detail/StudentGroupingDetailViewModel.kt @@ -0,0 +1,224 @@ +package world.respect.shared.viewmodel.studentgrouping.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinScopeComponent +import org.koin.core.component.inject +import org.koin.core.scope.Scope +import world.respect.datalayer.SchoolDataSource +import world.respect.lib.dataloadstate.ext.dataOrNull +import world.respect.lib.dataloadstate.DataLoadParams +import world.respect.lib.xapi.model.VERB_SAVED +import world.respect.lib.xapi.model.VERB_VOIDED +import world.respect.lib.xapi.model.XapiAccount +import world.respect.lib.xapi.model.XapiAgent +import world.respect.lib.xapi.model.XapiGroup +import world.respect.lib.xapi.resources.XapiStatementsResource +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.edit +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.NavResultReturner +import world.respect.shared.navigation.StudentGroupingDetail +import world.respect.shared.navigation.StudentGroupingEdit +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.lib.xapi.model.XapiGroup.Companion.RESULT_KEY_GROUP_UPDATED +import world.respect.lib.xapi.model.XapiGroup.Companion.CLASS +import world.respect.lib.xapi.model.XapiObjectType +import world.respect.lib.xapi.model.XapiStatement +import world.respect.lib.xapi.model.XapiStatementRef +import world.respect.lib.xapi.model.XapiVerb +import world.respect.shared.viewmodel.app.appstate.FabUiState +import kotlin.getValue +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.uuid.ExperimentalUuidApi + +data class StudentGroupingDetailUiState( + val groupName: String = "", + val groupMembers: List = emptyList(), + val showDeleteGroupDialog: Boolean = false, + val statementGroupId: String? = null, + val classId: String? = null, +) + +class StudentGroupingDetailViewModel( + savedStateHandle: SavedStateHandle, + private val respectAccountManager: RespectAccountManager, + private val navResultReturner: NavResultReturner +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = respectAccountManager.requireActiveAccountScope() + + private val _uiState = MutableStateFlow(StudentGroupingDetailUiState()) + + val uiState = _uiState.asStateFlow() + + private val schoolDataSource: SchoolDataSource by inject() + + private val route: StudentGroupingDetail = savedStateHandle.toRoute() + + private val schoolSelfUrl = respectAccountManager.activeAccount?.school?.self + ?: throw IllegalStateException("schoolSelfUrl is required") + + + init { + _appUiState.update { + it.copy( + fabState = FabUiState( + visible = true, + icon = FabUiState.FabIcon.EDIT, + text = Res.string.edit.asUiText(), + onClick = ::onClickEdit + ) + ) + } + + loadGroupDetail() + + viewModelScope.launch { + navResultReturner.filteredResultFlowForKey( + RESULT_KEY_GROUP_UPDATED + ).collect { + loadGroupDetail() + } + } + } + + fun onClickEdit() { + val classId = _uiState.value.classId + if (classId == null) { + Napier.e("onClickEdit: classId not loaded when trying to edit group") + return + } + _navCommandFlow.tryEmit( + NavCommand.Navigate( + StudentGroupingEdit( + classUid = classId, + groupId = route.groupId + ) + ) + ) + } + + fun onClickDeleteGroup() { + _uiState.update { it.copy(showDeleteGroupDialog = true) } + } + + fun onDismissDeleteGroupDialog() { + _uiState.update { it.copy(showDeleteGroupDialog = false) } + } + + @OptIn(ExperimentalUuidApi::class) + fun onConfirmDeleteGroup() { + _uiState.update { it.copy(showDeleteGroupDialog = false) } + + launchWithLoadingIndicator { + + val statementId = _uiState.value.statementGroupId + ?: throw IllegalStateException("Statement ID not found when trying to delete group") + + val sessionAndPerson = respectAccountManager.selectedAccountAndPersonFlow.firstOrNull() + ?: throw IllegalStateException("No person selected when trying to delete group") + + val actor = sessionAndPerson.xapiAgent + + val voidingStatement = XapiStatement( + actor = actor, + verb = XapiVerb(id = VERB_VOIDED), + `object` = XapiStatementRef( + objectType = XapiObjectType.StatementRef, + id = statementId + ), + timestamp = Clock.System.now() + ) + schoolDataSource.xapiResource.statements.post(listOf(voidingStatement)) + _navCommandFlow.tryEmit(NavCommand.PopUp()) + + } + } + + @OptIn(ExperimentalUuidApi::class) + private fun loadGroupDetail() { + viewModelScope.launch { + // Query by agent (the group itself) using its account identifier + val groupAgent = XapiAgent( + objectType = XapiObjectType.Agent, + account = XapiAccount( + name = route.groupId, + homePage = schoolSelfUrl.toString() + ) + ) + + schoolDataSource.xapiResource.statements.getAsFlow( + listParams = XapiStatementsResource.GetStatementParams( + verb = VERB_SAVED, + agent = groupAgent, + ), + dataLoadParams = DataLoadParams() + ).collect { dataLoadState -> + val statementResult = dataLoadState.dataOrNull() + if (statementResult == null) { + Napier.d("loadGroupDetail: data not yet loaded, waiting") + return@collect + } + + // Find the latest statement for this group by sorting by timestamp + val groupStatement = statementResult.statements + .filter { statement -> + val group = statement.`object` as? XapiGroup + group?.account?.name == route.groupId + } + .maxByOrNull { it.timestamp ?: it.stored ?: Instant.DISTANT_PAST } + + if (groupStatement != null) { + val group = groupStatement.`object` as XapiGroup + val memberNames = group.member?.mapNotNull { agent -> + val name = agent.name + if (name == null) { + Napier.w("loadGroupDetail: member agent has no name") + } + name + } ?: emptyList() + val statementId = groupStatement.id + if (statementId == null) { + Napier.e("loadGroupDetail: Group statement id is null") + return@collect + } + val groupName = group.name + if (groupName == null) { + Napier.e("loadGroupDetail: Group name is null") + return@collect + } + + val classActivityId = groupStatement.context?.contextActivities?.parent + ?.firstOrNull()?.id + val classId = classActivityId?.removePrefix("${schoolSelfUrl}${CLASS}") + + _uiState.update { prev -> + prev.copy( + groupName = groupName, + groupMembers = memberNames, + statementGroupId = statementId.toString(), + classId = classId + ) + } + + _appUiState.update { + it.copy( + title = group.name?.asUiText() + ) + } + } + } + } + } +} diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/studentgrouping/edit/StudentGroupingEditViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/studentgrouping/edit/StudentGroupingEditViewModel.kt new file mode 100644 index 000000000..0cac3d8ec --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/studentgrouping/edit/StudentGroupingEditViewModel.kt @@ -0,0 +1,318 @@ +package world.respect.shared.viewmodel.studentgrouping.edit + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinScopeComponent +import org.koin.core.component.inject +import org.koin.core.scope.Scope +import world.respect.datalayer.SchoolDataSource +import io.github.aakira.napier.Napier +import world.respect.lib.dataloadstate.ext.dataOrNull +import world.respect.datalayer.school.PersonDataSource +import world.respect.datalayer.school.model.EnrollmentRoleEnum +import world.respect.datalayer.school.model.Person +import world.respect.datalayer.shared.paging.EmptyPagingSourceFactory +import world.respect.datalayer.shared.paging.IPagingSourceFactory +import world.respect.datalayer.shared.paging.PagingSourceFactoryHolder +import world.respect.lib.dataloadstate.DataLoadParams +import world.respect.lib.xapi.model.VERB_SAVED +import world.respect.lib.xapi.model.XapiAccount +import world.respect.lib.xapi.model.XapiActivity +import world.respect.lib.xapi.model.XapiAgent +import world.respect.lib.xapi.model.XapiContext +import world.respect.lib.xapi.model.XapiContextActivities +import world.respect.lib.xapi.model.XapiGroup +import world.respect.lib.xapi.model.XapiGroup.Companion.CLASS +import world.respect.lib.xapi.model.XapiGroup.Companion.RESULT_KEY_GROUP_UPDATED +import world.respect.lib.xapi.model.XapiObjectType +import world.respect.lib.xapi.model.XapiStatement +import world.respect.lib.xapi.model.XapiVerb +import world.respect.lib.xapi.resources.XapiStatementsResource +import world.respect.libutil.util.time.localDateInCurrentTimeZone +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.datalayer.db.school.ext.fullName +import world.respect.lib.xapi.model.VERB_VOIDED +import world.respect.lib.xapi.model.XapiStatementRef +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.edit_group +import world.respect.shared.generated.resources.create_group +import world.respect.shared.generated.resources.required_field +import world.respect.shared.generated.resources.save +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.StudentGroupingDetail +import world.respect.shared.navigation.StudentGroupingEdit +import world.respect.shared.resources.UiText +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.viewmodel.app.appstate.ActionBarButtonUiState +import kotlin.getValue +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +data class StudentGroupingEditUiState( + val groupName: String = "", + val groupNameError: UiText? = null, + val students: IPagingSourceFactory = EmptyPagingSourceFactory(), + val selectedStudents: List = emptyList(), + val statementId: String? = null +) { + val selectedStudentIds: Set + get() = selectedStudents.map { it.guid }.toSet() +} + +class StudentGroupingEditViewModel( + savedStateHandle: SavedStateHandle, + private val respectAccountManager: RespectAccountManager +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = respectAccountManager.requireActiveAccountScope() + + private val _uiState = MutableStateFlow(StudentGroupingEditUiState()) + + val uiState = _uiState.asStateFlow() + + private val schoolDataSource: SchoolDataSource by inject() + + private val route: StudentGroupingEdit = savedStateHandle.toRoute() + + private val schoolSelfUrl = respectAccountManager.activeAccount?.school?.self + ?: throw IllegalStateException("schoolSelfUrl is required") + + val classActivityId = "${schoolSelfUrl}${CLASS}${route.classUid}" + + private fun pagingSourceByRole(): PagingSourceFactoryHolder { + return PagingSourceFactoryHolder { + schoolDataSource.personDataSource.listAsPagingSource( + loadParams = DataLoadParams(), + params = PersonDataSource.GetListParams( + filterByClazzUid = route.classUid, + filterByEnrolmentRole = EnrollmentRoleEnum.STUDENT, + inClassOnDay = localDateInCurrentTimeZone(), + ) + ) + } + } + + private val studentPagingSource = pagingSourceByRole() + + init { + _appUiState.update { + it.copy( + title = if (route.groupId == null) + Res.string.create_group.asUiText() + else + Res.string.edit_group.asUiText(), + + userAccountIconVisible = false, + hideBottomNavigation = true, + actionBarButtonState = ActionBarButtonUiState( + visible = true, + text = Res.string.save.asUiText(), + onClick = ::onClickSave + ), + ) + } + + _uiState.update { + it.copy(students = studentPagingSource) + } + + route.groupId?.let { groupId -> + @OptIn(ExperimentalUuidApi::class) + viewModelScope.launch { + try { + val statementResult = schoolDataSource.xapiResource.statements.get( + listParams = XapiStatementsResource.GetStatementParams( + verb = VERB_SAVED, + activity = classActivityId, + relatedActivities = true, + ), + dataLoadParams = DataLoadParams() + ).dataOrNull() + ?: throw IllegalStateException( + "Failed to load statements for classActivityId=$classActivityId" + ) + + val groupStatement = statementResult.statements + .filter { statement -> + val group = statement.`object` as? XapiGroup + group?.account?.name == groupId + } + .maxByOrNull { + it.timestamp ?: it.stored ?: Instant.DISTANT_PAST + } + ?: throw IllegalStateException( + "Could not find statement for groupId=$groupId" + ) + + val group = groupStatement.`object` as XapiGroup + + val groupName = group.name + val statementId = groupStatement.id + if (statementId == null) { + Napier.e("StudentGroupingEditViewModel: group statement id is null for groupId=$groupId, cannot edit") + return@launch + } + + val memberIds = group.member + ?.mapNotNull { agent -> + val name = agent.account?.name + if (name == null) { + Napier.w("StudentGroupingEditViewModel: member agent has no account name") + } + name + } + ?: emptyList() + + val persons = memberIds.mapNotNull { id -> + val person = schoolDataSource.personDataSource.findByGuid( + DataLoadParams(), + id + ).dataOrNull() + if (person == null) { + Napier.w("StudentGroupingEditViewModel: could not find person with guid=$id") + } + person + } + + _uiState.update { prev -> + prev.copy( + groupName = groupName ?: "", + selectedStudents = persons, + statementId = statementId.toString() + ) + } + } catch (e: Exception) { + Napier.e("StudentGroupingEditViewModel: error loading group", throwable = e) + } + } + } + } + + @OptIn(ExperimentalUuidApi::class) + fun onClickSave() { + + val groupName = _uiState.value.groupName + val existingStatementId = _uiState.value.statementId + + if (groupName.isBlank()) { + _uiState.update { + it.copy(groupNameError = Res.string.required_field.asUiText()) + } + return + } else { + _uiState.update { it.copy(groupNameError = null) } + } + launchWithLoadingIndicator { + + val sessionAndPerson = respectAccountManager.selectedAccountAndPersonFlow.firstOrNull() + ?: throw IllegalStateException("No person selected when trying to save group") + + val actor = sessionAndPerson.xapiAgent + + if (existingStatementId != null) { + val voidingStatement = XapiStatement( + actor = actor, + verb = XapiVerb(id = VERB_VOIDED), + `object` = XapiStatementRef( + objectType = XapiObjectType.StatementRef, + id = existingStatementId + ), + timestamp = Clock.System.now() + ) + schoolDataSource.xapiResource.statements.post(listOf(voidingStatement)) + } + + val members = _uiState.value.selectedStudents.map { student -> + XapiAgent( + name = student.fullName(), + objectType = XapiObjectType.Agent, + account = XapiAccount( + name = student.guid, + homePage = schoolSelfUrl.toString() + ) + ) + } + + val groupId = route.groupId ?: Uuid.random().toString() + + val group = XapiGroup( + objectType = XapiObjectType.Group, + name = groupName, + account = XapiAccount( + name = groupId, + homePage = schoolSelfUrl.toString() + ), + member = members + ) + + val verb = XapiVerb( + id = VERB_SAVED + ) + + val statement = XapiStatement( + actor = actor, + verb = verb, + `object` = group, + timestamp = Clock.System.now(), + context = XapiContext( + contextActivities = XapiContextActivities( + parent = listOf( + XapiActivity( + id = classActivityId, + objectType = XapiObjectType.Activity + ) + ) + ) + ) + ) + + schoolDataSource.xapiResource.statements.post(listOf(statement)) + + if (route.groupId == null) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + StudentGroupingDetail(groupId = groupId), + popUpTo = route, + popUpToInclusive = true + ) + ) + } else { + sendResultAndPop(RESULT_KEY_GROUP_UPDATED, true) + } + + } + } + + fun onStudentCheckedChange(person: Person, isChecked: Boolean) { + _uiState.update { prev -> + + val updated = if (isChecked) { + prev.selectedStudents + person + } else { + prev.selectedStudents.filterNot { it.guid == person.guid } + } + + prev.copy( + selectedStudents = updated + ) + } + } + + fun onGroupNameChanged(name: String) { + _uiState.update { + it.copy( + groupName = name, + groupNameError = null + ) + } + } +} \ No newline at end of file diff --git a/respect-lib-xapi-core/src/commonMain/kotlin/world/respect/lib/xapi/model/XapiGroup.kt b/respect-lib-xapi-core/src/commonMain/kotlin/world/respect/lib/xapi/model/XapiGroup.kt index b4b45f5ac..852946f97 100644 --- a/respect-lib-xapi-core/src/commonMain/kotlin/world/respect/lib/xapi/model/XapiGroup.kt +++ b/respect-lib-xapi-core/src/commonMain/kotlin/world/respect/lib/xapi/model/XapiGroup.kt @@ -47,6 +47,11 @@ data class XapiGroup( val isIdentified: Boolean = !isAnonymous + + companion object { + const val RESULT_KEY_GROUP_UPDATED = "groupUpdated" + const val CLASS = "class/" + } } diff --git a/respect-lib-xapi-core/src/commonMain/kotlin/world/respect/lib/xapi/model/XapiVerb.kt b/respect-lib-xapi-core/src/commonMain/kotlin/world/respect/lib/xapi/model/XapiVerb.kt index ce4bd51f1..bc23f57d9 100644 --- a/respect-lib-xapi-core/src/commonMain/kotlin/world/respect/lib/xapi/model/XapiVerb.kt +++ b/respect-lib-xapi-core/src/commonMain/kotlin/world/respect/lib/xapi/model/XapiVerb.kt @@ -7,6 +7,11 @@ const val VERB_COMPLETED = "http://adlnet.gov/expapi/verbs/completed" const val VERB_PROGRESSED = "http://adlnet.gov/expapi/verbs/progressed" +const val VERB_SAVED = "http://adlnet.gov/expapi/verbs/saved" + +const val VERB_VOIDED = "http://adlnet.gov/expapi/verbs/voided" + + @Serializable data class XapiVerb( val id: String,