diff --git a/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml b/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml index 94623f25a..f88e6a93c 100644 --- a/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml +++ b/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml @@ -105,7 +105,7 @@ onFlowComplete: - runFlow: file: "subflows/admin_add_class.yaml" env: - CLASSNAME: "New Class" + CLASS_NAME: New Class - assertVisible: id: "app_title" text: "New Class" diff --git a/.maestro/flows/001_004_shared_device_test.yaml b/.maestro/flows/001_004_shared_device_test.yaml new file mode 100644 index 000000000..a725a98e8 --- /dev/null +++ b/.maestro/flows/001_004_shared_device_test.yaml @@ -0,0 +1,344 @@ +appId: world.respect.app +onFlowStart: + - clearState: world.respect.app + - runScript: + file: "scripts/school_init.js" + env: + TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} + SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} + DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} + SCHOOL_URL: ${SCHOOL_URL} + SCHOOL_NAME: ${SCHOOL_NAME} + URL_SUBSTITUTION: ${URL_SUBSTITUTION} + NAME: "001_004_shared_device_test.yaml" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" + +--- +## Admin setup +- runFlow: "subflows/school_admin_login_flow.yaml" + +# Add class, then add Student and Teacher to class +- runFlow: + file: "subflows/admin_add_class.yaml" + env: + CLASS_NAME: New Class +- runFlow: + file: "subflows/add_person_to_a_class.yaml" + env: + + ADD_USER: "Add Student" + FIRSTNAME: "StudentA" + LASTNAME: "User" + GENDER: "Male" + ROLE: "Student" + CLASS_NAME: "New Class" + USERNAME: "studentauser" + PASSWORD: "test123" + QR_BADGE_LINK: ${output.SCHOOL_URL}respect_school_link/personqrbadge/id/12312 +- back +- runFlow: + file: "subflows/add_person_to_a_class.yaml" + env: + ADD_USER: "Add Teacher" + FIRSTNAME: "TeacherA" + LASTNAME: "User" + GENDER: "Male" + ROLE: "Teacher" + CLASS_NAME: "New Class" + USERNAME: "teacherauser" + PASSWORD: "test123" +- tapOn: "Apps" +- assertVisible: + id: "app_title" + text: "Apps" + +# Enable Shared Mode - Test Device 1 +- tapOn: + id: "Settings" +- assertVisible: + id: "app_title" + text: "Settings" +- assertVisible: "School name, policies, shared device" +- tapOn: "School" +- assertVisible: + id: "app_title" + text: "School" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "Shared school device" +- assertVisible: "0 devices" +- tapOn: "Shared school device" +- assertVisible: + id: "app_title" + text: "Shared school device" +- assertVisible: "No Shared Devices Available" +- assertVisible: "Shared school devices allow multiple users to securely access the app on the same device using their profile" +- assertVisible: "Student can self-select their class and name" +- assertVisible: "Teacher/admin unlock PIN" +- copyTextFrom: + id: "set_pin" +- tapOn: + id: "set_pin" +- assertVisible: "Set PIN" +- inputText: "123" +- tapOn: "Save" +- assertVisible: "Error: please enter 4 digit number" +- eraseText +- inputText: "123a" +- tapOn: "Save" +- assertVisible: "Error: please enter 4 digit number" +- tapOn: "Cancel" +- assertVisible: ${maestro.copiedText} +- tapOn: + id: "set_pin" +- assertVisible: "Set PIN" +- inputText: "1234" +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Shared school device" +- assertVisible: "1234" + +# Enable This Device +- tapOn: + id: "floating_action_button" +- assertVisible: "Add device" +- assertVisible: "This device" +- assertVisible: "Enable shared school device on mode on this device" +- assertVisible: "Another device" +- assertVisible: "Add using QR code, link, or invite code" +- tapOn: "This device" +- assertVisible: + id: "app_title" + text: "Enable shared school device mode" +- tapOn: "Enable" +- assertVisible: "Required field*" #mandatory field error +- tapOn: + text: "Device name *" + index: 1 +- inputText: "Test Device 1" +- hideKeyboard +- assertVisible: "Enable" +- tapOn: "Enable" +- assertVisible: + id: "app_title" + text: "Select class" +- assertVisible: "New Class" +- assertVisible: "Scan QR code badge" + +# Enable Test Device 1 +- tapOn: "Teacher/admin login" +- assertVisible: + id: "app_title" + text: "Teacher/admin login" +#- tapOn: "Enter school device PIN" +#- inputText: ${maestro.copiedText} +#- tapOn: "Next" +#- assertVisible: "Incorrect device PIN" +#- eraseText +- tapOn: "Enter school device PIN" +- inputText: "1234" +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Login" +#- tapOn: "Select another school" +#- runFlow: +# file: "subflows/get_started_select_school_by_name.yaml" +# env: +# SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: + id: "username" +- inputText: "teacherauser" +- tapOn: + id : "password" +- inputText: "test123" +- tapOn: "Login" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- tapOn: "Apps" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: + id: "Settings" +- assertVisible: + id: "app_title" + text: "Settings" +- tapOn: "School" +- assertVisible: + id: "app_title" + text: "School" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "Shared school device" +- assertVisible: "1 devices" +- tapOn: "Shared school device" +- assertVisible: + id: "app_title" + text: "Shared school device" +- assertVisible: "Student can self-select their class and name" #switch is ON +- assertVisible: "Teacher/admin unlock PIN" +- assertVisible: "Devices (1)" +- assertVisible: "Test Device 1 (This device)" + +# Generate Invite Link (Approval OFF) +- tapOn: + id: "floating_action_button" +- assertVisible: "Add device" +- tapOn: "Another device" +- assertVisible: + id: "app_title" + text: "Add shared school device" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "Approval required" #switch is On +- assertVisible: "Copy link" +- assertVisible: "Send link via SMS" +- assertVisible: "Send link via email" +- assertVisible: "Share link" +- assertVisible: "Reset code" +- tapOn: "Approval required" # turn the switch off +- assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_url" + +# Enable Test Device 2 +- clearState: world.respect.app +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${maestro.copiedText} +- tapOn: "Get Started" +- assertVisible: + id: "app_title" + text: "Enable shared school device mode" +- tapOn: + text: "Device name *" + index: 1 +- inputText: "Test Device 2" +- tapOn: "Enable" +- assertVisible: + id: "app_title" + text: "Select class" +- assertVisible: "Scan QR code badge" +- assertVisible: "Teacher/admin login" +- tapOn: "New Class" +- assertVisible: + id: "app_title" + text: "New Class" +- tapOn: "StudentA User" +- assertVisible: + id: "app_title" + text: "Assignments" +- assertVisible: "Assignments" +- assertVisible: "Apps" +- assertNotVisible: "Class" +- assertNotVisible: "People" +- tapOn: + id: "user_account_icon" +- assertNotVisible: "Share Feedback" +- assertVisible: "StudentA User" +- tapOn: "Logout" +- assertVisible: + id: "app_title" + text: "Select class" + +# Teacher login to shared school device to see devices update +- tapOn: "Teacher/admin login" +- assertVisible: + id: "app_title" + text: "Teacher/admin login" +- tapOn: "Enter school device PIN" +- inputText: "1234" +- tapOn: "Next" +- tapOn: + id: "username" +- inputText: teacherauser +- tapOn: + id : "password" +- inputText: test123 +- tapOn: "Login" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- tapOn: "Apps" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: + id: "Settings" +- assertVisible: + id: "app_title" + text: "Settings" +- tapOn: "School" +- assertVisible: + id: "app_title" + text: "School" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "Shared school device" +- assertVisible: "2 devices" +- tapOn: "Shared school device" +- assertVisible: + id: "app_title" + text: "Shared school device" +- tapOn: + id: "self_select_their_class" #switch is OFF +- assertVisible: "Teacher/admin unlock PIN" +- assertVisible: "Devices (2)" +- assertVisible: "Test Device 2 (This device)" +- assertVisible: "Test Device 1" +- tapOn: + id: "user_account_icon" +- tapOn: "Logout" + +# Validating flow when the Student can self-select their class and name switch is OFF +- assertVisible: + id: "app_title" + text: "Login" +- assertNotVisible: "New Class" +- assertVisible: "Teacher/admin login" +- tapOn: "Scan QR code badge" +- assertVisible: + id: "app_title" + text: "Scan QR code badge" +- tapOn: "More Options" +- tapOn: "Paste URL" +- tapOn: "Url" +- inputText: ${output.SCHOOL_URL}respect_school_link/personqrbadge/id/12312 +- tapOn: "OK" + +# DISABLED temporarily 21/Jan/2026 by Mike - how this is handled is being changed +# This part validate - When a device is in shared school device mode, if the user clicks scan a QR code badge button, then ONLY a QR code badge (student login badge) will be accepted. +#- inputText: ${maestro.copiedText} +#- tapOn: "OK" +#- assertVisible: "Invalid QR code scanned" +#- tapOn: "Try again" +#- assertVisible: +# id: "app_title" +# text: "Scan QR code badge" +#- tapOn: "More Options" +#- tapOn: "Paste URL" +#- tapOn: "Url" +#- inputText: ${output.SCHOOL_URL}respect_school_link/personqrbadge/id/12312 +#- tapOn: "Ok" + +- assertVisible: + id: "app_title" + text: "Assignments" +- assertVisible: "Assignments" +- assertVisible: "Apps" +- assertNotVisible: "Class" +- assertNotVisible: "People" +- tapOn: + id: "user_account_icon" +- assertNotVisible: "Share Feedback" +- assertVisible: "StudentA User" +- tapOn: "Logout" + diff --git a/.maestro/flows/subflows/add_person_to_a_class.yaml b/.maestro/flows/subflows/add_person_to_a_class.yaml new file mode 100644 index 000000000..3e1c941d8 --- /dev/null +++ b/.maestro/flows/subflows/add_person_to_a_class.yaml @@ -0,0 +1,50 @@ +appId: world.respect.app + +--- +# Add a person to a class and create account +- tapOn: ${ADD_USER} +- tapOn: "Add person" +- assertVisible: + id: "app_title" + text: "Add person" +- tapOn: "First names*" +- inputText: ${FIRSTNAME} +- tapOn: "Last name*" +- inputText: ${LASTNAME} +- tapOn: "Gender*" +- tapOn: ${GENDER} +- tapOn: + id: "role" +- tapOn: ${ROLE} +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: ${CLASS_NAME} +- tapOn: ${FIRSTNAME} ${LASTNAME} +- tapOn: "Create account" +- assertVisible: + id: "Username" + text: ${USERNAME} +- runFlow: + when: + notVisible: 'Set password' + commands: + - tapOn: "Password" + - inputText: ${PASSWORD} + - tapOn: "Save" +- runFlow: + when: + visible: 'Set password' + commands: + - tapOn: "Set password" + - tapOn: "Password*" + - inputText: ${PASSWORD} + - tapOn: "Save" +- runFlow: + when: + visible: 'Assign QR code badge' + file: "assign_qr_badge_flow.yaml" + env: + QR_BADGE_LINK: ${QR_BADGE_LINK} + + diff --git a/.maestro/flows/subflows/admin_add_class.yaml b/.maestro/flows/subflows/admin_add_class.yaml index 6d0d4f4dc..268c23c91 100644 --- a/.maestro/flows/subflows/admin_add_class.yaml +++ b/.maestro/flows/subflows/admin_add_class.yaml @@ -12,5 +12,5 @@ appId: world.respect.app id: "app_title" text: "Add class" - tapOn: "Class name*" -- inputText: ${CLASSNAME} +- inputText: ${CLASS_NAME} - tapOn: "Save" 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 9203819a4..393848e1b 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt @@ -6,8 +6,6 @@ import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.russhwolf.settings.Settings import com.russhwolf.settings.SharedPreferencesSettings -import world.respect.shared.domain.phonenumber.IPhoneNumberUtil -import world.respect.shared.domain.phonenumber.IPhoneNumberUtilAndroid import com.ustadmobile.core.domain.storage.GetOfflineStorageOptionsUseCase import com.ustadmobile.libcache.CachePathsProvider import com.ustadmobile.libcache.UstadCache @@ -88,8 +86,8 @@ import world.respect.datalayer.school.writequeue.EnqueueDrainRemoteWriteQueueUse import world.respect.datalayer.school.writequeue.EnqueueRunPullSyncUseCase import world.respect.datalayer.school.writequeue.RemoteWriteQueue import world.respect.datalayer.schooldirectory.SchoolDirectoryDataSourceLocal -import world.respect.datalayer.shared.pullsync.PullSyncTracker import world.respect.datalayer.shared.XXHashUidNumberMapper +import world.respect.datalayer.shared.pullsync.PullSyncTracker import world.respect.lib.primarykeygen.PrimaryKeyGenerator import world.respect.libutil.ext.sanitizedForFilename import world.respect.libxxhash.XXHasher64Factory @@ -100,21 +98,21 @@ import world.respect.shared.domain.account.RespectAccount import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.account.RespectAccountSchoolScopeLink import world.respect.shared.domain.account.RespectTokenManager -import world.respect.shared.domain.account.child.AddChildAccountUseCase import world.respect.shared.domain.account.authenticatepassword.AuthenticatePasswordUseCase +import world.respect.shared.domain.account.child.AddChildAccountUseCase import world.respect.shared.domain.account.child.AddChildAccountUseCaseClient import world.respect.shared.domain.account.gettokenanduser.GetTokenAndUserProfileWithCredentialUseCase import world.respect.shared.domain.account.gettokenanduser.GetTokenAndUserProfileWithCredentialUseCaseClient import world.respect.shared.domain.account.invite.ApproveOrDeclineInviteRequestUseCase +import world.respect.shared.domain.account.invite.EnableSharedDeviceModeUseCase import world.respect.shared.domain.account.invite.GetInviteInfoUseCase import world.respect.shared.domain.account.invite.GetInviteInfoUseCaseClient import world.respect.shared.domain.account.invite.RedeemInviteUseCase import world.respect.shared.domain.account.invite.RedeemInviteUseCaseClient -import world.respect.shared.domain.navigation.onaccountcreated.NavigateOnAccountCreatedUseCase import world.respect.shared.domain.account.passkey.EncodeUserHandleUseCaseImpl -import world.respect.shared.domain.account.passkey.GetPasskeyProviderInfoUseCaseImpl import world.respect.shared.domain.account.passkey.GetActivePersonPasskeysClient import world.respect.shared.domain.account.passkey.GetActivePersonPasskeysUseCase +import world.respect.shared.domain.account.passkey.GetPasskeyProviderInfoUseCaseImpl import world.respect.shared.domain.account.passkey.LoadAaguidJsonUseCase import world.respect.shared.domain.account.passkey.LoadAaguidJsonUseCaseAndroid import world.respect.shared.domain.account.passkey.RevokePasskeyUseCase @@ -122,6 +120,12 @@ import world.respect.shared.domain.account.passkey.RevokePasskeyUseCaseClient import world.respect.shared.domain.account.passkey.VerifyPasskeyUseCase import world.respect.shared.domain.account.setpassword.EncryptPersonPasswordUseCase import world.respect.shared.domain.account.setpassword.EncryptPersonPasswordUseCaseImpl +import world.respect.shared.domain.account.sharedschooldevice.GetSharedDeviceSelfSelectUseCase +import world.respect.shared.domain.account.sharedschooldevice.SetSharedDeviceSelfSelectUseCase +import world.respect.shared.domain.account.sharedschooldevice.setpin.GetSharedDevicePINUseCase +import world.respect.shared.domain.account.sharedschooldevice.setpin.GetSharedDevicePINUseCaseImpl +import world.respect.shared.domain.account.sharedschooldevice.setpin.SetSharedDevicePINUseCase +import world.respect.shared.domain.account.sharedschooldevice.setpin.SetSharedDevicePINUseCaseImpl import world.respect.shared.domain.account.username.UsernameSuggestionUseCase import world.respect.shared.domain.account.username.UsernameSuggestionUseCaseClient import world.respect.shared.domain.account.username.filterusername.FilterUsernameUseCase @@ -130,12 +134,14 @@ import world.respect.shared.domain.account.validatepassword.ValidatePasswordUseC import world.respect.shared.domain.account.validateqrbadge.ValidateQrCodeUseCase import world.respect.shared.domain.appversioninfo.GetAppVersionInfoUseCase import world.respect.shared.domain.appversioninfo.GetAppVersionInfoUseCaseAndroid +import world.respect.shared.domain.biometric.BiometricAuthUseCase +import world.respect.shared.domain.biometric.BiometricAuthUseCaseAndroidImpl import world.respect.shared.domain.clipboard.SetClipboardStringUseCase import world.respect.shared.domain.clipboard.SetClipboardStringUseCaseAndroid +import world.respect.shared.domain.createclass.CreateClassUseCase import world.respect.shared.domain.createlink.CreateInviteLinkUseCase import world.respect.shared.domain.devmode.GetDevModeEnabledUseCase import world.respect.shared.domain.devmode.SetDevModeEnabledUseCase -import world.respect.shared.domain.school.LaunchCustomTabUseCaseAndroid import world.respect.shared.domain.getdeviceinfo.GetDeviceInfoUseCase import world.respect.shared.domain.getdeviceinfo.GetDeviceInfoUseCaseAndroid import world.respect.shared.domain.getwarnings.GetWarningsUseCase @@ -143,9 +149,17 @@ import world.respect.shared.domain.getwarnings.GetWarningsUseCaseAndroid import world.respect.shared.domain.launchapp.LaunchAppUseCase import world.respect.shared.domain.launchapp.LaunchAppUseCaseAndroid import world.respect.shared.domain.navigation.deeplink.CustomDeepLinkToUrlUseCase +import world.respect.shared.domain.navigation.deeplink.InitDeepLinkUriProviderUseCase +import world.respect.shared.domain.navigation.deeplink.InitDeepLinkUriProviderUseCaseAndroid import world.respect.shared.domain.navigation.deeplink.UrlToCustomDeepLinkUseCase +import world.respect.shared.domain.navigation.deferreddeeplink.GetDeferredDeepLinkUseCase +import world.respect.shared.domain.navigation.deferreddeeplink.GetDeferredDeepLinkUseCaseAndroid +import world.respect.shared.domain.navigation.onaccountcreated.NavigateOnAccountCreatedUseCase +import world.respect.shared.domain.navigation.onappstart.NavigateOnAppStartUseCase import world.respect.shared.domain.onboarding.ShouldShowOnboardingUseCase import world.respect.shared.domain.permissions.CheckSchoolPermissionsUseCase +import world.respect.shared.domain.phonenumber.IPhoneNumberUtil +import world.respect.shared.domain.phonenumber.IPhoneNumberUtilAndroid import world.respect.shared.domain.phonenumber.OnClickPhoneNumUseCase import world.respect.shared.domain.phonenumber.OnClickPhoneNumberUseCaseAndroid import world.respect.shared.domain.phonenumber.PhoneNumValidatorAndroid @@ -154,12 +168,20 @@ import world.respect.shared.domain.report.formatter.CreateGraphFormatterUseCase import world.respect.shared.domain.report.query.MockRunReportUseCaseClientImpl import world.respect.shared.domain.report.query.RunReportUseCase import world.respect.shared.domain.school.LaunchCustomTabUseCase +import world.respect.shared.domain.school.LaunchCustomTabUseCaseAndroid import world.respect.shared.domain.school.RespectSchoolPath import world.respect.shared.domain.school.SchoolPrimaryKeyGenerator +import world.respect.shared.domain.sendinvite.LaunchSendEmailAndroid +import world.respect.shared.domain.sendinvite.LaunchSendSmsAndroid +import world.respect.shared.domain.sendinvite.LaunchShareLinkAndroid +import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase +import world.respect.shared.domain.sharelink.LaunchSendSmsUseCase +import world.respect.shared.domain.sharelink.LaunchShareLinkUseCase import world.respect.shared.domain.storage.CachePathsProviderAndroid import world.respect.shared.domain.storage.GetAndroidSdCardDirUseCase import world.respect.shared.domain.storage.GetOfflineStorageOptionsUseCaseAndroid import world.respect.shared.domain.storage.GetOfflineStorageSettingUseCase +import world.respect.shared.domain.urltonavcommand.ResolveUrlToNavCommandUseCase import world.respect.shared.domain.usagereporting.GetUsageReportingEnabledUseCase import world.respect.shared.domain.usagereporting.GetUsageReportingEnabledUseCaseAndroid import world.respect.shared.domain.usagereporting.SetUsageReportingEnabledUseCase @@ -181,19 +203,21 @@ import world.respect.shared.viewmodel.apps.list.AppListViewModel import world.respect.shared.viewmodel.assignment.detail.AssignmentDetailViewModel import world.respect.shared.viewmodel.assignment.edit.AssignmentEditViewModel import world.respect.shared.viewmodel.assignment.list.AssignmentListViewModel -import world.respect.shared.viewmodel.enrollment.list.EnrollmentListViewModel -import world.respect.shared.viewmodel.enrollment.edit.EnrollmentEditViewModel import world.respect.shared.viewmodel.clazz.detail.ClazzDetailViewModel import world.respect.shared.viewmodel.clazz.edit.ClazzEditViewModel import world.respect.shared.viewmodel.clazz.list.ClazzListViewModel +import world.respect.shared.viewmodel.curriculum.mapping.edit.CurriculumMappingEditViewModel +import world.respect.shared.viewmodel.curriculum.mapping.list.CurriculumMappingListViewModel +import world.respect.shared.viewmodel.enrollment.edit.EnrollmentEditViewModel +import world.respect.shared.viewmodel.enrollment.list.EnrollmentListViewModel import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel -import world.respect.shared.viewmodel.manageuser.accountlist.AccountListViewModel import world.respect.shared.viewmodel.manageuser.acceptinvite.AcceptInviteViewModel +import world.respect.shared.viewmodel.manageuser.accountlist.AccountListViewModel +import world.respect.shared.viewmodel.manageuser.enterinvitecode.EnterInviteCodeViewModel import world.respect.shared.viewmodel.manageuser.enterpasswordsignup.EnterPasswordSignupViewModel import world.respect.shared.viewmodel.manageuser.getstarted.GetStartedViewModel import world.respect.shared.viewmodel.manageuser.howpasskeywork.HowPasskeyWorksViewModel -import world.respect.shared.viewmodel.manageuser.enterinvitecode.EnterInviteCodeViewModel import world.respect.shared.viewmodel.manageuser.login.LoginViewModel import world.respect.shared.viewmodel.manageuser.otheroption.OtherOptionsViewModel import world.respect.shared.viewmodel.manageuser.otheroptionsignup.OtherOptionsSignupViewModel @@ -205,18 +229,14 @@ import world.respect.shared.viewmodel.onboarding.OnboardingViewModel import world.respect.shared.viewmodel.person.changepassword.ChangePasswordViewModel import world.respect.shared.viewmodel.person.copycode.CopyInviteCodeViewModel import world.respect.shared.viewmodel.person.detail.PersonDetailViewModel -import world.respect.shared.domain.biometric.BiometricAuthUseCase -import world.respect.shared.domain.biometric.BiometricAuthUseCaseAndroidImpl -import world.respect.shared.domain.createclass.CreateClassUseCase -import world.respect.shared.domain.navigation.deferreddeeplink.GetDeferredDeepLinkUseCase -import world.respect.shared.domain.navigation.deeplink.InitDeepLinkUriProviderUseCase -import world.respect.shared.domain.navigation.deeplink.InitDeepLinkUriProviderUseCaseAndroid import world.respect.shared.viewmodel.person.edit.PersonEditViewModel -import world.respect.shared.viewmodel.person.list.PersonListViewModel import world.respect.shared.viewmodel.person.inviteperson.InvitePersonViewModel -import world.respect.shared.viewmodel.person.qrcode.InviteQrViewModel +import world.respect.shared.viewmodel.person.list.PersonListViewModel import world.respect.shared.viewmodel.person.manageaccount.ManageAccountViewModel import world.respect.shared.viewmodel.person.passkeylist.PasskeyListViewModel +import world.respect.shared.viewmodel.person.qrcode.InviteQrViewModel +import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccountSetPasswordViewModel +import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccountSetUserNameViewModel import world.respect.shared.viewmodel.report.ReportViewModel import world.respect.shared.viewmodel.report.detail.ReportDetailViewModel import world.respect.shared.viewmodel.report.edit.ReportEditViewModel @@ -226,25 +246,17 @@ import world.respect.shared.viewmodel.report.indictor.edit.IndicatorEditViewMode import world.respect.shared.viewmodel.report.indictor.list.IndicatorListViewModel import world.respect.shared.viewmodel.report.list.ReportListViewModel import world.respect.shared.viewmodel.report.list.ReportTemplateListViewModel -import world.respect.sharedse.domain.account.authenticatepassword.AuthenticatePasswordUseCaseDbImpl -import java.io.File -import world.respect.shared.viewmodel.settings.SettingsViewModel -import world.respect.shared.viewmodel.curriculum.mapping.list.CurriculumMappingListViewModel -import world.respect.shared.viewmodel.curriculum.mapping.edit.CurriculumMappingEditViewModel -import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccountSetPasswordViewModel -import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccountSetUserNameViewModel +import world.respect.shared.viewmodel.scanqrcode.ScanQRCodeViewModel import world.respect.shared.viewmodel.schooldirectory.edit.SchoolDirectoryEditViewModel import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryListViewModel -import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase -import world.respect.shared.domain.sharelink.LaunchShareLinkUseCase -import world.respect.shared.domain.sharelink.LaunchSendSmsUseCase -import world.respect.shared.domain.sendinvite.LaunchSendSmsAndroid -import world.respect.shared.domain.sendinvite.LaunchSendEmailAndroid -import world.respect.shared.domain.sendinvite.LaunchShareLinkAndroid -import world.respect.shared.domain.urltonavcommand.ResolveUrlToNavCommandUseCase -import world.respect.shared.viewmodel.scanqrcode.ScanQRCodeViewModel -import world.respect.shared.domain.navigation.deferreddeeplink.GetDeferredDeepLinkUseCaseAndroid -import world.respect.shared.domain.navigation.onappstart.NavigateOnAppStartUseCase +import world.respect.shared.viewmodel.settings.SettingsViewModel +import world.respect.shared.viewmodel.sharedschooldevice.SchoolSettingsViewModel +import world.respect.shared.viewmodel.sharedschooldevice.SharedDevicesSettingsViewmodel +import world.respect.shared.viewmodel.sharedschooldevice.TeacherPinConfirmationViewmodel +import world.respect.shared.viewmodel.sharedschooldevice.login.SelectClassViewModel +import world.respect.shared.viewmodel.sharedschooldevice.login.StudentListViewModel +import world.respect.sharedse.domain.account.authenticatepassword.AuthenticatePasswordUseCaseDbImpl +import java.io.File const val SHARED_PREF_SETTINGS_NAME = "respect_settings3_" @@ -384,6 +396,11 @@ val appKoinModule = module { viewModelOf(::EnrollmentEditViewModel) viewModelOf(::InviteQrViewModel) viewModelOf(::CreateAccountSetPasswordViewModel) + viewModelOf(::SchoolSettingsViewModel) + viewModelOf(::SharedDevicesSettingsViewmodel) + viewModelOf(::TeacherPinConfirmationViewmodel) + viewModelOf(::SelectClassViewModel) + viewModelOf(::StudentListViewModel) single { @@ -694,6 +711,12 @@ val appKoinModule = module { settings = get(), ) } + single { + EnableSharedDeviceModeUseCase( + accountManager = get(), + settings = get(), + ) + } /** * The SchoolDirectoryEntry scope might be one instance per school url or one instance per account @@ -743,6 +766,7 @@ val appKoinModule = module { RedeemInviteUseCaseClient( schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl, httpClient = get(), + accountManager = get() ) } @@ -923,6 +947,21 @@ val appKoinModule = module { ) } + scoped { + SetSharedDevicePINUseCaseImpl(schoolDataSource = get()) + } + scoped { + GetSharedDevicePINUseCaseImpl( + schoolDataSource = get(), + setSharedDevicePINUseCase = get() + ) + } + scoped { + GetSharedDeviceSelfSelectUseCase(schoolDataSource = get()) + } + scoped { + SetSharedDeviceSelfSelectUseCase(schoolDataSource = get()) + } scoped { ApproveOrDeclineInviteRequestUseCase( schoolDataSource = get(), diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt index 58ea4bf0b..b8c642155 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt @@ -42,6 +42,8 @@ import org.koin.compose.getKoin import org.koin.compose.koinInject import world.respect.app.components.uiTextStringResource import world.respect.app.effects.NavControllerLogEffect +import world.respect.datalayer.school.ext.primaryRole +import world.respect.datalayer.school.model.PersonRoleEnum import world.respect.navigation.NavCommandEffect import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.biometric.BiometricAuthUseCase @@ -201,7 +203,7 @@ fun App( navController = navController, topLevelItems = topLevelNavItems, onProfileClick = { - if (activeAccount?.isChild == false) { + if (activeAccount?.isChild == false || activeAccount?.person?.primaryRole() == PersonRoleEnum.STUDENT) { navController.navigate(AccountList) }else { coroutineScope.launch { 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..4f9ac366d 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 @@ -24,13 +24,13 @@ import world.respect.app.view.enrollment.list.EnrollmentListScreen import world.respect.app.view.home.HomeScreen import world.respect.app.view.learningunit.detail.LearningUnitDetailScreen import world.respect.app.view.learningunit.list.LearningUnitListScreen -import world.respect.app.view.manageuser.accountlist.AccountListScreen import world.respect.app.view.manageuser.acceptinvite.AcceptInviteScreen +import world.respect.app.view.manageuser.accountlist.AccountListScreen import world.respect.app.view.manageuser.createaccount.CreateAccountScreen +import world.respect.app.view.manageuser.enterinvitecode.EnterInviteCodeScreen import world.respect.app.view.manageuser.enterpasswordsignup.EnterPasswordSignupScreen import world.respect.app.view.manageuser.getstarted.GetStartedScreen import world.respect.app.view.manageuser.howpasskeywork.HowPasskeyWorksScreen -import world.respect.app.view.manageuser.enterinvitecode.EnterInviteCodeScreen import world.respect.app.view.manageuser.login.LoginScreen import world.respect.app.view.manageuser.otheroption.OtherOptionsScreen import world.respect.app.view.manageuser.otheroptionsignup.OtherOptionsSignupScreen @@ -61,7 +61,13 @@ 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.sharedschooldevice.SchoolSettingsScreen +import world.respect.app.view.sharedschooldevice.SharedDevicesSettingsScreen +import world.respect.app.view.sharedschooldevice.TeacherPinConfirmationScreen +import world.respect.app.view.sharedschooldevice.login.SelectClassScreen +import world.respect.app.view.sharedschooldevice.login.StudentListScreen import world.respect.app.viewmodel.respectViewModel +import world.respect.shared.navigation.AcceptInvite import world.respect.shared.navigation.AccountList import world.respect.shared.navigation.Acknowledgement import world.respect.shared.navigation.AppsDetail @@ -72,7 +78,6 @@ import world.respect.shared.navigation.ChangePassword import world.respect.shared.navigation.ClazzDetail import world.respect.shared.navigation.ClazzEdit import world.respect.shared.navigation.ClazzList -import world.respect.shared.navigation.AcceptInvite import world.respect.shared.navigation.CopyCode import world.respect.shared.navigation.CreateAccount import world.respect.shared.navigation.CreateAccountSetPassword @@ -81,6 +86,7 @@ import world.respect.shared.navigation.CurriculumMappingEdit import world.respect.shared.navigation.CurriculumMappingList import world.respect.shared.navigation.EnrollmentEdit import world.respect.shared.navigation.EnrollmentList +import world.respect.shared.navigation.EnterInviteCode import world.respect.shared.navigation.EnterLink import world.respect.shared.navigation.EnterPasswordSignup import world.respect.shared.navigation.GetStartedScreen @@ -89,7 +95,6 @@ import world.respect.shared.navigation.IndicatorDetail import world.respect.shared.navigation.IndicatorList import world.respect.shared.navigation.IndictorEdit import world.respect.shared.navigation.InvitePerson -import world.respect.shared.navigation.EnterInviteCode import world.respect.shared.navigation.Home import world.respect.shared.navigation.LearningUnitDetail import world.respect.shared.navigation.LearningUnitList @@ -114,8 +119,13 @@ import world.respect.shared.navigation.RespectComposeNavController import world.respect.shared.navigation.ScanQRCode import world.respect.shared.navigation.SchoolDirectoryEdit import world.respect.shared.navigation.SchoolDirectoryList +import world.respect.shared.navigation.SchoolSettings +import world.respect.shared.navigation.SelectClass import world.respect.shared.navigation.Settings +import world.respect.shared.navigation.SharedDevicesSettings import world.respect.shared.navigation.SignupScreen +import world.respect.shared.navigation.StudentList +import world.respect.shared.navigation.TeacherPinConfirmation import world.respect.shared.navigation.TermsAndCondition import world.respect.shared.navigation.WaitingForApproval import world.respect.shared.viewmodel.acknowledgement.AcknowledgementViewModel @@ -134,10 +144,10 @@ import world.respect.shared.viewmodel.enrollment.list.EnrollmentListViewModel import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel import world.respect.shared.viewmodel.manageuser.acceptinvite.AcceptInviteViewModel +import world.respect.shared.viewmodel.manageuser.enterinvitecode.EnterInviteCodeViewModel import world.respect.shared.viewmodel.manageuser.enterpasswordsignup.EnterPasswordSignupViewModel import world.respect.shared.viewmodel.manageuser.getstarted.GetStartedViewModel import world.respect.shared.viewmodel.manageuser.howpasskeywork.HowPasskeyWorksViewModel -import world.respect.shared.viewmodel.manageuser.enterinvitecode.EnterInviteCodeViewModel import world.respect.shared.viewmodel.manageuser.login.LoginViewModel import world.respect.shared.viewmodel.manageuser.otheroption.OtherOptionsViewModel import world.respect.shared.viewmodel.manageuser.otheroptionsignup.OtherOptionsSignupViewModel @@ -574,6 +584,40 @@ fun AppNavHost( viewModel = viewModel ) } + composable { + SchoolSettingsScreen( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) + ) + } + + composable { + SharedDevicesSettingsScreen( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) + ) + } + + composable { + SelectClassScreen( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) + ) + } + composable { + TeacherPinConfirmationScreen( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) + ) + } composable { val viewModel: CurriculumMappingEditViewModel = respectViewModel( @@ -655,6 +699,15 @@ fun AppNavHost( ) ) } + + composable { + StudentListScreen( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) + ) + } } } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/acceptinvite/AcceptInviteScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/acceptinvite/AcceptInviteScreen.kt index 1c47c2a89..4f71a7627 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/acceptinvite/AcceptInviteScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/acceptinvite/AcceptInviteScreen.kt @@ -1,23 +1,37 @@ package world.respect.app.view.manageuser.acceptinvite +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.material.icons.Icons import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import world.respect.app.components.RespectDetailField import world.respect.app.components.defaultItemPadding @@ -26,11 +40,20 @@ import world.respect.datalayer.school.model.ClassInvite import world.respect.datalayer.school.model.NewUserInvite import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.class_name +import world.respect.shared.generated.resources.device_name +import world.respect.shared.generated.resources.enable +import world.respect.shared.generated.resources.image_shared_device import world.respect.shared.generated.resources.loading import world.respect.shared.generated.resources.next +import world.respect.shared.generated.resources.required_field import world.respect.shared.generated.resources.role import world.respect.shared.generated.resources.school_name import world.respect.shared.generated.resources.school_server_url +import world.respect.shared.generated.resources.shared_device +import world.respect.shared.generated.resources.shared_device_description_1 +import world.respect.shared.generated.resources.shared_device_description_2 +import world.respect.shared.generated.resources.shared_device_description_3 +import world.respect.shared.generated.resources.undraw_sync_pe2t_1 import world.respect.shared.util.ext.isLoading import world.respect.shared.util.ext.label import world.respect.shared.util.ext.roleLabel @@ -45,12 +68,19 @@ fun AcceptInviteScreen( ) { val uiState by viewModel.uiState.collectAsState() val appUiState by viewModel.appUiState.collectAsState() - - AcceptInviteScreen( - uiState = uiState, - appUiState = appUiState, - onClickNext = viewModel::onClickNext - ) + if (!uiState.isSharedDeviceMode) { + AcceptInviteScreen( + uiState = uiState, + appUiState = appUiState, + onClickNext = viewModel::onClickNext + ) + } else { + SharedSchoolDeviceEnableScreenContent( + uiState = uiState, + onDeviceNameChange = viewModel::updateDeviceName, + onEnableSharedDeviceMode = viewModel::enableSharedDeviceMode, + ) + } } @Composable @@ -74,7 +104,7 @@ fun AcceptInviteScreen( Spacer(Modifier.size(16.dp)) Text( - text =stringResource(Res.string.loading), + text = stringResource(Res.string.loading), modifier = Modifier.align(Alignment.CenterHorizontally), ) } @@ -98,7 +128,7 @@ fun AcceptInviteScreen( } invite != null -> { - when(invite) { + when (invite) { is NewUserInvite -> { RespectDetailField( modifier = Modifier.defaultItemPadding(), @@ -147,9 +177,123 @@ fun AcceptInviteScreen( } } } + } +} +@Composable +fun SharedSchoolDeviceEnableScreenContent( + uiState: AcceptInviteUiState, + onDeviceNameChange: (String) -> Unit = {}, + onEnableSharedDeviceMode: () -> Unit = {}, +) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .defaultItemPadding(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text( + text = stringResource(Res.string.device_name), + style = MaterialTheme.typography.bodyLarge + ) + } + item { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .testTag("device_name_input"), + value = uiState.deviceName, + label = { Text("${stringResource(Res.string.device_name)} *") }, + onValueChange = onDeviceNameChange, + singleLine = true, + isError = uiState.errorText != null, + supportingText = { + if (uiState.errorText != null) { + Text(text = "${stringResource(Res.string.required_field)}*") + } + } + ) + } + item { + SharedSchoolDeviceInfoBox( + onClickEnableSharedSchoolDeviceMode = onEnableSharedDeviceMode + ) + } + } +} +@Composable +private fun SharedSchoolDeviceInfoBox( + onClickEnableSharedSchoolDeviceMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp + ), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Image( + painter = painterResource(Res.drawable.undraw_sync_pe2t_1), + contentDescription = stringResource(Res.string.image_shared_device), + modifier = Modifier + .width(120.dp) + .height(100.dp) + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(Res.string.shared_device), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = " * ${stringResource(Res.string.shared_device_description_1)}", + style = MaterialTheme.typography.bodySmall + ) + Text( + text = " * ${stringResource(Res.string.shared_device_description_2)}", + style = MaterialTheme.typography.bodySmall + ) + Text( + text = " * ${stringResource(Res.string.shared_device_description_3)}", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + Button( + onClick = onClickEnableSharedSchoolDeviceMode, + modifier = Modifier + .fillMaxWidth() + .testTag("enable_button"), + enabled = true, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface + ), + ) { + Text(stringResource(Res.string.enable)) + } + } } - } + diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/AccountListScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/AccountListScreen.kt index 12e30efb2..da8140e8d 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/AccountListScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/AccountListScreen.kt @@ -128,10 +128,12 @@ fun AccountListScreen( items = uiState.accounts, key = { it.session.account.userGuid } ) { account -> - AccountListItem( - account = account, - onClickAccount = onClickAccount, - ) + if (account.session.profilePersonUid != null) { + AccountListItem( + account = account, + onClickAccount = onClickAccount, + ) + } } item("divider2") { diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/login/LoginScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/login/LoginScreen.kt index 8b6a0f196..12830037c 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/login/LoginScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/login/LoginScreen.kt @@ -28,6 +28,7 @@ import world.respect.app.components.uiTextStringResource import world.respect.shared.domain.account.username.validateusername.ValidateUsernameUseCase import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.i_have_an_invite_code +import world.respect.shared.generated.resources.select_another_school import world.respect.shared.generated.resources.login import world.respect.shared.generated.resources.password_label import world.respect.shared.generated.resources.username_label @@ -50,6 +51,7 @@ fun LoginScreen( onPasswordChanged = viewModel::onPasswordChanged, onClickLogin = viewModel::onClickLogin, onClickInviteCode = viewModel::onClickInviteCode, + onClickSelectAnotherSchool = viewModel::onClickSelectAnotherSchool ) } @@ -60,7 +62,8 @@ fun LoginScreen( onUsernameChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit, onClickLogin: () -> Unit, - onClickInviteCode: () -> Unit = {} + onClickInviteCode: () -> Unit = {}, + onClickSelectAnotherSchool:() -> Unit, ) { Column( modifier = Modifier @@ -110,6 +113,14 @@ fun LoginScreen( ) { Text(text = stringResource(Res.string.login)) } + if (uiState.isSharedDevice) { + OutlinedButton( + onClick = onClickSelectAnotherSchool, + modifier = Modifier.fillMaxWidth().defaultItemPadding() + ) { + Text(text = stringResource(Res.string.select_another_school)) + } + } OutlinedButton( onClick = onClickInviteCode, diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt index 1abf71dfd..dae8e4367 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt @@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll -import androidx.compose.material.RadioButton +import androidx.compose.material3.RadioButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Email @@ -112,26 +112,38 @@ fun InvitePersonScreen( .fillMaxSize() .verticalScroll(rememberScrollState()) ) { - - if(uiState.showRoleSelection) { - val selectedRole = uiState.selectedRole ?: uiState.roleOptions.firstOrNull() + if (!uiState.isSharedDeviceMode) { + if (uiState.showRoleSelection) { + val selectedRole = uiState.selectedRole ?: uiState.roleOptions.firstOrNull() ?: PersonRoleEnum.STUDENT - RespectExposedDropDownMenuField( - value = selectedRole, - modifier = Modifier.defaultItemPadding().fillMaxWidth().testTag("role"), - label = { - Text(stringResource(Res.string.role)) - }, - onOptionSelected = { newRole -> - onRoleChange(newRole) - }, - options = uiState.roleOptions, - itemText = { stringResource(it.label) }, - enabled = fieldsEnabled, - ) + RespectExposedDropDownMenuField( + value = selectedRole, + modifier = Modifier + .defaultItemPadding() + .fillMaxWidth() + .testTag("role"), + label = { + Text(stringResource(Res.string.role)) + }, + onOptionSelected = { newRole -> + onRoleChange(newRole) + }, + options = uiState.roleOptions, + itemText = { stringResource(it.label) }, + enabled = fieldsEnabled, + ) + } + } + if (uiState.isSharedDeviceMode) { + uiState.schoolName?.let { + Text( + text = it, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally) + ) + } } - uiState.inviteUrl?.also { link -> val linkStr = link.toString() diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/settings/SettingsScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/settings/SettingsScreen.kt index e252880ed..787c83e06 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/settings/SettingsScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/settings/SettingsScreen.kt @@ -1,12 +1,14 @@ package world.respect.app.view.settings import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.School import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -20,11 +22,14 @@ import org.jetbrains.compose.resources.stringResource import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.loading import world.respect.shared.generated.resources.mappings +import world.respect.shared.generated.resources.policies_shared_devices +import world.respect.shared.generated.resources.school import world.respect.shared.viewmodel.settings.SettingsViewModel @Composable fun SettingsScreen( onNavigateToMapping: () -> Unit = {}, + onClickSchool: () -> Unit = {}, ) { LazyColumn( modifier = Modifier @@ -39,6 +44,15 @@ fun SettingsScreen( testTag = "mapping_setting_item" ) } + item { + SettingsListItem( + icon = Icons.Filled.School, + title = stringResource(Res.string.school), + onClick = onClickSchool, + testTag = "shared_devices_item", + description = stringResource(Res.string.policies_shared_devices), + ) + } } } @@ -47,14 +61,23 @@ private fun SettingsListItem( icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, onClick: () -> Unit, - testTag: String + testTag: String, + description: String? = null ) { ListItem( headlineContent = { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge - ) + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall + ) + } + } }, leadingContent = { Icon( @@ -79,6 +102,7 @@ fun SettingsScreenForViewModel( viewModel: SettingsViewModel ) { SettingsScreen( - onNavigateToMapping = viewModel::onNavigateToMapping + onNavigateToMapping = viewModel::onNavigateToMapping, + onClickSchool = viewModel::onClickSchool ) } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/SchoolSettingsScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/SchoolSettingsScreen.kt new file mode 100644 index 000000000..58747f62a --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/SchoolSettingsScreen.kt @@ -0,0 +1,73 @@ +package world.respect.app.view.sharedschooldevice + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +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.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.devices +import world.respect.shared.generated.resources.school_name +import world.respect.shared.generated.resources.shared_school_devices +import world.respect.shared.viewmodel.sharedschooldevice.SchoolSettingsViewModel + +@Composable +fun SchoolSettingsScreen( + viewModel: SchoolSettingsViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + Column { + SchoolSettingsScreen( + title = stringResource(Res.string.school_name), + description = uiState.schoolName ?: "", + testTag = "my_school", + ) + SchoolSettingsScreen( + title = stringResource(Res.string.shared_school_devices), + description = "${uiState.sharedSchoolDeviceCount.toString()} ${stringResource(Res.string.devices)}", + testTag = "devices_count", + onClick = viewModel::onClickSharedSchoolDevices + ) + } + +} + +@Composable +fun SchoolSettingsScreen( + title: String, + description: String, + onClick: () -> Unit = {}, + testTag: String +) { + ListItem( + headlineContent = { + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall + ) + } + }, + modifier = Modifier + .testTag(testTag) + .fillMaxWidth() + .clickable(onClick = onClick), + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface + ), + tonalElevation = 0.dp + ) +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/SharedDevicesSettingsScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/SharedDevicesSettingsScreen.kt new file mode 100644 index 000000000..5af7635a5 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/SharedDevicesSettingsScreen.kt @@ -0,0 +1,625 @@ +package world.respect.app.view.sharedschooldevice + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.paging.compose.collectAsLazyPagingItems +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +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.ext.getDeviceInfo +import world.respect.datalayer.school.model.Person +import world.respect.libutil.util.time.toFormattedDate +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.accept_invite +import world.respect.shared.generated.resources.add_device +import world.respect.shared.generated.resources.another_device +import world.respect.shared.generated.resources.another_device_add +import world.respect.shared.generated.resources.arrow_down_icon +import world.respect.shared.generated.resources.cancel +import world.respect.shared.generated.resources.close_icon +import world.respect.shared.generated.resources.devices +import world.respect.shared.generated.resources.dismiss_invite +import world.respect.shared.generated.resources.no_shared_devices_available +import world.respect.shared.generated.resources.no_shared_devices_available_info +import world.respect.shared.generated.resources.pending_device_requests +import world.respect.shared.generated.resources.phone_android_icon +import world.respect.shared.generated.resources.qr_code_badge +import world.respect.shared.generated.resources.save +import world.respect.shared.generated.resources.set_pin +import world.respect.shared.generated.resources.share_icon +import world.respect.shared.generated.resources.student_can_self_select_their_class_name +import world.respect.shared.generated.resources.tablet_android_last_seen +import world.respect.shared.generated.resources.teacher_admin_unlock_pin +import world.respect.shared.generated.resources.this_device +import world.respect.shared.generated.resources.this_device_enable +import world.respect.shared.generated.resources.undraw_bookmarks_i66k__1__1 +import world.respect.shared.resources.UiText +import world.respect.shared.viewmodel.sharedschooldevice.SharedDevicesSettingsUiState +import world.respect.shared.viewmodel.sharedschooldevice.SharedDevicesSettingsViewmodel + +@Composable +fun SharedDevicesSettingsScreen( + viewModel: SharedDevicesSettingsViewmodel, +) { + val uiState by viewModel.uiState.collectAsState() + SharedDevicesSettingsContent( + uiState = uiState, + onToggleSelfSelect = viewModel::toggleSelfSelect, + onShowPinDialog = viewModel::onShowPinDialog, + onTogglePendingInvites = viewModel::onTogglePendingInvites, + onClickAcceptOrDismissInvite = viewModel::onClickAcceptOrDismissInvite, + onRemoveDevice = viewModel::onRemoveDevice, + onSavePin = viewModel::onSavePin, + onDismissPinDialog = viewModel::onDismissPinDialog, + onAddAnotherDevice = { + viewModel.onClickAddAnotherDevice() + viewModel.onDismissBottomSheet() + }, + onEnableOnThisDevice = { + viewModel.onClickEnableOnThisDevice() + viewModel.onDismissBottomSheet() + }, + onDismissBottomSheet = viewModel::onDismissBottomSheet, + ) +} + +@Composable +private fun SharedDevicesSettingsContent( + uiState: SharedDevicesSettingsUiState, + onToggleSelfSelect: (Boolean) -> Unit, + onShowPinDialog: () -> Unit, + onTogglePendingInvites: () -> Unit, + onClickAcceptOrDismissInvite: (Person, Boolean) -> Unit, + onRemoveDevice: (Person) -> Unit, + onSavePin: (String) -> Unit, + onDismissPinDialog: () -> Unit, + onAddAnotherDevice: () -> Unit, + onEnableOnThisDevice: () -> Unit, + onDismissBottomSheet: () -> Unit, +) { + val focusManager = LocalFocusManager.current + + val pager = respectRememberPager(uiState.devices) + val lazyPagingItems = pager.flow.collectAsLazyPagingItems() + + val pendingPager = respectRememberPager(uiState.pendingDevices) + val pendingItems = pendingPager.flow.collectAsLazyPagingItems() + // Create sorted items list using remember with keys to trigger recomposition + val sortedItems = remember( + lazyPagingItems.itemCount, + lazyPagingItems.itemSnapshotList, + uiState.currentDeviceGuid + ) { + lazyPagingItems.itemSnapshotList.items + .sortedWith( + compareBy { person -> + // Current device first (false before true) + person.guid != uiState.currentDeviceGuid + }.thenBy { it.fullName() } + ) + } + // Handle bottom sheet dismissal + LaunchedEffect(uiState.showBottomSheetOptions) { + if (uiState.showBottomSheetOptions) { + focusManager.clearFocus() + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .defaultItemPadding(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + SettingsOptionRow( + title = stringResource(Res.string.student_can_self_select_their_class_name), + checked = uiState.isSelfSelectClassAndName, + onCheckedChange = onToggleSelfSelect + ) + } + } + + item { + Row( + modifier = Modifier + .clickable { onShowPinDialog() } + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(Res.string.teacher_admin_unlock_pin), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = uiState.pin, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("set_pin") + ) + } + } + } + + // Pending Requests Dropdown Section + if (pendingItems.itemCount > 0) { + item("pending_person_header") { + ListItem( + modifier = Modifier.clickable { onTogglePendingInvites() }, + headlineContent = { + Text( + text = stringResource(Res.string.pending_device_requests) + + " (${pendingItems.itemCount})" + ) + }, + trailingContent = { + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + modifier = Modifier + .size(24.dp) + .rotate(if (uiState.isPendingExpanded) 0f else -90f), + contentDescription = stringResource(Res.string.arrow_down_icon) + ) + } + ) + } + } + + if (uiState.isPendingExpanded) { + respectPagingItems( + items = pendingItems, + key = { item, index -> (item?.guid ?: "") + index.toString() } + ) { device -> + device?.let { device -> + ListItem( + modifier = Modifier.clickable { }, + leadingContent = { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = stringResource(Res.string.phone_android_icon), + ) + }, + headlineContent = { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = device.givenName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "${device.getDeviceInfo()} ${ + stringResource( + Res.string.tablet_android_last_seen + ) + }: ${device.lastModified.toString().toFormattedDate()}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + trailingContent = { + Row { + Icon( + modifier = Modifier.size(24.dp) + .clickable { + device.also { + onClickAcceptOrDismissInvite( + it, + true + ) + } + }, + imageVector = Icons.Outlined.CheckCircle, + contentDescription = stringResource(resource = Res.string.accept_invite) + ) + + Spacer(Modifier.width(16.dp)) + + Icon( + modifier = Modifier.size(24.dp).clickable { + device.also { onClickAcceptOrDismissInvite(it, false) } + }, + imageVector = Icons.Outlined.Cancel, + contentDescription = stringResource(resource = Res.string.dismiss_invite) + ) + } + } + ) + } + } + } + + item { + Text( + text = stringResource(Res.string.devices) + " (${lazyPagingItems.itemCount})", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + } + if (lazyPagingItems.itemCount == 0) { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(top = 34.dp) + .fillMaxWidth() + ) { + Image( + painter = painterResource(Res.drawable.undraw_bookmarks_i66k__1__1), + contentDescription = stringResource(Res.string.qr_code_badge), + modifier = Modifier + ) + Text( + text = stringResource(Res.string.no_shared_devices_available), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(Res.string.no_shared_devices_available_info), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } else { + items(sortedItems.size) { index -> + val personDetails = sortedItems[index] + val isCurrentDevice = personDetails.guid == uiState.currentDeviceGuid + + ListItem( + modifier = Modifier.clickable { }, + leadingContent = { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = stringResource(Res.string.phone_android_icon), + ) + }, + headlineContent = { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = if (isCurrentDevice) { + "${personDetails.fullName()} (${stringResource(Res.string.this_device)})" + } else { + personDetails.fullName() + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + + Text( + text = personDetails.getDeviceInfo(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${ + stringResource(Res.string.tablet_android_last_seen) + }: ${personDetails.lastModified.toString().toFormattedDate()}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + trailingContent = { + IconButton( + onClick = { onRemoveDevice(personDetails) } + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.close_icon), + ) + } + } + ) + } + } + } + + // PIN Dialog + if (uiState.showPinDialog) { + PinEntryDialog( + onDismiss = onDismissPinDialog, + onSave = onSavePin, + errorMessage = uiState.error + ) + } + + // Bottom Sheet + if (uiState.showBottomSheetOptions && !uiState.showPinDialog) { + AddDeviceBottomSheet( + onDismiss = onDismissBottomSheet, + onAddAnotherDevice = onAddAnotherDevice, + onEnableOnThisDevice = onEnableOnThisDevice + ) + } + } +} + +@Composable +private fun SettingsOptionRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.testTag("self_select_their_class") + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddDeviceBottomSheet( + onDismiss: () -> Unit, + onAddAnotherDevice: () -> Unit, + onEnableOnThisDevice: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(Res.string.add_device), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onEnableOnThisDevice) + .padding(vertical = 16.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = stringResource(Res.string.phone_android_icon), + tint = MaterialTheme.colorScheme.primary + ) + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(Res.string.this_device), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(Res.string.this_device_enable), + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onAddAnotherDevice) + .padding(vertical = 16.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(Res.string.share_icon), + tint = MaterialTheme.colorScheme.primary + ) + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(Res.string.another_device), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(Res.string.another_device_add), + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PinEntryDialog( + onDismiss: () -> Unit, + onSave: (String) -> Unit, + errorMessage: UiText? = null, +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + var currentPin by remember { mutableStateOf("") } + + BasicAlertDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(), + ) { + Surface( + modifier = Modifier + .widthIn(max = 300.dp) + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(24.dp), + ) { + // Title + Text( + text = stringResource(Res.string.set_pin), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + BasicTextField( + value = currentPin, + onValueChange = { newPin -> + currentPin = newPin + }, + modifier = Modifier + .testTag("pin_text") + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.surfaceVariant) + .focusRequester(focusRequester) + .padding(8.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword + ), + ) + if (errorMessage != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = uiTextStringResource(errorMessage), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth() + ) + } + Spacer(modifier = Modifier.height(24.dp)) + + // Buttons Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + // Cancel Text + Text( + text = stringResource(Res.string.cancel), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clickable { onDismiss() } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + // Save Text + Text( + text = stringResource(Res.string.save), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clickable( + onClick = { onSave(currentPin) } + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) + } + } + } + } +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/TeacherPinConfirmationScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/TeacherPinConfirmationScreen.kt new file mode 100644 index 000000000..ebeb12722 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/TeacherPinConfirmationScreen.kt @@ -0,0 +1,107 @@ +package world.respect.app.view.sharedschooldevice + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import org.jetbrains.compose.resources.stringResource +import world.respect.app.components.defaultItemPadding +import world.respect.app.components.uiTextStringResource +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.enter_school_device_pin +import world.respect.shared.generated.resources.next +import world.respect.shared.viewmodel.sharedschooldevice.TeacherPinConfirmationUiState +import world.respect.shared.viewmodel.sharedschooldevice.TeacherPinConfirmationViewmodel + + +@Composable +fun TeacherPinConfirmationScreen( + viewModel: TeacherPinConfirmationViewmodel, +) { + val uiState by viewModel.uiState.collectAsState(context = Dispatchers.Main.immediate) + TeacherPinConfirmationScreen( + uiState = uiState, + onPinChanged = viewModel::onPinChanged, + onClickNext = viewModel::onClickNext + ) +} + +@Composable +fun TeacherPinConfirmationScreen( + uiState: TeacherPinConfirmationUiState, + onPinChanged: (String) -> Unit, + onClickNext: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + + Column( + modifier = Modifier + .fillMaxSize() + .defaultItemPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextField( + value = uiState.pin, + onValueChange = onPinChanged, + label = { + Text(text = stringResource(Res.string.enter_school_device_pin)) + }, + placeholder = { + Text(text = stringResource(Res.string.enter_school_device_pin)) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier + .testTag("Enter school device PIN") + .fillMaxWidth() + .focusRequester(focusRequester), + isError = uiState.errorMessage != null, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface + ), + supportingText = uiState.errorMessage?.let { errorMessage -> + { Text(uiTextStringResource(errorMessage)) } + } + ) + Spacer(modifier = Modifier.height(4.dp)) + + Button( + onClick = onClickNext, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface + ), + ) { + Text(text = stringResource(Res.string.next)) + } + } +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/login/SelectClassScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/login/SelectClassScreen.kt new file mode 100644 index 000000000..d72dbdde1 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/login/SelectClassScreen.kt @@ -0,0 +1,149 @@ +package world.respect.app.view.sharedschooldevice.login + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +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.datalayer.school.ClassDataSource +import world.respect.datalayer.school.model.Clazz +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.scan_qr_code +import world.respect.shared.generated.resources.teacher_admin_login +import world.respect.shared.viewmodel.sharedschooldevice.login.SelectClassUiState +import world.respect.shared.viewmodel.sharedschooldevice.login.SelectClassViewModel + +@Composable +fun SelectClassScreen( + viewModel: SelectClassViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + SelectClassScreen( + uiState = uiState, + onClickClazz = viewModel::onClickClazz, + onClickScanQrCode = viewModel::onClickScanQrCode, + onClickTeacherAdminLogin = viewModel::onClickTeacherAdminLogin + ) +} + +@Composable +fun SelectClassScreen( + uiState: SelectClassUiState, + onClickClazz: (Clazz) -> Unit, + onClickScanQrCode: () -> Unit, + onClickTeacherAdminLogin: () -> Unit, +) { + Box(modifier = Modifier.fillMaxSize()) { + if (uiState.isSelfSelectClassAndName) { + val pager = respectRememberPager(uiState.classes) + val lazyPagingItems = pager.flow.collectAsLazyPagingItems() + val listState = rememberLazyListState() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState + ) { + respectPagingItems( + items = lazyPagingItems, + key = { item, index -> item?.guid ?: index.toString() }, + contentType = { ClassDataSource.ENDPOINT_NAME }, + ) { clazz -> + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { + clazz?.also(onClickClazz) + }, + leadingContent = { + RespectPersonAvatar(name = clazz?.title ?: "") + }, + headlineContent = { + Text(text = clazz?.title ?: "") + } + ) + } + item { + Spacer(modifier = Modifier.padding(bottom = 160.dp)) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .defaultItemPadding(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Scan button appears bottom when self-select is enabled + if (uiState.isSelfSelectClassAndName) { + OutlinedButton( + onClick = onClickScanQrCode, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(Res.string.scan_qr_code)) + } + } + OutlinedButton( + onClick = onClickTeacherAdminLogin, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(Res.string.teacher_admin_login)) + } + + if (uiState.deviceName.isNotEmpty()) { + Text( + text = uiState.deviceName, + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Centered scan button (only when self-select is disabled) + if (!uiState.isSelfSelectClassAndName) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 120.dp), + contentAlignment = Alignment.Center + ) { + Button( + onClick = onClickScanQrCode, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface + ), + ) { + Text(text = stringResource(Res.string.scan_qr_code)) + } + } + } + } +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/login/StudentListScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/login/StudentListScreen.kt new file mode 100644 index 000000000..3b4ca6776 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/sharedschooldevice/login/StudentListScreen.kt @@ -0,0 +1,67 @@ +package world.respect.app.view.sharedschooldevice.login + +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.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.RespectPersonAvatar +import world.respect.app.components.respectPagingItems +import world.respect.app.components.respectRememberPager +import world.respect.datalayer.db.school.ext.fullName +import world.respect.datalayer.school.ClassDataSource +import world.respect.datalayer.school.model.Person +import world.respect.shared.viewmodel.sharedschooldevice.login.StudentListUiState +import world.respect.shared.viewmodel.sharedschooldevice.login.StudentListViewModel + +@Composable +fun StudentListScreen( + viewModel: StudentListViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + StudentListScreen( + uiState = uiState, + onClickStudent = viewModel::onClickStudent, + ) +} + +@Composable +fun StudentListScreen( + uiState: StudentListUiState, + onClickStudent: (Person) -> Unit, +) { + val pager = respectRememberPager(uiState.students) + + val lazyPagingItems = pager.flow.collectAsLazyPagingItems() + + LazyColumn(modifier = Modifier.fillMaxSize()) { + + respectPagingItems( + items = lazyPagingItems, + key = { item, index -> item?.guid ?: index.toString() }, + contentType = { ClassDataSource.ENDPOINT_NAME }, + ) { student -> + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { + student?.also(onClickStudent) + }, + + leadingContent = { + RespectPersonAvatar(name = student?.fullName() ?: "") + }, + + headlineContent = { + Text(text = student?.fullName() ?: "") + } + ) + } + } +} \ No newline at end of file diff --git a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/13.json b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/13.json new file mode 100644 index 000000000..a54f87a8e --- /dev/null +++ b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/13.json @@ -0,0 +1,1803 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "9a618b8d4ebbb7449220f872676e2ecd", + "entities": [ + { + "tableName": "SchoolAppEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`saUid` TEXT NOT NULL, `saUidNum` INTEGER NOT NULL, `saManifestUrl` TEXT NOT NULL, `saStatus` INTEGER NOT NULL, `saLastModified` INTEGER NOT NULL, `saStored` INTEGER NOT NULL, PRIMARY KEY(`saUidNum`))", + "fields": [ + { + "fieldPath": "saUid", + "columnName": "saUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saUidNum", + "columnName": "saUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saManifestUrl", + "columnName": "saManifestUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saStatus", + "columnName": "saStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saLastModified", + "columnName": "saLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saStored", + "columnName": "saStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "saUidNum" + ] + } + }, + { + "tableName": "PersonEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pGuid` TEXT NOT NULL, `pGuidHash` INTEGER NOT NULL, `pActive` INTEGER NOT NULL, `pStatus` INTEGER NOT NULL, `pLastModified` INTEGER NOT NULL, `pStored` INTEGER NOT NULL, `pMetadata` TEXT, `pUsername` TEXT, `pGivenName` TEXT NOT NULL, `pFamilyName` TEXT NOT NULL, `pMiddleName` TEXT, `pGender` INTEGER NOT NULL, `pDateOfBirth` INTEGER, `pEmail` TEXT, `pPhoneNumber` TEXT, PRIMARY KEY(`pGuidHash`))", + "fields": [ + { + "fieldPath": "pGuid", + "columnName": "pGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pGuidHash", + "columnName": "pGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pActive", + "columnName": "pActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pStatus", + "columnName": "pStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pLastModified", + "columnName": "pLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pStored", + "columnName": "pStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pMetadata", + "columnName": "pMetadata", + "affinity": "TEXT" + }, + { + "fieldPath": "pUsername", + "columnName": "pUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "pGivenName", + "columnName": "pGivenName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pFamilyName", + "columnName": "pFamilyName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pMiddleName", + "columnName": "pMiddleName", + "affinity": "TEXT" + }, + { + "fieldPath": "pGender", + "columnName": "pGender", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pDateOfBirth", + "columnName": "pDateOfBirth", + "affinity": "INTEGER" + }, + { + "fieldPath": "pEmail", + "columnName": "pEmail", + "affinity": "TEXT" + }, + { + "fieldPath": "pPhoneNumber", + "columnName": "pPhoneNumber", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pGuidHash" + ] + } + }, + { + "tableName": "PersonRoleEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`prUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `prPersonGuidHash` INTEGER NOT NULL, `prIsPrimaryRole` INTEGER NOT NULL, `prRoleEnum` INTEGER NOT NULL, `prBeginDate` INTEGER, `prEndDate` INTEGER)", + "fields": [ + { + "fieldPath": "prUid", + "columnName": "prUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prPersonGuidHash", + "columnName": "prPersonGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prIsPrimaryRole", + "columnName": "prIsPrimaryRole", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prRoleEnum", + "columnName": "prRoleEnum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prBeginDate", + "columnName": "prBeginDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "prEndDate", + "columnName": "prEndDate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "prUid" + ] + } + }, + { + "tableName": "PersonRelatedPersonEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`prpUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `prpPersonUidNum` INTEGER NOT NULL, `prpOtherPersonUid` TEXT NOT NULL, `prpOtherPersonUidNum` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "prpUid", + "columnName": "prpUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prpPersonUidNum", + "columnName": "prpPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prpOtherPersonUid", + "columnName": "prpOtherPersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prpOtherPersonUidNum", + "columnName": "prpOtherPersonUidNum", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "prpUid" + ] + } + }, + { + "tableName": "PersonPasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ppwGuidNum` INTEGER NOT NULL, `ppwGuid` TEXT NOT NULL, `authAlgorithm` TEXT NOT NULL, `authEncoded` TEXT NOT NULL, `authSalt` TEXT NOT NULL, `authIterations` INTEGER NOT NULL, `authKeyLen` INTEGER NOT NULL, `ppwLastModified` INTEGER NOT NULL, `ppwStored` INTEGER NOT NULL, PRIMARY KEY(`ppwGuidNum`))", + "fields": [ + { + "fieldPath": "ppwGuidNum", + "columnName": "ppwGuidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppwGuid", + "columnName": "ppwGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authAlgorithm", + "columnName": "authAlgorithm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authEncoded", + "columnName": "authEncoded", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authSalt", + "columnName": "authSalt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authIterations", + "columnName": "authIterations", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authKeyLen", + "columnName": "authKeyLen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppwLastModified", + "columnName": "ppwLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppwStored", + "columnName": "ppwStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ppwGuidNum" + ] + } + }, + { + "tableName": "PersonPasskeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ppPersonUidNum` INTEGER NOT NULL, `ppCredentialId` TEXT NOT NULL, `ppLastModified` INTEGER NOT NULL, `ppStored` INTEGER NOT NULL, `ppAttestationObj` TEXT, `ppClientDataJson` TEXT, `ppOriginString` TEXT, `ppChallengeString` TEXT, `ppPublicKey` TEXT, `isRevoked` INTEGER NOT NULL, `ppDeviceName` TEXT NOT NULL DEFAULT '', `ppTimeCreated` INTEGER NOT NULL DEFAULT 0, `ppAaguid` TEXT NOT NULL DEFAULT '', `ppProviderName` TEXT NOT NULL DEFAULT '', `ppIconLight` TEXT NOT NULL DEFAULT '', `ppIconDark` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`ppPersonUidNum`, `ppCredentialId`))", + "fields": [ + { + "fieldPath": "ppPersonUidNum", + "columnName": "ppPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppCredentialId", + "columnName": "ppCredentialId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ppLastModified", + "columnName": "ppLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppStored", + "columnName": "ppStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppAttestationObj", + "columnName": "ppAttestationObj", + "affinity": "TEXT" + }, + { + "fieldPath": "ppClientDataJson", + "columnName": "ppClientDataJson", + "affinity": "TEXT" + }, + { + "fieldPath": "ppOriginString", + "columnName": "ppOriginString", + "affinity": "TEXT" + }, + { + "fieldPath": "ppChallengeString", + "columnName": "ppChallengeString", + "affinity": "TEXT" + }, + { + "fieldPath": "ppPublicKey", + "columnName": "ppPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "isRevoked", + "columnName": "isRevoked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppDeviceName", + "columnName": "ppDeviceName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppTimeCreated", + "columnName": "ppTimeCreated", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ppAaguid", + "columnName": "ppAaguid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppProviderName", + "columnName": "ppProviderName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppIconLight", + "columnName": "ppIconLight", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppIconDark", + "columnName": "ppIconDark", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ppPersonUidNum", + "ppCredentialId" + ] + } + }, + { + "tableName": "AuthTokenEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`atUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `atPGuidHash` INTEGER NOT NULL, `atPGuid` TEXT NOT NULL, `atCode` TEXT, `atToken` TEXT NOT NULL, `atTimeCreated` INTEGER NOT NULL, `atTtl` INTEGER NOT NULL, `atPlatform` TEXT, `atAndroidSdkInt` INTEGER, `atVersion` TEXT, `atManufacturer` TEXT, `atModel` TEXT, `atRam` INTEGER)", + "fields": [ + { + "fieldPath": "atUid", + "columnName": "atUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atPGuidHash", + "columnName": "atPGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atPGuid", + "columnName": "atPGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atCode", + "columnName": "atCode", + "affinity": "TEXT" + }, + { + "fieldPath": "atToken", + "columnName": "atToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atTimeCreated", + "columnName": "atTimeCreated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atTtl", + "columnName": "atTtl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atPlatform", + "columnName": "atPlatform", + "affinity": "TEXT" + }, + { + "fieldPath": "atAndroidSdkInt", + "columnName": "atAndroidSdkInt", + "affinity": "INTEGER" + }, + { + "fieldPath": "atVersion", + "columnName": "atVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "atManufacturer", + "columnName": "atManufacturer", + "affinity": "TEXT" + }, + { + "fieldPath": "atModel", + "columnName": "atModel", + "affinity": "TEXT" + }, + { + "fieldPath": "atRam", + "columnName": "atRam", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "atUid" + ] + } + }, + { + "tableName": "ReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rGuid` TEXT NOT NULL, `rOwnerGuid` TEXT NOT NULL, `rTitle` TEXT NOT NULL, `rOptions` TEXT NOT NULL, `rIsTemplate` INTEGER NOT NULL, `rActive` INTEGER NOT NULL, `rLastModified` INTEGER NOT NULL, `rStored` INTEGER NOT NULL, PRIMARY KEY(`rGuid`))", + "fields": [ + { + "fieldPath": "rGuid", + "columnName": "rGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rOwnerGuid", + "columnName": "rOwnerGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rTitle", + "columnName": "rTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rOptions", + "columnName": "rOptions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rIsTemplate", + "columnName": "rIsTemplate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rActive", + "columnName": "rActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rLastModified", + "columnName": "rLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rStored", + "columnName": "rStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "rGuid" + ] + } + }, + { + "tableName": "IndicatorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`indicatorId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `type` TEXT NOT NULL, `sql` TEXT NOT NULL, PRIMARY KEY(`indicatorId`))", + "fields": [ + { + "fieldPath": "indicatorId", + "columnName": "indicatorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sql", + "columnName": "sql", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "indicatorId" + ] + } + }, + { + "tableName": "ClassEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cGuid` TEXT NOT NULL, `cGuidHash` INTEGER NOT NULL, `cTitle` TEXT NOT NULL, `cStatus` INTEGER NOT NULL, `cDescription` TEXT, `cLastModified` INTEGER NOT NULL, `cStored` INTEGER NOT NULL, `cTeacherInviteGuid` TEXT, `cStudentInviteGuid` TEXT, PRIMARY KEY(`cGuidHash`))", + "fields": [ + { + "fieldPath": "cGuid", + "columnName": "cGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cGuidHash", + "columnName": "cGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cTitle", + "columnName": "cTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cStatus", + "columnName": "cStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cDescription", + "columnName": "cDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "cLastModified", + "columnName": "cLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cStored", + "columnName": "cStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cTeacherInviteGuid", + "columnName": "cTeacherInviteGuid", + "affinity": "TEXT" + }, + { + "fieldPath": "cStudentInviteGuid", + "columnName": "cStudentInviteGuid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cGuidHash" + ] + } + }, + { + "tableName": "ClassPermissionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cpeId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `cpeClassUidNum` INTEGER NOT NULL, `cpeToEnrollmentRole` INTEGER, `cpePermissions` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "cpeId", + "columnName": "cpeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cpeClassUidNum", + "columnName": "cpeClassUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cpeToEnrollmentRole", + "columnName": "cpeToEnrollmentRole", + "affinity": "INTEGER" + }, + { + "fieldPath": "cpePermissions", + "columnName": "cpePermissions", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cpeId" + ] + } + }, + { + "tableName": "EnrollmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eUid` TEXT NOT NULL, `eUidNum` INTEGER NOT NULL, `eStatus` INTEGER NOT NULL, `eLastModified` INTEGER NOT NULL, `eStored` INTEGER NOT NULL, `eMetadata` TEXT, `eClassUid` TEXT NOT NULL, `eClassUidNum` INTEGER NOT NULL, `ePersonUid` TEXT NOT NULL, `ePersonUidNum` INTEGER NOT NULL, `eRole` INTEGER NOT NULL, `eBeginDate` INTEGER, `eEndDate` INTEGER, `eRemovedAt` INTEGER, `eInviteCode` TEXT, `eApprovedByPersonUidNum` INTEGER NOT NULL, `eApprovedByPersonUid` TEXT, PRIMARY KEY(`eUidNum`))", + "fields": [ + { + "fieldPath": "eUid", + "columnName": "eUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eUidNum", + "columnName": "eUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eStatus", + "columnName": "eStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eLastModified", + "columnName": "eLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eStored", + "columnName": "eStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eMetadata", + "columnName": "eMetadata", + "affinity": "TEXT" + }, + { + "fieldPath": "eClassUid", + "columnName": "eClassUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eClassUidNum", + "columnName": "eClassUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ePersonUid", + "columnName": "ePersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ePersonUidNum", + "columnName": "ePersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eRole", + "columnName": "eRole", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eBeginDate", + "columnName": "eBeginDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "eEndDate", + "columnName": "eEndDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "eRemovedAt", + "columnName": "eRemovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "eInviteCode", + "columnName": "eInviteCode", + "affinity": "TEXT" + }, + { + "fieldPath": "eApprovedByPersonUidNum", + "columnName": "eApprovedByPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eApprovedByPersonUid", + "columnName": "eApprovedByPersonUid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eUidNum" + ] + } + }, + { + "tableName": "AssignmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`aeUid` TEXT NOT NULL, `aeUidNum` INTEGER NOT NULL, `aeTitle` TEXT NOT NULL, `aeDescription` TEXT NOT NULL, `aeClassUid` TEXT NOT NULL, `aeClassUidNum` INTEGER NOT NULL, `aeDeadline` INTEGER, `aeLastModified` INTEGER NOT NULL, `aeStored` INTEGER NOT NULL, PRIMARY KEY(`aeUidNum`))", + "fields": [ + { + "fieldPath": "aeUid", + "columnName": "aeUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeUidNum", + "columnName": "aeUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aeTitle", + "columnName": "aeTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeDescription", + "columnName": "aeDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeClassUid", + "columnName": "aeClassUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeClassUidNum", + "columnName": "aeClassUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aeDeadline", + "columnName": "aeDeadline", + "affinity": "INTEGER" + }, + { + "fieldPath": "aeLastModified", + "columnName": "aeLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aeStored", + "columnName": "aeStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "aeUidNum" + ] + } + }, + { + "tableName": "AssignmentLearningResourceRefEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alrrAeUidNum` INTEGER NOT NULL, `alrrLearningUnitManifestUrlHash` INTEGER NOT NULL, `alrrLearningUnitManifestUrl` TEXT NOT NULL, `alrrAppManifestUrl` TEXT NOT NULL, PRIMARY KEY(`alrrAeUidNum`, `alrrLearningUnitManifestUrlHash`))", + "fields": [ + { + "fieldPath": "alrrAeUidNum", + "columnName": "alrrAeUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alrrLearningUnitManifestUrlHash", + "columnName": "alrrLearningUnitManifestUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alrrLearningUnitManifestUrl", + "columnName": "alrrLearningUnitManifestUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alrrAppManifestUrl", + "columnName": "alrrAppManifestUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "alrrAeUidNum", + "alrrLearningUnitManifestUrlHash" + ] + } + }, + { + "tableName": "WriteQueueItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`wqiQueueItemId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `wqiModel` INTEGER NOT NULL, `wqiUid` TEXT NOT NULL, `wqiTimeQueued` INTEGER NOT NULL, `wqiAttemptCount` INTEGER NOT NULL, `wqiTimeWritten` INTEGER NOT NULL, `wqiAccountGuid` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "wqiQueueItemId", + "columnName": "wqiQueueItemId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiModel", + "columnName": "wqiModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiUid", + "columnName": "wqiUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wqiTimeQueued", + "columnName": "wqiTimeQueued", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiAttemptCount", + "columnName": "wqiAttemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiTimeWritten", + "columnName": "wqiTimeWritten", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiAccountGuid", + "columnName": "wqiAccountGuid", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "wqiQueueItemId" + ] + } + }, + { + "tableName": "SchoolPermissionGrantEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spgUid` TEXT NOT NULL, `spgUidNum` INTEGER NOT NULL, `spgStatusEnum` INTEGER NOT NULL, `spgToRole` INTEGER NOT NULL, `spgPermissions` INTEGER NOT NULL, `spgLastModified` INTEGER NOT NULL, `spgStored` INTEGER NOT NULL, PRIMARY KEY(`spgUidNum`))", + "fields": [ + { + "fieldPath": "spgUid", + "columnName": "spgUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spgUidNum", + "columnName": "spgUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgStatusEnum", + "columnName": "spgStatusEnum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgToRole", + "columnName": "spgToRole", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgPermissions", + "columnName": "spgPermissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgLastModified", + "columnName": "spgLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgStored", + "columnName": "spgStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "spgUidNum" + ] + } + }, + { + "tableName": "PullSyncStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pssAccountPersonUid` TEXT NOT NULL, `pssAccountPersonUidNum` INTEGER NOT NULL, `pssTableId` INTEGER NOT NULL, `pssLastConsistentThrough` INTEGER NOT NULL, `pssPermissionsLastModified` INTEGER NOT NULL, PRIMARY KEY(`pssAccountPersonUid`, `pssTableId`))", + "fields": [ + { + "fieldPath": "pssAccountPersonUid", + "columnName": "pssAccountPersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pssAccountPersonUidNum", + "columnName": "pssAccountPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pssTableId", + "columnName": "pssTableId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pssLastConsistentThrough", + "columnName": "pssLastConsistentThrough", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pssPermissionsLastModified", + "columnName": "pssPermissionsLastModified", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pssAccountPersonUid", + "pssTableId" + ] + } + }, + { + "tableName": "PersonQrBadgeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pqrGuidNum` INTEGER NOT NULL, `pqrGuid` TEXT NOT NULL, `pqrLastModified` INTEGER NOT NULL, `pqrStored` INTEGER NOT NULL, `pqrQrCodeUrl` TEXT, `pqrStatus` INTEGER NOT NULL, PRIMARY KEY(`pqrGuidNum`))", + "fields": [ + { + "fieldPath": "pqrGuidNum", + "columnName": "pqrGuidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pqrGuid", + "columnName": "pqrGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pqrLastModified", + "columnName": "pqrLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pqrStored", + "columnName": "pqrStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pqrQrCodeUrl", + "columnName": "pqrQrCodeUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "pqrStatus", + "columnName": "pqrStatus", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pqrGuidNum" + ] + }, + "indices": [ + { + "name": "index_PersonQrBadgeEntity_pqrQrCodeUrl", + "unique": false, + "columnNames": [ + "pqrQrCodeUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PersonQrBadgeEntity_pqrQrCodeUrl` ON `${TABLE_NAME}` (`pqrQrCodeUrl`)" + } + ] + }, + { + "tableName": "InviteEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`iGuid` TEXT NOT NULL, `iGuidHash` INTEGER NOT NULL, `iCode` TEXT NOT NULL, `iApprovalRequiredAfter` INTEGER NOT NULL, `iLastModified` INTEGER NOT NULL, `iStored` INTEGER NOT NULL, `iStatus` INTEGER NOT NULL, `iNewUserRole` INTEGER, `iNewUserFirstInvite` INTEGER NOT NULL, `iForFamilyOfGuid` TEXT, `iForFamilyOfGuidHash` INTEGER, `iForClassGuid` TEXT, `iForClassName` TEXT, `iInviteMode` INTEGER, `iSchoolName` TEXT, `iForClassGuidHash` INTEGER, `iForClassRole` INTEGER, PRIMARY KEY(`iGuidHash`))", + "fields": [ + { + "fieldPath": "iGuid", + "columnName": "iGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iGuidHash", + "columnName": "iGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iCode", + "columnName": "iCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iApprovalRequiredAfter", + "columnName": "iApprovalRequiredAfter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLastModified", + "columnName": "iLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iStored", + "columnName": "iStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iStatus", + "columnName": "iStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iNewUserRole", + "columnName": "iNewUserRole", + "affinity": "INTEGER" + }, + { + "fieldPath": "iNewUserFirstInvite", + "columnName": "iNewUserFirstInvite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iForFamilyOfGuid", + "columnName": "iForFamilyOfGuid", + "affinity": "TEXT" + }, + { + "fieldPath": "iForFamilyOfGuidHash", + "columnName": "iForFamilyOfGuidHash", + "affinity": "INTEGER" + }, + { + "fieldPath": "iForClassGuid", + "columnName": "iForClassGuid", + "affinity": "TEXT" + }, + { + "fieldPath": "iForClassName", + "columnName": "iForClassName", + "affinity": "TEXT" + }, + { + "fieldPath": "iInviteMode", + "columnName": "iInviteMode", + "affinity": "INTEGER" + }, + { + "fieldPath": "iSchoolName", + "columnName": "iSchoolName", + "affinity": "TEXT" + }, + { + "fieldPath": "iForClassGuidHash", + "columnName": "iForClassGuidHash", + "affinity": "INTEGER" + }, + { + "fieldPath": "iForClassRole", + "columnName": "iForClassRole", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "iGuidHash" + ] + } + }, + { + "tableName": "LangMapEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lmeId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lmeTopParentType` INTEGER NOT NULL, `lmeTopParentUid1` INTEGER NOT NULL, `lmeTopParentUid2` INTEGER NOT NULL, `lmePropType` INTEGER NOT NULL, `lmePropFk` INTEGER NOT NULL, `lmeLang` TEXT NOT NULL, `lmeRegion` TEXT, `lmeValue` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "lmeId", + "columnName": "lmeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeTopParentType", + "columnName": "lmeTopParentType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeTopParentUid1", + "columnName": "lmeTopParentUid1", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeTopParentUid2", + "columnName": "lmeTopParentUid2", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmePropType", + "columnName": "lmePropType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmePropFk", + "columnName": "lmePropFk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeLang", + "columnName": "lmeLang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lmeRegion", + "columnName": "lmeRegion", + "affinity": "TEXT" + }, + { + "fieldPath": "lmeValue", + "columnName": "lmeValue", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "lmeId" + ] + }, + "indices": [ + { + "name": "index_LangMapEntity_lmeTopParentType_lmeTopParentUid1_lmeTopParentUid2_lmePropType", + "unique": false, + "columnNames": [ + "lmeTopParentType", + "lmeTopParentUid1", + "lmeTopParentUid2", + "lmePropType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LangMapEntity_lmeTopParentType_lmeTopParentUid1_lmeTopParentUid2_lmePropType` ON `${TABLE_NAME}` (`lmeTopParentType`, `lmeTopParentUid1`, `lmeTopParentUid2`, `lmePropType`)" + } + ] + }, + { + "tableName": "ReadiumLinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rleId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rleOpdsParentType` INTEGER NOT NULL, `rleOpdsParentUid` INTEGER NOT NULL, `rlePropType` TEXT NOT NULL, `rlePropFk` INTEGER NOT NULL, `rleIndex` INTEGER NOT NULL, `rleHref` TEXT NOT NULL, `rleRel` TEXT, `rleType` TEXT, `rleTitle` TEXT, `rleTemplated` INTEGER, `rleProperties` TEXT, `rleHeight` INTEGER, `rleWidth` INTEGER, `rleSize` INTEGER, `rleBitrate` REAL, `rleDuration` REAL, `rleLanguage` TEXT)", + "fields": [ + { + "fieldPath": "rleId", + "columnName": "rleId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleOpdsParentType", + "columnName": "rleOpdsParentType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleOpdsParentUid", + "columnName": "rleOpdsParentUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rlePropType", + "columnName": "rlePropType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rlePropFk", + "columnName": "rlePropFk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleIndex", + "columnName": "rleIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleHref", + "columnName": "rleHref", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rleRel", + "columnName": "rleRel", + "affinity": "TEXT" + }, + { + "fieldPath": "rleType", + "columnName": "rleType", + "affinity": "TEXT" + }, + { + "fieldPath": "rleTitle", + "columnName": "rleTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "rleTemplated", + "columnName": "rleTemplated", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleProperties", + "columnName": "rleProperties", + "affinity": "TEXT" + }, + { + "fieldPath": "rleHeight", + "columnName": "rleHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleWidth", + "columnName": "rleWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleSize", + "columnName": "rleSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleBitrate", + "columnName": "rleBitrate", + "affinity": "REAL" + }, + { + "fieldPath": "rleDuration", + "columnName": "rleDuration", + "affinity": "REAL" + }, + { + "fieldPath": "rleLanguage", + "columnName": "rleLanguage", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rleId" + ] + } + }, + { + "tableName": "OpdsPublicationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`opeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opeOfeUid` INTEGER NOT NULL, `opeOgeUid` INTEGER NOT NULL, `opeIndex` INTEGER NOT NULL, `opeUrl` TEXT, `opeUrlHash` INTEGER NOT NULL, `opeLastModified` INTEGER NOT NULL, `opeEtag` TEXT, `opeMdIdentifier` TEXT, `opeMdLanguage` TEXT, `opeMdType` TEXT, `opeMdDescription` TEXT, `opeMdNumberOfPages` INTEGER, `opeMdDuration` REAL)", + "fields": [ + { + "fieldPath": "opeUid", + "columnName": "opeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeOfeUid", + "columnName": "opeOfeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeOgeUid", + "columnName": "opeOgeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeIndex", + "columnName": "opeIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeUrl", + "columnName": "opeUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "opeUrlHash", + "columnName": "opeUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeLastModified", + "columnName": "opeLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeEtag", + "columnName": "opeEtag", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdIdentifier", + "columnName": "opeMdIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdLanguage", + "columnName": "opeMdLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdType", + "columnName": "opeMdType", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdDescription", + "columnName": "opeMdDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdNumberOfPages", + "columnName": "opeMdNumberOfPages", + "affinity": "INTEGER" + }, + { + "fieldPath": "opeMdDuration", + "columnName": "opeMdDuration", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "opeUid" + ] + } + }, + { + "tableName": "ReadiumSubjectEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rseUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rseStringValue` TEXT, `rseTopParentType` INTEGER NOT NULL, `rseTopParentUid` INTEGER NOT NULL, `rseSubjectSortAs` TEXT, `rseSubjectCode` TEXT, `rseSubjectScheme` TEXT, `rseIndex` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "rseUid", + "columnName": "rseUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rseStringValue", + "columnName": "rseStringValue", + "affinity": "TEXT" + }, + { + "fieldPath": "rseTopParentType", + "columnName": "rseTopParentType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rseTopParentUid", + "columnName": "rseTopParentUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rseSubjectSortAs", + "columnName": "rseSubjectSortAs", + "affinity": "TEXT" + }, + { + "fieldPath": "rseSubjectCode", + "columnName": "rseSubjectCode", + "affinity": "TEXT" + }, + { + "fieldPath": "rseSubjectScheme", + "columnName": "rseSubjectScheme", + "affinity": "TEXT" + }, + { + "fieldPath": "rseIndex", + "columnName": "rseIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rseUid" + ] + } + }, + { + "tableName": "OpdsFacetEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofaeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ofaeOfeUid` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "ofaeUid", + "columnName": "ofaeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofaeOfeUid", + "columnName": "ofaeOfeUid", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ofaeUid" + ] + } + }, + { + "tableName": "OpdsGroupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ogeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ogeOfeUid` INTEGER NOT NULL, `ogeIndex` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "ogeUid", + "columnName": "ogeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ogeOfeUid", + "columnName": "ogeOfeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ogeIndex", + "columnName": "ogeIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ogeUid" + ] + } + }, + { + "tableName": "OpdsFeedEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofeUid` INTEGER NOT NULL, `ofeUrl` TEXT NOT NULL, `ofeUrlHash` INTEGER NOT NULL, `ofeLastModified` INTEGER NOT NULL, `ofeLastModifiedHeader` INTEGER NOT NULL, `ofeEtag` TEXT, `ofeStored` INTEGER NOT NULL, PRIMARY KEY(`ofeUid`))", + "fields": [ + { + "fieldPath": "ofeUid", + "columnName": "ofeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeUrl", + "columnName": "ofeUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ofeUrlHash", + "columnName": "ofeUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeLastModified", + "columnName": "ofeLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeLastModifiedHeader", + "columnName": "ofeLastModifiedHeader", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeEtag", + "columnName": "ofeEtag", + "affinity": "TEXT" + }, + { + "fieldPath": "ofeStored", + "columnName": "ofeStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ofeUid" + ] + } + }, + { + "tableName": "OpdsFeedMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofmeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ofmeOfeUid` INTEGER NOT NULL, `ofmePropType` INTEGER NOT NULL, `ofmePropFk` INTEGER NOT NULL, `ofmeIdentifier` TEXT, `ofmeType` TEXT, `ofmeTitle` TEXT NOT NULL, `ofmeSubtitle` TEXT, `ofmeModified` INTEGER, `ofmeDescription` TEXT, `ofmeItemsPerPage` INTEGER, `ofmeCurrentPage` INTEGER, `ofmeNumberOfItems` INTEGER)", + "fields": [ + { + "fieldPath": "ofmeUid", + "columnName": "ofmeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmeOfeUid", + "columnName": "ofmeOfeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmePropType", + "columnName": "ofmePropType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmePropFk", + "columnName": "ofmePropFk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmeIdentifier", + "columnName": "ofmeIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeType", + "columnName": "ofmeType", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeTitle", + "columnName": "ofmeTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ofmeSubtitle", + "columnName": "ofmeSubtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeModified", + "columnName": "ofmeModified", + "affinity": "INTEGER" + }, + { + "fieldPath": "ofmeDescription", + "columnName": "ofmeDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeItemsPerPage", + "columnName": "ofmeItemsPerPage", + "affinity": "INTEGER" + }, + { + "fieldPath": "ofmeCurrentPage", + "columnName": "ofmeCurrentPage", + "affinity": "INTEGER" + }, + { + "fieldPath": "ofmeNumberOfItems", + "columnName": "ofmeNumberOfItems", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ofmeUid" + ] + } + }, + { + "tableName": "SchoolConfigSettingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scsKey` TEXT NOT NULL, `scsValue` TEXT NOT NULL, `scsStatus` INTEGER NOT NULL, `scsLastModified` INTEGER NOT NULL, `scsStored` INTEGER NOT NULL, `scsCanReadFlags` INTEGER NOT NULL, `scsAnonCanRead` INTEGER NOT NULL, `scsCanWriteFlags` INTEGER NOT NULL, PRIMARY KEY(`scsKey`))", + "fields": [ + { + "fieldPath": "scsKey", + "columnName": "scsKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scsValue", + "columnName": "scsValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scsStatus", + "columnName": "scsStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsLastModified", + "columnName": "scsLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsStored", + "columnName": "scsStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsCanReadFlags", + "columnName": "scsCanReadFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsAnonCanRead", + "columnName": "scsAnonCanRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsCanWriteFlags", + "columnName": "scsCanWriteFlags", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "scsKey" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a618b8d4ebbb7449220f872676e2ecd')" + ] + } +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt index 5f41ee84a..8a491d2c6 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt @@ -44,6 +44,7 @@ import world.respect.datalayer.db.school.daos.PersonQrBadgeEntityDao import world.respect.datalayer.db.school.daos.PersonRelatedPersonEntityDao import world.respect.datalayer.db.school.daos.PullSyncStatusEntityDao import world.respect.datalayer.db.school.daos.SchoolAppEntityDao +import world.respect.datalayer.db.school.daos.SchoolConfigSettingEntityDao import world.respect.datalayer.db.school.daos.WriteQueueItemEntityDao import world.respect.datalayer.db.school.entities.AssignmentEntity import world.respect.datalayer.db.school.entities.AssignmentLearningResourceRefEntity @@ -59,6 +60,7 @@ import world.respect.datalayer.db.school.entities.WriteQueueItemEntity import world.respect.datalayer.db.school.daos.SchoolPermissionGrantDao import world.respect.datalayer.db.school.entities.ClassPermissionEntity import world.respect.datalayer.db.school.entities.PullSyncStatusEntity +import world.respect.datalayer.db.school.entities.SchoolConfigSettingEntity import world.respect.datalayer.db.school.entities.SchoolPermissionGrantEntity import world.respect.datalayer.school.model.Assignment import world.respect.datalayer.school.model.Clazz @@ -105,8 +107,10 @@ import world.respect.datalayer.school.model.Report OpdsGroupEntity::class, OpdsFeedEntity::class, OpdsFeedMetadataEntity::class, + + SchoolConfigSettingEntity::class, ], - version = 12, + version = 13, ) @TypeConverters(SharedConverters::class, SchoolTypeConverters::class, OpdsTypeConverters::class) @ConstructedBy(RespectSchoolDatabaseConstructor::class) @@ -162,6 +166,7 @@ abstract class RespectSchoolDatabase: RoomDatabase() { abstract fun getOpdsGroupEntityDao(): OpdsGroupEntityDao + abstract fun getSchoolConfigSettingEntityDao(): SchoolConfigSettingEntityDao companion object { diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabaseMigrations.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabaseMigrations.kt index 55818d1aa..fbb9a26dc 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabaseMigrations.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabaseMigrations.kt @@ -19,9 +19,24 @@ val MIGRATION_11_12 = object: Migration(11, 12) { } } +val MIGRATION_12_13 = object: Migration(12, 13) { + override fun migrate(connection: SQLiteConnection) { + + connection.execSQL("UPDATE PersonRoleEntity SET prRoleEnum = CASE WHEN prRoleEnum = 3 THEN 4 WHEN prRoleEnum = 4 THEN 8 WHEN prRoleEnum = 5 THEN 16 ELSE prRoleEnum END WHERE prRoleEnum IN (3, 4, 5)") + + connection.execSQL("UPDATE SchoolPermissionGrantEntity SET spgToRole = CASE WHEN spgToRole = 3 THEN 4 WHEN spgToRole = 4 THEN 8 WHEN spgToRole = 5 THEN 16 ELSE spgToRole END WHERE spgToRole IN (3, 4, 5)") + + connection.execSQL("UPDATE InviteEntity SET iNewUserRole = CASE WHEN iNewUserRole = 3 THEN 4 WHEN iNewUserRole = 4 THEN 8 WHEN iNewUserRole = 5 THEN 16 ELSE iNewUserRole END WHERE iNewUserRole IN (3, 4, 5)") + + connection.execSQL("CREATE TABLE IF NOT EXISTS `SchoolConfigSettingEntity` (`scsKey` TEXT NOT NULL, `scsValue` TEXT NOT NULL, `scsStatus` INTEGER NOT NULL, `scsLastModified` INTEGER NOT NULL, `scsStored` INTEGER NOT NULL, `scsCanReadFlags` INTEGER NOT NULL, `scsAnonCanRead` INTEGER NOT NULL, `scsCanWriteFlags` INTEGER NOT NULL, PRIMARY KEY(`scsKey`))") + } +} + fun RoomDatabase.Builder.addCommonMigrations( ): RoomDatabase.Builder { - return this.addMigrations(MIGRATION_11_12) + return this.addMigrations( + MIGRATION_11_12, + MIGRATION_12_13, + ) } - diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt index 2ad81f42a..05780d586 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt @@ -4,8 +4,6 @@ import kotlinx.serialization.json.Json import world.respect.datalayer.AuthenticatedUserPrincipalId import world.respect.datalayer.SchoolDataSourceLocal import world.respect.datalayer.UidNumberMapper -import world.respect.datalayer.db.school.opds.OpdsPublicationDataSourceDb -import world.respect.datalayer.db.school.opds.OpdsFeedDataSourceDb import world.respect.datalayer.db.school.AssignmentDatasourceDb import world.respect.datalayer.db.school.ClassDatasourceDb import world.respect.datalayer.db.school.EnrollmentDataSourceDb @@ -18,10 +16,12 @@ import world.respect.datalayer.db.school.PersonPasswordDataSourceDb import world.respect.datalayer.db.school.PersonQrBadgeDataSourceDb import world.respect.datalayer.db.school.ReportDataSourceDb import world.respect.datalayer.db.school.SchoolAppDataSourceDb +import world.respect.datalayer.db.school.SchoolConfigSettingDataSourceDb import world.respect.datalayer.db.school.SchoolPermissionGrantDataSourceDb +import world.respect.datalayer.db.school.opds.OpdsFeedDataSourceDb +import world.respect.datalayer.db.school.opds.OpdsPublicationDataSourceDb import world.respect.datalayer.school.AssignmentDataSourceLocal import world.respect.datalayer.school.ClassDataSourceLocal -import world.respect.datalayer.school.DummySchoolConfigSettingsDataSource import world.respect.datalayer.school.EnrollmentDataSourceLocal import world.respect.datalayer.school.IndicatorDataSource import world.respect.datalayer.school.InviteDataSourceLocal @@ -31,11 +31,10 @@ import world.respect.datalayer.school.PersonPasswordDataSourceLocal import world.respect.datalayer.school.PersonQrCodeBadgeDataSourceLocal import world.respect.datalayer.school.ReportDataSourceLocal import world.respect.datalayer.school.SchoolAppDataSourceLocal -import world.respect.datalayer.school.SchoolConfigSettingDataSource import world.respect.datalayer.school.SchoolPermissionGrantDataSourceLocal import world.respect.datalayer.school.domain.CheckPersonPermissionUseCase -import world.respect.datalayer.school.opds.OpdsPublicationDataSourceLocal import world.respect.datalayer.school.opds.OpdsFeedDataSourceLocal +import world.respect.datalayer.school.opds.OpdsPublicationDataSourceLocal import world.respect.lib.primarykeygen.PrimaryKeyGenerator /** @@ -136,9 +135,11 @@ class SchoolDataSourceDb( ) } - override val schoolConfigSettingDataSource: SchoolConfigSettingDataSource by lazy { - DummySchoolConfigSettingsDataSource( - defaultAppCatalogUrl = defaultAppCatalogUrl, + override val schoolConfigSettingDataSource by lazy { + SchoolConfigSettingDataSourceDb( + schoolDb = schoolDb, + authenticatedUser = authenticatedUser, + uidNumberMapper = uidNumberMapper, ) } -} \ No newline at end of file +} diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/PersonDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/PersonDataSourceDb.kt index 5f7d33034..33768de11 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/PersonDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/PersonDataSourceDb.kt @@ -186,6 +186,7 @@ class PersonDataSourceDb( filterByPersonStatus = params.filterByPersonStatus?.flag ?: 0, includeRelated = params.includeRelated, includeDeleted = params.common.includeDeleted ?: false, + excludeSharedSchoolDevice = params.excludeSharedSchoolDevice, ).map(tag = { "PersonDataSourceDb/listAsPagingSource(params=$params)" }) { it.toPersonEntities().toModel() } @@ -237,6 +238,7 @@ class PersonDataSourceDb( filterByPersonRole = listParams.filterByPersonRole?.flag ?: 0, filterByPersonStatus = listParams.filterByPersonStatus?.flag ?: 0, includeRelated = listParams.includeRelated, + excludeSharedSchoolDevice = listParams.excludeSharedSchoolDevice, ) } } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt new file mode 100644 index 000000000..5433d2d3e --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt @@ -0,0 +1,132 @@ +package world.respect.datalayer.db.school + +import androidx.room.Transactor +import androidx.room.useWriterConnection +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import world.respect.datalayer.AuthenticatedUserPrincipalId +import world.respect.datalayer.DataLoadMetaInfo +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.DataReadyState +import world.respect.datalayer.NoDataLoadedState +import world.respect.datalayer.UidNumberMapper +import world.respect.datalayer.db.RespectSchoolDatabase +import world.respect.datalayer.db.school.adapters.asEntity +import world.respect.datalayer.db.school.adapters.asModel +import world.respect.datalayer.exceptions.ForbiddenException +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal +import world.respect.datalayer.school.ext.foldToFlag +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.datalayer.shared.maxLastModifiedOrNull +import world.respect.datalayer.shared.maxLastStoredOrNull +import kotlin.time.Clock + +class SchoolConfigSettingDataSourceDb( + private val schoolDb: RespectSchoolDatabase, + private val authenticatedUser: AuthenticatedUserPrincipalId, + private val uidNumberMapper: UidNumberMapper, +) : SchoolConfigSettingDataSourceLocal { + + private val authenticatedUserUidNum: Long + get() = uidNumberMapper(authenticatedUser.guid) + + override suspend fun findByGuid( + params: DataLoadParams, + guid: String + ): DataLoadState { + return schoolDb.getSchoolConfigSettingEntityDao().list( + authenticatedPersonUidNum = authenticatedUserUidNum, + keys = listOf(guid) + ).firstOrNull()?.asModel()?.let { DataReadyState(it) } ?: NoDataLoadedState.notFound() + } + + override fun listAsFlow( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): Flow>> { + return schoolDb.getSchoolConfigSettingEntityDao().listAsFlow( + authenticatedPersonUidNum = authenticatedUserUidNum, + keys = params.keys, + since = params.common.since?.toEpochMilliseconds() ?: 0 + ).map { list -> + DataReadyState( + data = list.map { it.asModel() } + ) + } + } + + override suspend fun list( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): DataLoadState> { + val queryTime = Clock.System.now() + val data = schoolDb.getSchoolConfigSettingEntityDao().list( + authenticatedPersonUidNum = authenticatedUserUidNum, + keys = params.keys, + since = params.common.since?.toEpochMilliseconds() ?: 0 + ).map { it.asModel() } + + return DataReadyState( + data = data, + metaInfo = DataLoadMetaInfo( + lastModified = data.maxLastModifiedOrNull()?.toEpochMilliseconds() ?: -1, + lastStored = data.maxLastStoredOrNull()?.toEpochMilliseconds() ?: -1, + consistentThrough = queryTime, + ) + ) + } + + override suspend fun store(list: List) { + if (list.isEmpty()) return + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + list.forEach { setting -> + val lastModAndPermission = schoolDb.getSchoolConfigSettingEntityDao() + .getLastModifiedAndHasPermission( + authenticatedPersonUidNum = authenticatedUserUidNum, + key = setting.key, + canWriteRolesMask = setting.canWrite.foldToFlag() + ) + + if (!lastModAndPermission.hasPermission) { + throw ForbiddenException() + } + } + + schoolDb.getSchoolConfigSettingEntityDao().insert( + list.map { it.copy(stored = Clock.System.now()).asEntity() } + ) + } + } + } + + override suspend fun updateLocal( + list: List, + forceOverwrite: Boolean + ) { + if (list.isEmpty()) return + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + val toInsert = list.filter { item -> + forceOverwrite || schoolDb.getSchoolConfigSettingEntityDao() + .getLastModifiedByKey( + key = item.key + ).let { it ?: 0L } < item.lastModified.toEpochMilliseconds() + }.map { it.asEntity() } + + if (toInsert.isNotEmpty()) { + schoolDb.getSchoolConfigSettingEntityDao().insert(toInsert) + } + } + } + } + + override suspend fun findByUidList(uids: List): List { + return schoolDb.getSchoolConfigSettingEntityDao().list( + authenticatedPersonUidNum = authenticatedUserUidNum, + keys = uids + ).map { it.asModel() } + } +} diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/SchoolConfigSettingAdapter.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/SchoolConfigSettingAdapter.kt new file mode 100644 index 000000000..1bc8415e7 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/SchoolConfigSettingAdapter.kt @@ -0,0 +1,32 @@ +package world.respect.datalayer.db.school.adapters + +import world.respect.datalayer.db.school.entities.SchoolConfigSettingEntity +import world.respect.datalayer.school.ext.foldToFlag +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.SchoolConfigSetting + + +fun SchoolConfigSetting.asEntity(): SchoolConfigSettingEntity { + return SchoolConfigSettingEntity( + scsKey = key, + scsValue = value, + scsStatus = status, + scsLastModified = lastModified, + scsStored = stored, + scsCanReadFlags = canRead.foldToFlag(), + scsCanWriteFlags = canWrite.foldToFlag(), + scsAnonCanRead = canRead.contains(null), + ) +} + +fun SchoolConfigSettingEntity.asModel(): SchoolConfigSetting { + return SchoolConfigSetting( + key = scsKey, + value = scsValue, + status = scsStatus, + lastModified = scsLastModified, + stored = scsStored, + canRead = PersonRoleEnum.unfoldFromFlag(scsCanReadFlags), + canWrite = PersonRoleEnum.unfoldFromFlag(scsCanWriteFlags) + ) +} diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/PersonEntityDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/PersonEntityDao.kt index 21e00bc08..3a84f30e0 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/PersonEntityDao.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/PersonEntityDao.kt @@ -57,6 +57,7 @@ interface PersonEntityDao { roleTeacherPermissionRequired: Long = PermissionFlags.PERSON_TEACHER_WRITE, roleStudentPermissionRequired: Long = PermissionFlags.PERSON_STUDENT_WRITE, roleParentPermissionRequired: Long = PermissionFlags.PERSON_PARENT_WRITE, + roleSharedDevicePermissionRequired: Long = PermissionFlags.PERSON_STUDENT_READ, ): LastModifiedAndPermission @@ -120,6 +121,7 @@ interface PersonEntityDao { filterByPersonStatus: Int = 0, includeRelated: Boolean = false, includeDeleted: Boolean = false, + excludeSharedSchoolDevice: Boolean = false, ): Flow> @Query(""" @@ -147,6 +149,7 @@ interface PersonEntityDao { filterByPersonStatus: Int = 0, includeRelated: Boolean = false, includeDeleted: Boolean = false, + excludeSharedSchoolDevice: Boolean = false, ): List @Transaction @@ -190,6 +193,7 @@ interface PersonEntityDao { filterByPersonStatus: Int = 0, includeRelated: Boolean = false, includeDeleted: Boolean = false, + excludeSharedSchoolDevice: Boolean = false, ): PagingSource @Query(""" @@ -220,6 +224,7 @@ interface PersonEntityDao { filterByPersonStatus: Int = 0, includeRelated: Boolean = false, includeDeleted: Boolean = false, + excludeSharedSchoolDevice: Boolean = false, ): PagingSource @Query(""" @@ -316,6 +321,7 @@ interface PersonEntityDao { WHEN ${PersonRoleEnum.TEACHER_INT} THEN ${PermissionFlags.PERSON_TEACHER_READ} WHEN ${PersonRoleEnum.STUDENT_INT} THEN ${PermissionFlags.PERSON_STUDENT_READ} WHEN ${PersonRoleEnum.PARENT_INT} THEN ${PermissionFlags.PERSON_PARENT_READ} + WHEN ${PersonRoleEnum.SHARED_SCHOOL_DEVICE_INT} THEN ${PermissionFlags.PERSON_STUDENT_READ} ELSE ${Long.MAX_VALUE} """ @@ -420,7 +426,13 @@ interface PersonEntityDao { FROM PersonRoleEntity WHERE PersonRoleEntity.prPersonGuidHash = PersonEntity.pGuidHash)) AND (:filterByPersonStatus = 0 OR PersonEntity.pStatus = :filterByPersonStatus) - AND (:includeDeleted OR PersonEntity.pStatus != ${PersonStatusEnum.TO_BE_DELETED_INT}) + AND (:includeDeleted OR PersonEntity.pStatus != ${PersonStatusEnum.TO_BE_DELETED_INT}) + AND (:excludeSharedSchoolDevice = 0 OR NOT EXISTS ( + SELECT 1 + FROM PersonRoleEntity + WHERE PersonRoleEntity.prPersonGuidHash = PersonEntity.pGuidHash + AND PersonRoleEntity.prRoleEnum = ${PersonRoleEnum.SHARED_SCHOOL_DEVICE_INT} + )) ), RelatedPersons(uidNum) AS ( @@ -435,6 +447,12 @@ interface PersonEntityDao { FROM Persons) ) AND ($AUTHENTICATED_USER_PERSON_READ_PERMISSION_WHERE_CLAUSE_SQL) + AND (:excludeSharedSchoolDevice = 0 OR NOT EXISTS ( + SELECT 1 + FROM PersonRoleEntity + WHERE PersonRoleEntity.prPersonGuidHash = PersonEntity.pGuidHash + AND PersonRoleEntity.prRoleEnum = ${PersonRoleEnum.SHARED_SCHOOL_DEVICE_INT} + )) ), AllPersons(uidNum) AS ( @@ -462,6 +480,7 @@ interface PersonEntityDao { WHEN ${PersonRoleEnum.TEACHER_INT} THEN :roleTeacherPermissionRequired WHEN ${PersonRoleEnum.STUDENT_INT} THEN :roleStudentPermissionRequired WHEN ${PersonRoleEnum.PARENT_INT} THEN :roleParentPermissionRequired + WHEN ${PersonRoleEnum.SHARED_SCHOOL_DEVICE_INT} THEN :roleSharedDevicePermissionRequired ELSE ${Long.MAX_VALUE} END ) diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt new file mode 100644 index 000000000..f587ee3af --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt @@ -0,0 +1,90 @@ +package world.respect.datalayer.db.school.daos + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.db.school.entities.LastModifiedAndPermission +import world.respect.datalayer.db.school.entities.SchoolConfigSettingEntity + +@Dao +interface SchoolConfigSettingEntityDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entities: List) + + @Query(LIST_SQL) + fun listAsFlow( + authenticatedPersonUidNum: Long, + keys: List? = null, + since: Long = 0, + ): Flow> + + @Query(LIST_SQL) + suspend fun list( + authenticatedPersonUidNum: Long, + keys: List? = null, + since: Long = 0, + ): List + + @Query(""" + SELECT scsLastModified + FROM SchoolConfigSettingEntity + WHERE scsKey = :key + """) + suspend fun getLastModifiedByKey(key: String): Long? + + @Query(GET_LAST_MODIFIED_AND_HAS_PERMISSION_SQL) + suspend fun getLastModifiedAndHasPermission( + authenticatedPersonUidNum: Long, + key: String, + canWriteRolesMask: Int = 0 + ): LastModifiedAndPermission + + companion object { + + private const val AUTHENTICATED_USER_ROLE_SQL = """ + SELECT PersonRoleEntity.prRoleEnum + FROM PersonRoleEntity + WHERE PersonRoleEntity.prPersonGuidHash = :authenticatedPersonUidNum + LIMIT 1 + """ + + private const val READ_PERMISSION_CHECK_SQL = """ + scsAnonCanRead OR (($AUTHENTICATED_USER_ROLE_SQL) & scsCanReadFlags) > 0 + """ + + private const val WRITE_PERMISSION_CHECK_SQL = """ + (($AUTHENTICATED_USER_ROLE_SQL) & scsCanWriteFlags) > 0 + """ + + private const val LIST_SQL = """ + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE scsKey IN (:keys) + AND SchoolConfigSettingEntity.scsStored > :since + AND ($READ_PERMISSION_CHECK_SQL) + """ + + private const val GET_LAST_MODIFIED_AND_HAS_PERMISSION_SQL = """ + SELECT 0 AS uidNum, + (SELECT SchoolConfigSettingEntity.scsLastModified + FROM SchoolConfigSettingEntity + WHERE SchoolConfigSettingEntity.scsKey = :key) AS lastModified, + ( + -- for existing records + EXISTS ( + SELECT 1 + FROM SchoolConfigSettingEntity + WHERE SchoolConfigSettingEntity.scsKey = :key + AND ($WRITE_PERMISSION_CHECK_SQL) + ) + OR + -- for new records (using the passed mask) + (NOT EXISTS (SELECT 1 FROM SchoolConfigSettingEntity WHERE scsKey = :key) + AND ($AUTHENTICATED_USER_ROLE_SQL) & :canWriteRolesMask > 0) + ) AS hasPermission + """ + } +} diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/domain/AddDefaultSchoolPermissionGrantsUseCase.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/domain/AddDefaultSchoolPermissionGrantsUseCase.kt index ccb0c71b1..9e2dfb01c 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/domain/AddDefaultSchoolPermissionGrantsUseCase.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/domain/AddDefaultSchoolPermissionGrantsUseCase.kt @@ -41,6 +41,9 @@ class AddDefaultSchoolPermissionGrantsUseCase( PersonRoleEnum.PARENT.newInitialGrant( PermissionFlags.PARENT_DEFAULT_SCHOOL_PERMISSIONS ), + PersonRoleEnum.SHARED_SCHOOL_DEVICE.newInitialGrant( + PermissionFlags.SHARED_DEVICE_DEFAULT_SCHOOL_PERMISSIONS + ), ).map { it.toEntity(uidNumberMapper) } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/domain/CheckPersonPermissionUseCaseDbImpl.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/domain/CheckPersonPermissionUseCaseDbImpl.kt index 2eb13483f..3258e1480 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/domain/CheckPersonPermissionUseCaseDbImpl.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/domain/CheckPersonPermissionUseCaseDbImpl.kt @@ -25,7 +25,8 @@ class CheckPersonPermissionUseCaseDbImpl( roleAdminPermissionRequired = permissionsRequiredByRole.roleAdminPermissionRequired, roleTeacherPermissionRequired = permissionsRequiredByRole.roleTeacherPermissionRequired, roleParentPermissionRequired = permissionsRequiredByRole.roleParentPermissionRequired, - roleStudentPermissionRequired = permissionsRequiredByRole.roleStudentPermissionRequired + roleStudentPermissionRequired = permissionsRequiredByRole.roleStudentPermissionRequired, + roleSharedDevicePermissionRequired = permissionsRequiredByRole.roleSharedDevicePermissionRequired ).hasPermission } } \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/SchoolConfigSettingEntity.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/SchoolConfigSettingEntity.kt new file mode 100644 index 000000000..69373e060 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/SchoolConfigSettingEntity.kt @@ -0,0 +1,19 @@ +package world.respect.datalayer.db.school.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import world.respect.datalayer.school.model.StatusEnum +import kotlin.time.Instant + +@Entity +data class SchoolConfigSettingEntity( + @PrimaryKey + val scsKey: String, + val scsValue: String, + val scsStatus: StatusEnum, + val scsLastModified: Instant, + val scsStored: Instant, + val scsCanReadFlags: Int, + val scsAnonCanRead: Boolean, + val scsCanWriteFlags: Int, +) \ No newline at end of file diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt index 80b28b01b..1de1c240c 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt @@ -16,13 +16,13 @@ import world.respect.datalayer.http.school.PersonPasskeyDataSourceHttp import world.respect.datalayer.http.school.PersonPasswordDataSourceHttp import world.respect.datalayer.http.school.PersonQrBadgeDataSourceHttp import world.respect.datalayer.http.school.SchoolAppDataSourceHttp +import world.respect.datalayer.http.school.SchoolConfigSettingDataSourceHttp import world.respect.datalayer.http.school.SchoolPermissionGrantDataSourceHttp import world.respect.datalayer.networkvalidation.BaseDataSourceValidationHelper import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper import world.respect.datalayer.school.opds.OpdsPublicationDataSource import world.respect.datalayer.school.AssignmentDataSource import world.respect.datalayer.school.ClassDataSource -import world.respect.datalayer.school.DummySchoolConfigSettingsDataSource import world.respect.datalayer.school.EnrollmentDataSource import world.respect.datalayer.school.IndicatorDataSource import world.respect.datalayer.school.InviteDataSource @@ -172,8 +172,12 @@ class SchoolDataSourceHttp( } override val schoolConfigSettingDataSource: SchoolConfigSettingDataSource by lazy { - DummySchoolConfigSettingsDataSource( - defaultAppCatalogUrl = defaultAppCatalogUrl, + SchoolConfigSettingDataSourceHttp( + schoolUrl = schoolUrl, + schoolDirectoryEntryDataSource = schoolDirectoryEntryDataSource, + httpClient = httpClient, + tokenProvider = tokenProvider, + validationHelper = validationHelper, ) } } diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt new file mode 100644 index 000000000..325e15e82 --- /dev/null +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt @@ -0,0 +1,94 @@ +package world.respect.datalayer.http.school + +import io.ktor.client.HttpClient +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.contentType +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.AuthTokenProvider +import world.respect.datalayer.DataLayerParams +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.ext.firstOrNotLoaded +import world.respect.datalayer.ext.getAsDataLoadState +import world.respect.datalayer.ext.getDataLoadResultAsFlow +import world.respect.datalayer.ext.useTokenProvider +import world.respect.datalayer.ext.useValidationCacheControl +import world.respect.datalayer.http.ext.appendCommonListParams +import world.respect.datalayer.http.ext.respectEndpointUrl +import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.datalayer.schooldirectory.SchoolDirectoryEntryDataSource + +class SchoolConfigSettingDataSourceHttp( + override val schoolUrl: Url, + override val schoolDirectoryEntryDataSource: SchoolDirectoryEntryDataSource, + private val httpClient: HttpClient, + private val tokenProvider: AuthTokenProvider, + private val validationHelper: ExtendedDataSourceValidationHelper?, +) : SchoolConfigSettingDataSource, SchoolUrlBasedDataSource { + + private suspend fun SchoolConfigSettingDataSource.GetListParams.urlWithParams(): Url { + return URLBuilder(respectEndpointUrl(SchoolConfigSettingDataSource.ENDPOINT_NAME)) + .apply { + parameters.appendCommonListParams(common) + keys?.let { parameters.appendAll(DataLayerParams.KEYS, it) } + } + .build() + } + + override suspend fun findByGuid( + params: DataLoadParams, + guid: String + ): DataLoadState { + return httpClient.getAsDataLoadState>( + SchoolConfigSettingDataSource.GetListParams( + keys = listOf(guid) + ).urlWithParams() + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + }.firstOrNotLoaded() + } + + override fun listAsFlow( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): Flow>> { + return httpClient.getDataLoadResultAsFlow>( + urlFn = { params.urlWithParams() }, + dataLoadParams = loadParams, + validationHelper = validationHelper, + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + } + } + + override suspend fun list( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): DataLoadState> { + return httpClient.getAsDataLoadState>( + url = params.urlWithParams(), + validationHelper = validationHelper, + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + } + } + + override suspend fun store(list: List) { + httpClient.post( + url = respectEndpointUrl(SchoolConfigSettingDataSource.ENDPOINT_NAME) + ) { + useTokenProvider(tokenProvider) + contentType(ContentType.Application.Json) + setBody(list) + } + } +} diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt index 280de6156..d8243a036 100644 --- a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt @@ -14,6 +14,7 @@ import world.respect.datalayer.repository.school.PersonPasskeyDataSourceReposito import world.respect.datalayer.repository.school.PersonPasswordDataSourceRepository import world.respect.datalayer.repository.school.PersonQrCodeBadgeDataSourceRepository import world.respect.datalayer.repository.school.SchoolAppDataSourceRepository +import world.respect.datalayer.repository.school.SchoolConfigSettingDataSourceRepository import world.respect.datalayer.repository.school.SchoolPermissionGrantDataSourceRepository import world.respect.datalayer.school.IndicatorDataSource import world.respect.datalayer.school.PersonPasskeyDataSource @@ -139,7 +140,12 @@ class SchoolDataSourceRepository( ) } - override val schoolConfigSettingDataSource: SchoolConfigSettingDataSource by lazy { - local.schoolConfigSettingDataSource + override val schoolConfigSettingDataSource: SchoolConfigSettingDataSourceRepository by lazy { + SchoolConfigSettingDataSourceRepository( + local = local.schoolConfigSettingDataSource, + remote = remote.schoolConfigSettingDataSource, + validationHelper = validationHelper, + remoteWriteQueue = remoteWriteQueue, + ) } -} \ No newline at end of file +} diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt new file mode 100644 index 000000000..19618deca --- /dev/null +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt @@ -0,0 +1,69 @@ +package world.respect.datalayer.repository.school + +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.DataReadyState +import world.respect.datalayer.ext.combineWithRemote +import world.respect.datalayer.ext.updateFromRemoteIfNeeded +import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.datalayer.school.writequeue.RemoteWriteQueue +import world.respect.datalayer.school.writequeue.WriteQueueItem +import world.respect.datalayer.shared.RepositoryModelDataSource +import world.respect.libutil.util.time.systemTimeInMillis + +class SchoolConfigSettingDataSourceRepository( + override val local: SchoolConfigSettingDataSourceLocal, + override val remote: SchoolConfigSettingDataSource, + private val validationHelper: ExtendedDataSourceValidationHelper, + private val remoteWriteQueue: RemoteWriteQueue, +) : SchoolConfigSettingDataSource, RepositoryModelDataSource { + + override suspend fun findByGuid( + params: DataLoadParams, + guid: String + ): DataLoadState { + if (!params.onlyIfCached) { + val remoteResult = remote.findByGuid(params, guid) + local.updateFromRemoteIfNeeded(remoteResult, validationHelper) + } + return local.findByGuid(params, guid) + } + + override fun listAsFlow( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): Flow>> { + return local.listAsFlow(loadParams, params) + } + + override suspend fun list( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): DataLoadState> { + val remoteResult = remote.list(loadParams, params) + if (remoteResult is DataReadyState) { + local.updateLocal(remoteResult.data) + validationHelper.updateValidationInfo(remoteResult.metaInfo) + } + + return local.list(loadParams, params).combineWithRemote(remoteResult) + } + + override suspend fun store(list: List) { + local.store(list) + val timeNow = systemTimeInMillis() + remoteWriteQueue.add( + list.map { + WriteQueueItem( + model = WriteQueueItem.Model.SCHOOL_CONFIG_SETTING, + uid = it.key, + timeQueued = timeNow, + ) + } + ) + } +} diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt index 283e02a20..68fce5e65 100644 --- a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt @@ -69,6 +69,10 @@ class DrainRemoteWriteQueueUseCase( repository.inviteDataSource.sendToRemote(listOf(item)) } + WriteQueueItem.Model.SCHOOL_CONFIG_SETTING -> { + repository.schoolConfigSettingDataSource.sendToRemote(listOf(item)) + } + WriteQueueItem.Model.OPDS_FEED -> { val dataLoad = repository.opdsFeedDataSource.local.getByUrl( url = Url(item.uid), diff --git a/respect-datalayer/AGENTS.md b/respect-datalayer/AGENTS.md index 1c5bad234..06dd5d32f 100644 --- a/respect-datalayer/AGENTS.md +++ b/respect-datalayer/AGENTS.md @@ -1,5 +1,5 @@ * The datalayer is split into two parts: SchoolDataSource for school-level data (users, student - progress, etc) and RespectAppDataSource for app-wide data. + progress, etc) and RespectAppDataSource for app-wide data (e.g. school directories). * Any writable model class should implement the ```ModelWithTimes``` interface such that it can be synced * Models normally have a string uid (required as this UID may come from an external system). diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt index 828361fb7..81d0edd3c 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt @@ -12,8 +12,12 @@ object DataLayerParams { const val GUID = "guid" + const val KEYS = "keys" + const val INCLUDE_RELATED = "includeRelated" + const val EXCLUDE_SHARED_SCHOOL_DEVICE = "excludeSharedSchoolDevice" + const val INCLUDE_DELETED = "includeDeleted" const val IN_CLASS_ON_DAY = "inClassOnDay" diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt index ea5276334..51e75d950 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt @@ -11,6 +11,7 @@ import world.respect.datalayer.school.PersonPasswordDataSourceLocal import world.respect.datalayer.school.PersonQrCodeBadgeDataSourceLocal import world.respect.datalayer.school.ReportDataSourceLocal import world.respect.datalayer.school.SchoolAppDataSourceLocal +import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal import world.respect.datalayer.school.SchoolPermissionGrantDataSourceLocal import world.respect.datalayer.school.opds.OpdsFeedDataSourceLocal @@ -47,4 +48,6 @@ interface SchoolDataSourceLocal: SchoolDataSource { override val opdsFeedDataSource: OpdsFeedDataSourceLocal + override val schoolConfigSettingDataSource: SchoolConfigSettingDataSourceLocal + } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/DummySchoolConfigSettingsDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/DummySchoolConfigSettingsDataSource.kt deleted file mode 100644 index 792e941ec..000000000 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/DummySchoolConfigSettingsDataSource.kt +++ /dev/null @@ -1,66 +0,0 @@ -package world.respect.datalayer.school - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import world.respect.datalayer.DataLoadParams -import world.respect.datalayer.DataLoadState -import world.respect.datalayer.DataReadyState -import world.respect.datalayer.school.model.SchoolConfigSetting -import world.respect.datalayer.shared.paging.IPagingSourceFactory - -class DummySchoolConfigSettingsDataSource( - private val defaultAppCatalogUrl: String?, -): SchoolConfigSettingDataSource { - - override suspend fun findByGuid( - params: DataLoadParams, - guid: String - ): DataLoadState { - TODO("Not yet implemented") - } - - override fun listAsPagingSource( - loadParams: DataLoadParams, - params: SchoolConfigSettingDataSource.GetListParams - ): IPagingSourceFactory { - TODO("Not yet implemented") - } - - override fun listAsFlow( - loadParams: DataLoadParams, - params: SchoolConfigSettingDataSource.GetListParams - ): Flow>> { - return flowOf( - DataReadyState( - data = defaultAppCatalogUrl?.let { - listOf( - SchoolConfigSetting( - key = SchoolConfigSettingDataSource.KEY_APP_CATALOGS, - value = it, - ) - ) - } ?: emptyList() - ) - ) - } - - override suspend fun list( - loadParams: DataLoadParams, - params: SchoolConfigSettingDataSource.GetListParams - ): DataLoadState> { - return DataReadyState( - data = defaultAppCatalogUrl?.let { - listOf( - SchoolConfigSetting( - key = SchoolConfigSettingDataSource.KEY_APP_CATALOGS, - value = it, - ) - ) - } ?: emptyList() - ) - } - - override suspend fun store(list: List) { - TODO("Not yet implemented") - } -} \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/PersonDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/PersonDataSource.kt index 2410bf85a..cc7a3e13b 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/PersonDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/PersonDataSource.kt @@ -39,6 +39,7 @@ interface PersonDataSource: WritableDataSource { val filterByPersonRole: PersonRoleEnum? = null, val includeRelated: Boolean = false, val inClassOnDay: LocalDate? = null, + val excludeSharedSchoolDevice: Boolean = true, ) { companion object { @@ -60,6 +61,7 @@ interface PersonDataSource: WritableDataSource { PersonRoleEnum.fromValue(it) }, includeRelated = stringValues[DataLayerParams.INCLUDE_RELATED]?.toBoolean() ?: false, + excludeSharedSchoolDevice = stringValues[DataLayerParams.EXCLUDE_SHARED_SCHOOL_DEVICE]?.toBoolean() ?: false, ) } diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt index b4ca79900..5631ee291 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt @@ -2,25 +2,26 @@ package world.respect.datalayer.school import io.ktor.util.StringValues import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.DataLayerParams import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState import world.respect.datalayer.school.model.SchoolConfigSetting import world.respect.datalayer.shared.WritableDataSource -import world.respect.datalayer.shared.paging.IPagingSourceFactory import world.respect.datalayer.shared.params.GetListCommonParams interface SchoolConfigSettingDataSource: WritableDataSource { data class GetListParams( val common: GetListCommonParams = GetListCommonParams(), - val key: String? = null, + val keys: List? = null, ) { companion object { fun fromParams(params: StringValues): GetListParams { return GetListParams( - common = GetListCommonParams.fromParams(params) + common = GetListCommonParams.fromParams(params), + keys = params.getAll(DataLayerParams.KEYS) ) } } @@ -37,11 +38,6 @@ interface SchoolConfigSettingDataSource: WritableDataSource params: GetListParams = GetListParams(), ): Flow>> - fun listAsPagingSource( - loadParams: DataLoadParams = DataLoadParams(), - params: GetListParams = GetListParams(), - ): IPagingSourceFactory - suspend fun list( loadParams: DataLoadParams = DataLoadParams(), params: GetListParams = GetListParams(), @@ -55,5 +51,9 @@ interface SchoolConfigSettingDataSource: WritableDataSource const val KEY_APP_CATALOGS = "app-catalogs" + const val KEY_SHARED_DEVICE_PIN = "shared-device-pin" + + const val KEY_SHARED_DEVICE_SELF_SELECT = "shared-device-self-select" + } -} \ No newline at end of file +} diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSourceLocal.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSourceLocal.kt new file mode 100644 index 000000000..0476860f9 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSourceLocal.kt @@ -0,0 +1,6 @@ +package world.respect.datalayer.school + +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.datalayer.shared.LocalModelDataSource + +interface SchoolConfigSettingDataSourceLocal: SchoolConfigSettingDataSource, LocalModelDataSource \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/CheckPersonPermissionUseCase.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/CheckPersonPermissionUseCase.kt index 97241d78f..e6020a109 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/CheckPersonPermissionUseCase.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/CheckPersonPermissionUseCase.kt @@ -14,13 +14,15 @@ interface CheckPersonPermissionUseCase { val roleTeacherPermissionRequired: Long = PermissionFlags.PERSON_TEACHER_WRITE, val roleStudentPermissionRequired: Long = PermissionFlags.PERSON_STUDENT_WRITE, val roleParentPermissionRequired: Long = PermissionFlags.PERSON_PARENT_WRITE, + val roleSharedDevicePermissionRequired: Long = PermissionFlags.PERSON_STUDENT_READ, ) { val flagList: List get() = listOf(roleAdminPermissionRequired, roleTeacherPermissionRequired, roleStudentPermissionRequired, - roleParentPermissionRequired + roleParentPermissionRequired, + roleSharedDevicePermissionRequired ) companion object { @@ -30,6 +32,7 @@ interface CheckPersonPermissionUseCase { roleTeacherPermissionRequired = PermissionFlags.PERSON_TEACHER_WRITE, roleStudentPermissionRequired = PermissionFlags.PERSON_STUDENT_WRITE, roleParentPermissionRequired = PermissionFlags.PERSON_PARENT_WRITE, + roleSharedDevicePermissionRequired = PermissionFlags.PERSON_STUDENT_READ, ) } diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonExt.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonExt.kt index cec85c7ad..bd1477cda 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonExt.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonExt.kt @@ -3,8 +3,10 @@ package world.respect.datalayer.school.ext import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import world.respect.datalayer.exceptions.ForbiddenException +import world.respect.datalayer.school.model.DeviceInfo import world.respect.datalayer.school.model.Invite2 import world.respect.datalayer.school.model.Person import world.respect.datalayer.school.model.PersonRoleEnum @@ -25,7 +27,8 @@ fun Person.primaryRole(): PersonRoleEnum { * Put the invite code in the metadata of the Person */ fun Person.copyWithInviteInfo( - invite: Invite2 + invite: Invite2, + deviceInfo: DeviceInfo? = null ): Person { return copy( metadata = buildJsonObject { @@ -35,6 +38,13 @@ fun Person.copyWithInviteInfo( put(Person.METADATA_KEY_INVITE_ID, JsonPrimitive(invite.code)) put(Person.METADATA_KEY_INVITE_UID, JsonPrimitive(invite.uid)) + + if (deviceInfo != null) { + put(Person.DEVICE_INFO, JsonPrimitive(deviceInfo.toString())) + put(Person.DEVICE_MODEL, JsonPrimitive(deviceInfo.model)) + put(Person.DEVICE_PLATFORM, JsonPrimitive(deviceInfo.platform.name)) + put(Person.DEVICE_OS_VERSION, JsonPrimitive(deviceInfo.version)) + } } ) } @@ -46,3 +56,26 @@ fun Person.inviteCodeOrNull(): String? { fun Person.inviteUidOrNull(): String? { return metadata?.get(Person.METADATA_KEY_INVITE_UID)?.jsonPrimitive?.contentOrNull } +fun Person.deviceModelOrNull(): String? { + return metadata?.jsonObject?.get(Person.DEVICE_MODEL)?.jsonPrimitive?.content +} + +fun Person.devicePlatformOrNull(): String? { + return metadata?.jsonObject?.get(Person.DEVICE_PLATFORM)?.jsonPrimitive?.content +} + +fun Person.deviceOsVersionOrNull(): String? { + return metadata?.jsonObject?.get(Person.DEVICE_OS_VERSION)?.jsonPrimitive?.content +} + + +fun Person.getDeviceInfo(): String { + val model = deviceModelOrNull() ?: return givenName + val platform = devicePlatformOrNull() ?: "Android" + val osVersion = deviceOsVersionOrNull() ?: "" + + val deviceType = if (model.contains("tab", ignoreCase = true) || + model.contains("pad", ignoreCase = true)) "Tablet" else "Mobile" + + return "$deviceType ($platform $osVersion)" +} diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonRoleEnumExt.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonRoleEnumExt.kt index 12624a0bb..933be653e 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonRoleEnumExt.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonRoleEnumExt.kt @@ -21,3 +21,16 @@ val PersonRoleEnum.writePermissionFlag: Long val PersonRoleEnum.newUserInviteUid: String get() = "$TYPE_NEW_USER:${this.value}" + +/** + * Fold the list of PersonRoleEnum into a single flag + */ +fun List.foldToFlag(): Int { + return this.fold(0) { acc, enum -> + if(enum != null) { + acc.or(enum.flag) + }else { + acc + } + } +} diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PermissionFlags.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PermissionFlags.kt index ee3b1f9b2..ac98d3472 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PermissionFlags.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PermissionFlags.kt @@ -55,5 +55,7 @@ object PermissionFlags { const val SYSADMIN_DEFAULT_SCHOOL_PERMISSIONS = SYSTEM_ADMIN + const val SHARED_DEVICE_DEFAULT_SCHOOL_PERMISSIONS = CLASS_READ + .or(PERSON_STUDENT_READ) } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Person.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Person.kt index 2cd8f3af1..a5d04d482 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Person.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Person.kt @@ -50,6 +50,14 @@ data class Person( const val METADATA_KEY_INVITE_UID = "inviteUid" + const val DEVICE_INFO = "deviceInfo" + + const val DEVICE_MODEL = "deviceModel" + + const val DEVICE_PLATFORM = "devicePlatform" + + const val DEVICE_OS_VERSION = "deviceOsVersion" + } } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt index 101529320..05f0a4484 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt @@ -12,9 +12,10 @@ import kotlinx.serialization.encoding.Encoder enum class PersonRoleEnum(val value: String, val flag: Int) { SITE_ADMINISTRATOR("siteAdministrator", 1), STUDENT("student", 2), - SYSTEM_ADMINISTRATOR("systemAdministrator", 3), - TEACHER("teacher", 4), - PARENT("parent", 5); + SYSTEM_ADMINISTRATOR("systemAdministrator", 4), + TEACHER("teacher", 8), + PARENT("parent", 16), + SHARED_SCHOOL_DEVICE("sharedschooldevice", 32); companion object { @@ -22,12 +23,13 @@ enum class PersonRoleEnum(val value: String, val flag: Int) { const val STUDENT_INT = 2 - const val SYSTEM_ADMINISTRATOR_INT = 3 + const val SYSTEM_ADMINISTRATOR_INT = 4 - const val TEACHER_INT = 4 + const val TEACHER_INT = 8 - const val PARENT_INT = 5 + const val PARENT_INT = 16 + const val SHARED_SCHOOL_DEVICE_INT = 32 fun fromValue(value: String): PersonRoleEnum { return entries.first { it.value == value } @@ -37,6 +39,12 @@ enum class PersonRoleEnum(val value: String, val flag: Int) { return entries.first { it.flag == flag } } + fun unfoldFromFlag(flag: Int): List { + return entries.filter { enum -> + (flag and enum.flag) == enum.flag + } + } + } } @@ -56,4 +64,3 @@ object PersonRoleEnumSerializer: KSerializer { return PersonRoleEnum.fromValue(decoder.decodeString()) } } - diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/SchoolConfigSetting.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/SchoolConfigSetting.kt index f5a3ee544..a583b4752 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/SchoolConfigSetting.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/SchoolConfigSetting.kt @@ -9,8 +9,10 @@ import kotlin.time.Clock * Provides a way to store key-value configuration settings for the school itself e.g. the list of * URLs of app catalogs, single sign-on settings, etc). * - * Config settings can only be written by the admin. Storing each key-value pair as its own entity - * allows for granular per-setting control over read permissions. + * Storing each key-value pair as its own entity allows for granular per-setting control over read + * and write permissions. + * + * @property canRead */ @Serializable data class SchoolConfigSetting( @@ -20,6 +22,7 @@ data class SchoolConfigSetting( override val lastModified: InstantAsISO8601 = Clock.System.now(), override val stored: InstantAsISO8601 = Clock.System.now(), val canRead: List = listOf(), + val canWrite: List = listOf(), ) : ModelWithTimes { companion object { diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt index aae2c9c6e..d7dfce807 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt @@ -26,6 +26,7 @@ class WriteQueueItem( PERSON_QRBADGE(8), INVITE(9), OPDS_FEED(10), + SCHOOL_CONFIG_SETTING(11), ; diff --git a/respect-lib-shared/src/commonMain/composeResources/drawable/undraw_bookmarks_i66k__1__1.xml b/respect-lib-shared/src/commonMain/composeResources/drawable/undraw_bookmarks_i66k__1__1.xml new file mode 100644 index 000000000..d2ff12986 --- /dev/null +++ b/respect-lib-shared/src/commonMain/composeResources/drawable/undraw_bookmarks_i66k__1__1.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/respect-lib-shared/src/commonMain/composeResources/drawable/undraw_sync_pe2t_1.xml b/respect-lib-shared/src/commonMain/composeResources/drawable/undraw_sync_pe2t_1.xml new file mode 100644 index 000000000..f964dea34 --- /dev/null +++ b/respect-lib-shared/src/commonMain/composeResources/drawable/undraw_sync_pe2t_1.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml index 52b9cb015..4c008347a 100644 --- a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml +++ b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml @@ -21,6 +21,34 @@ App Assignments Assignment + Device + Image shared device + Pending device request to join + Teacher/admin unlock PIN + + Shared Device + Student can login without the school name + Device auto sync offline to reduce data usage + School admin can manually manage + Enable + No Shared Devices Available + Shared school devices allow multiple users to securely access the app on the same device using their profile + + + PhoneAndroid + last seen + CheckCircle + Add Device + This device + Enable shared school device on mode on this device + Close Icon + Add using QR code, link, or invite code + Another device + Set PIN + Share Icon + Arrow Down + Devices + Add assignment Edit assignment Classes @@ -174,12 +202,15 @@ Enter Roll Number Shared device setting Scan QR code badge - Teacher or admin login + Teacher/admin login School Directory Let's get started School name + Enter school device PIN Type school name here... I have an invite code + Select another school + Invalid school name School not found: please ask your admin to add your school. Add Directory @@ -281,7 +312,7 @@ Indicator Name SQL Edit Filters - Blank Template> + Blank Template Total Content Usage Duration @@ -356,6 +387,13 @@ School Grade Level Assessment Type (Self/Assignment) + Shared school device + Enable shared school device mode + + Device name + Enable shared device mode + Students must enter their roll number to login + Student can self-select their class and name @@ -428,6 +466,7 @@ First names Mappings + School name, policies, shared device Mapping Sections Section @@ -472,7 +511,7 @@ Confirm Dismiss - App version\ + App version Your device is running Android 6, which is not fully supported. You may experience issues. We recommend using Android 7 or higher Old password @@ -511,6 +550,7 @@ Select family person Add new person Invite person + Add shared school device You haven't added any apps for your school yet. No apps added for your school yet. Ask your admin to add some. @@ -570,4 +610,8 @@ Select Host + Error: please enter 4 digit number + + Invalid PIN + diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/RespectAccountManager.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/RespectAccountManager.kt index 938f85db3..857b0cc9e 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/RespectAccountManager.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/RespectAccountManager.kt @@ -99,7 +99,7 @@ class RespectAccountManager( loadParams = DataLoadParams(), params = PersonDataSource.GetListParams( common = GetListCommonParams( - guid = session.account.userGuid + guid = activePersonUid ), includeRelated = true, ) @@ -160,6 +160,7 @@ class RespectAccountManager( suspend fun register( redeemInviteRequest: RespectRedeemInviteRequest, schoolUrl: Url, + useActiveUserAuth: Boolean = true ): Person { val schoolScopeId = SchoolDirectoryEntryScopeId( schoolUrl, null, @@ -169,7 +170,7 @@ class RespectAccountManager( ) val redeemInviteUseCase: RedeemInviteUseCase = schoolScope.get() - val authResponse = redeemInviteUseCase(redeemInviteRequest) + val authResponse = redeemInviteUseCase(redeemInviteRequest, useActiveUserAuth) val schoolDirectoryEntry = appDataSource.schoolDirectoryEntryDataSource.getSchoolDirectoryEntryByUrl( schoolUrl @@ -281,6 +282,31 @@ class RespectAccountManager( } + /** + * Logs in a user on a shared device by switching to an existing profile without creating a new account. + * + * This function is specifically designed for shared device scenarios where: + * - Users scan a QR code to access the device + * - We want to switch to their existing profile rather than creating a new account + * - The device remains shared but the active user context changes + **/ + suspend fun loginAsProfileOnSharedDevice( + credential: RespectCredential, + schoolUrl: Url, + ): AuthResponse { + val schoolScopeId = SchoolDirectoryEntryScopeId(schoolUrl, null) + val schoolScope = getKoin().getOrCreateScope( + schoolScopeId.scopeId + ) + + val authUseCase: GetTokenAndUserProfileWithCredentialUseCase = schoolScope.get() + val authResponse = authUseCase(credential) + + switchProfile(authResponse.person.guid) + + return authResponse + } + fun switchAccount(account: RespectAccount) { if(!_storedAccounts.value.any { it.isSameAccount(account) }) { throw IllegalArgumentException("switchAccount: account not stored/available") diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/EnableSharedDeviceModeUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/EnableSharedDeviceModeUseCase.kt new file mode 100644 index 000000000..460ff398e --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/EnableSharedDeviceModeUseCase.kt @@ -0,0 +1,48 @@ +package world.respect.shared.domain.account.invite + +import com.russhwolf.settings.Settings +import io.ktor.http.Url +import world.respect.datalayer.school.model.Person +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.util.ext.isSameAccount + +class EnableSharedDeviceModeUseCase( + private val accountManager: RespectAccountManager, + private val settings: Settings, +) { + suspend operator fun invoke( + redeemInviteRequest: RespectRedeemInviteRequest, + schoolUrl: Url, + useActiveUserAuth: Boolean = false + ): Person { + try { + val personRegistered = accountManager.register( + redeemInviteRequest = redeemInviteRequest, + schoolUrl = schoolUrl, + useActiveUserAuth = useActiveUserAuth + ) + + val deviceAccount = accountManager.activeAccount + ?: throw IllegalStateException("Device account was not set as active") + + val currentAccounts = accountManager.accounts.value + currentAccounts.forEach { account -> + if (!account.isSameAccount(deviceAccount)) { + accountManager.removeAccount(account) + } + } + + settings.putBoolean(SETTINGS_KEY_IS_SHARED_MODE, true) + + return personRegistered + + } catch (e: Exception) { + e.printStackTrace() + throw e + } + } + + companion object { + const val SETTINGS_KEY_IS_SHARED_MODE = "is_shared_device_mode" + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCase.kt index 983708071..25a5615a1 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCase.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCase.kt @@ -15,7 +15,8 @@ interface RedeemInviteUseCase { * */ suspend operator fun invoke( - redeemRequest: RespectRedeemInviteRequest + redeemRequest: RespectRedeemInviteRequest, + useActiveUserAuth: Boolean = false ): AuthResponse } \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseClient.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseClient.kt index ba58b121a..a19ef1633 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseClient.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseClient.kt @@ -5,10 +5,13 @@ import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.Url import io.ktor.http.contentType +import world.respect.datalayer.AuthTokenProvider import world.respect.libutil.ext.appendEndpointSegments import world.respect.shared.domain.account.AuthResponse +import world.respect.shared.domain.account.RespectAccountManager /** * RedeemInviteUseCase should be used by the RespectAccountManager, not directly by any ViewModel @@ -16,15 +19,30 @@ import world.respect.shared.domain.account.AuthResponse class RedeemInviteUseCaseClient( private val schoolUrl: Url, private val httpClient: HttpClient, + private val accountManager: RespectAccountManager, ) : RedeemInviteUseCase { - override suspend fun invoke(redeemRequest: RespectRedeemInviteRequest): AuthResponse { + override suspend fun invoke( + redeemRequest: RespectRedeemInviteRequest, + useActiveUserAuth: Boolean + ): AuthResponse { return httpClient.post( schoolUrl.appendEndpointSegments("api/school/respect/invite/redeem") ) { contentType(ContentType.Application.Json) + + if (!useActiveUserAuth) { + val scope = accountManager.requireActiveAccountScope() + val tokenProvider = scope.getOrNull() + ?: throw IllegalStateException( + "useActiveUserAuth=true but no token provider found for active account" + ) + + val token = tokenProvider.provideToken() + headers.append(HttpHeaders.Authorization, "Bearer ${token.accessToken}") + } + setBody(redeemRequest) }.body() } - } \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RespectRedeemInviteRequest.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RespectRedeemInviteRequest.kt index 2960a5477..98e698f2b 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RespectRedeemInviteRequest.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RespectRedeemInviteRequest.kt @@ -35,7 +35,7 @@ data class RespectRedeemInviteRequest( data class Account( val guid: String, val username: String, - val credential: RespectCredential, + val credential: RespectCredential? = null, ) companion object { diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/GetSharedDeviceSelfSelectUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/GetSharedDeviceSelfSelectUseCase.kt new file mode 100644 index 000000000..bbe5f27fd --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/GetSharedDeviceSelfSelectUseCase.kt @@ -0,0 +1,20 @@ +package world.respect.shared.domain.account.sharedschooldevice + +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.SchoolConfigSettingDataSource + +class GetSharedDeviceSelfSelectUseCase( + private val schoolDataSource: SchoolDataSource +) { + + suspend operator fun invoke(): Boolean { + val setting = schoolDataSource.schoolConfigSettingDataSource.findByGuid( + DataLoadParams(), + SchoolConfigSettingDataSource.KEY_SHARED_DEVICE_SELF_SELECT + ).dataOrNull() + + return setting?.value?.toBoolean() ?: true + } +} diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/SetSharedDeviceSelfSelectUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/SetSharedDeviceSelfSelectUseCase.kt new file mode 100644 index 000000000..cb6824b81 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/SetSharedDeviceSelfSelectUseCase.kt @@ -0,0 +1,42 @@ +package world.respect.shared.domain.account.sharedschooldevice + +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.SchoolConfigSetting +import kotlin.time.Clock + +class SetSharedDeviceSelfSelectUseCase( + private val schoolDataSource: SchoolDataSource +) { + + suspend operator fun invoke(enabled: Boolean) { + val params = DataLoadParams() + val existingSetting = schoolDataSource.schoolConfigSettingDataSource.findByGuid( + params, SchoolConfigSettingDataSource.KEY_SHARED_DEVICE_SELF_SELECT + ).dataOrNull() + + val setting = SchoolConfigSetting( + key = SchoolConfigSettingDataSource.KEY_SHARED_DEVICE_SELF_SELECT, + value = enabled.toString(), + lastModified = Clock.System.now(), + canRead = existingSetting?.canRead ?: listOf( + PersonRoleEnum.SYSTEM_ADMINISTRATOR, + PersonRoleEnum.SITE_ADMINISTRATOR, + PersonRoleEnum.TEACHER, + PersonRoleEnum.STUDENT, + PersonRoleEnum.SHARED_SCHOOL_DEVICE, + PersonRoleEnum.PARENT + ), + canWrite = existingSetting?.canWrite ?: listOf( + PersonRoleEnum.SYSTEM_ADMINISTRATOR, + PersonRoleEnum.SITE_ADMINISTRATOR, + PersonRoleEnum.TEACHER + ) + ) + + schoolDataSource.schoolConfigSettingDataSource.store(listOf(setting)) + } +} diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/setpin/GetSharedDevicePINUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/setpin/GetSharedDevicePINUseCase.kt new file mode 100644 index 000000000..5970b3cb7 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/setpin/GetSharedDevicePINUseCase.kt @@ -0,0 +1,36 @@ +package world.respect.shared.domain.account.sharedschooldevice.setpin + +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import kotlin.random.Random + +interface GetSharedDevicePINUseCase { + suspend operator fun invoke(): String +} + +class GetSharedDevicePINUseCaseImpl( + private val schoolDataSource: SchoolDataSource, + private val setSharedDevicePINUseCase: SetSharedDevicePINUseCase +) : GetSharedDevicePINUseCase { + + override suspend fun invoke(): String { + val existingPin = schoolDataSource.schoolConfigSettingDataSource.findByGuid( + DataLoadParams(), + SchoolConfigSettingDataSource.KEY_SHARED_DEVICE_PIN + ).dataOrNull() + + return if (existingPin != null) { + existingPin.value + } else { + val newPin = generateRandomPin() + setSharedDevicePINUseCase(newPin) + newPin + } + } + + private fun generateRandomPin(): String { + return Random.nextInt(1000, 10000).toString().padStart(4, '0') + } +} diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/setpin/SetSharedDevicePINUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/setpin/SetSharedDevicePINUseCase.kt new file mode 100644 index 000000000..3e6927303 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/sharedschooldevice/setpin/SetSharedDevicePINUseCase.kt @@ -0,0 +1,44 @@ +package world.respect.shared.domain.account.sharedschooldevice.setpin + +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.SchoolConfigSetting +import kotlin.time.Clock + +interface SetSharedDevicePINUseCase { + suspend operator fun invoke(pin: String) +} + +class SetSharedDevicePINUseCaseImpl( + private val schoolDataSource: SchoolDataSource +) : SetSharedDevicePINUseCase { + + override suspend fun invoke(pin: String) { + val params = DataLoadParams() + val existingSetting = schoolDataSource.schoolConfigSettingDataSource.findByGuid( + params, SchoolConfigSettingDataSource.KEY_SHARED_DEVICE_PIN + ).dataOrNull() + + val setting = SchoolConfigSetting( + key = SchoolConfigSettingDataSource.KEY_SHARED_DEVICE_PIN, + value = pin, + lastModified = Clock.System.now(), + canRead = existingSetting?.canRead ?: listOf( + PersonRoleEnum.SYSTEM_ADMINISTRATOR, + PersonRoleEnum.SITE_ADMINISTRATOR, + PersonRoleEnum.TEACHER, + PersonRoleEnum.SHARED_SCHOOL_DEVICE + ), + canWrite = existingSetting?.canWrite ?: listOf( + PersonRoleEnum.SYSTEM_ADMINISTRATOR, + PersonRoleEnum.SITE_ADMINISTRATOR, + PersonRoleEnum.TEACHER + ) + ) + + schoolDataSource.schoolConfigSettingDataSource.store(listOf(setting)) + } +} 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 2fd39609b..8926b581f 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 @@ -85,15 +85,16 @@ object SchoolDirectoryEdit : RespectAppRoute @Serializable data class LoginScreen( val schoolUrlStr: String, + val isSharedDevice: Boolean? = null, ) : RespectAppRoute { @Transient val schoolUrl = Url(schoolUrlStr) companion object { - fun create(schoolUrl: Url) = LoginScreen(schoolUrl.toString()) + fun create(schoolUrl: Url, isSharedDevice: Boolean? = null) = + LoginScreen(schoolUrl.toString(), isSharedDevice) } - } @Serializable @@ -403,6 +404,7 @@ class AcceptInvite( val schoolUrlStr: String, val code: String, val canGoBack: Boolean = true, + val useActiveUserAuth: Boolean? = null, ) : RespectAppRoute { @Transient @@ -413,10 +415,12 @@ class AcceptInvite( schoolUrl: Url, code: String, canGoBack: Boolean = true, + useActiveUserAuth: Boolean? = null, ) = AcceptInvite( schoolUrlStr = schoolUrl.toString(), code = code, canGoBack = canGoBack, + useActiveUserAuth = useActiveUserAuth, ) } } @@ -731,7 +735,8 @@ data class ScanQRCode( val resultDestStr: String? = null, private val schoolUrlStr: String? = null, val username: String? = null, - private val nextAfterScanStr: String? = null + private val nextAfterScanStr: String? = null, + val isSharedDevice: Boolean = false ) : RespectAppRoute, RouteWithResultDest { @Transient @@ -751,13 +756,15 @@ data class ScanQRCode( resultDest: ResultDest? = null, schoolUrl: Url? = null, username: String? = null, - nextAfterScan: NextAfterScan? = null + nextAfterScan: NextAfterScan? = null, + isSharedDevice: Boolean = false ) = ScanQRCode( guid = guid, resultDestStr = resultDest?.encodeToJsonStringOrNull(), username = username, schoolUrlStr = schoolUrl?.toString(), - nextAfterScanStr = nextAfterScan?.name + nextAfterScanStr = nextAfterScan?.name, + isSharedDevice = isSharedDevice ) } } @@ -765,6 +772,46 @@ data class ScanQRCode( @Serializable data object CurriculumMappingList : RespectAppRoute +@Serializable +data object SchoolSettings : RespectAppRoute + +@Serializable +data object SharedDevicesSettings : RespectAppRoute + +@Serializable +data class SelectClass( + val deviceGuid: String +) : RespectAppRoute { + + companion object { + fun create( + deviceGuid: String + ) = SelectClass( + deviceGuid = deviceGuid + ) + } +} + +@Serializable +data object TeacherPinConfirmation : RespectAppRoute + +@Serializable +data class StudentList( + val className: String, + val guid: String, +) : RespectAppRoute { + + companion object { + fun create( + className: String, + guid: String, + ) = StudentList( + className = className, + guid = guid, + ) + } +} + @Serializable data class CurriculumMappingEdit( val textbookUid: Long = 0L, diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/util/ext/PersonRoleEnumExt.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/util/ext/PersonRoleEnumExt.kt index 8ba2d7cdd..f6bebadf0 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/util/ext/PersonRoleEnumExt.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/util/ext/PersonRoleEnumExt.kt @@ -4,6 +4,7 @@ import org.jetbrains.compose.resources.StringResource import world.respect.datalayer.school.model.PersonRoleEnum import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.parent +import world.respect.shared.generated.resources.shared_school_devices import world.respect.shared.generated.resources.site_administrator import world.respect.shared.generated.resources.student import world.respect.shared.generated.resources.system_administrator @@ -16,4 +17,5 @@ val PersonRoleEnum.label: StringResource PersonRoleEnum.TEACHER -> Res.string.teacher PersonRoleEnum.SYSTEM_ADMINISTRATOR -> Res.string.system_administrator PersonRoleEnum.SITE_ADMINISTRATOR -> Res.string.site_administrator + PersonRoleEnum.SHARED_SCHOOL_DEVICE -> Res.string.shared_school_devices } \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/launcher/AppLauncherViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/launcher/AppLauncherViewModel.kt index caf58a19a..9b806f16d 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/launcher/AppLauncherViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/launcher/AppLauncherViewModel.kt @@ -3,7 +3,6 @@ package world.respect.shared.viewmodel.apps.launcher import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute -import io.ktor.http.Url import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -123,12 +122,13 @@ class AppLauncherViewModel( accountManager.selectedAccountAndPersonFlow.collect { selected -> val isAdmin = selected?.person?.isAdmin() == true val devModeEnabled = getDevModeEnabledUseCase() + // TODO: For now, make the settingsIcon always visible for testing. _appUiState.update { it.copy( fabState = it.fabState.copy( visible = isAdmin ), - settingsIconVisible = isAdmin && devModeEnabled, + settingsIconVisible = true ) } _uiState.update { diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt index 407af21b2..aa953547a 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt @@ -60,7 +60,7 @@ class AppListViewModel( schoolDataSource.schoolConfigSettingDataSource.listAsFlow( loadParams = DataLoadParams(), params = SchoolConfigSettingDataSource.GetListParams( - key = SchoolConfigSettingDataSource.KEY_APP_CATALOGS + keys = listOf(SchoolConfigSettingDataSource.KEY_APP_CATALOGS) ) ).collectLatest { config -> val feedUrl = config.dataOrNull()?.firstOrNull()?.value?.let { diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt index b032e6d10..48fd9e292 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt @@ -4,6 +4,7 @@ package world.respect.shared.viewmodel.manageuser.acceptinvite import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.russhwolf.settings.Settings import io.ktor.http.Url import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,9 +17,13 @@ import world.respect.datalayer.RespectAppDataSource import world.respect.datalayer.ext.dataOrNull import world.respect.datalayer.respect.model.SchoolDirectoryEntry import world.respect.datalayer.respect.model.invite.RespectInviteInfo +import world.respect.datalayer.school.ext.accepterPersonRole import world.respect.datalayer.school.ext.isChildUser import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.PersonStatusEnum import world.respect.lib.opds.model.LangMap +import world.respect.shared.domain.account.invite.EnableSharedDeviceModeUseCase import world.respect.shared.domain.account.invite.GetInviteInfoUseCase import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest.PersonInfo @@ -26,16 +31,21 @@ import world.respect.shared.domain.getdeviceinfo.GetDeviceInfoUseCase import world.respect.shared.domain.getdeviceinfo.toUserFriendlyString import world.respect.shared.domain.school.SchoolPrimaryKeyGenerator import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.enable_shared_school_device_mode import world.respect.shared.generated.resources.invitation +import world.respect.shared.generated.resources.required_field import world.respect.shared.generated.resources.something_wrong_with_invite import world.respect.shared.navigation.AcceptInvite import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.SelectClass import world.respect.shared.navigation.SignupScreen import world.respect.shared.navigation.TermsAndCondition +import world.respect.shared.navigation.WaitingForApproval import world.respect.shared.resources.UiText import world.respect.shared.util.di.SchoolDirectoryEntryScopeId import world.respect.shared.util.ext.asUiText import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.viewmodel.manageuser.acceptinvite.AcceptInviteUiState.Companion.CURRENT_DEVICE_GUID data class AcceptInviteUiState( val inviteInfo: RespectInviteInfo? = null, @@ -43,16 +53,23 @@ data class AcceptInviteUiState( val isTeacherInvite: Boolean = false, val schoolName: LangMap? = null, val schoolUrl: Url? = null, + val isSharedDeviceMode: Boolean = false, + val deviceName: String = "", ) { val nextButtonEnabled: Boolean get() = inviteInfo?.invite != null + companion object { + const val CURRENT_DEVICE_GUID = "current_device_guid" + } } class AcceptInviteViewModel( savedStateHandle: SavedStateHandle, private val getDeviceInfoUseCase: GetDeviceInfoUseCase, private val respectAppDataSource: RespectAppDataSource, + private val enableSharedDeviceModeUseCase: EnableSharedDeviceModeUseCase, + private val settings: Settings ) : RespectViewModel(savedStateHandle), KoinScopeComponent { private val route: AcceptInvite = savedStateHandle.toRoute() @@ -73,14 +90,6 @@ class AcceptInviteViewModel( val uiState = _uiState.asStateFlow() init { - _appUiState.update { - it.copy( - title = Res.string.invitation.asUiText(), - hideBottomNavigation = true, - userAccountIconVisible = false, - showBackButton = route.canGoBack, - ) - } launchWithLoadingIndicator( onShowError = { @@ -95,12 +104,30 @@ class AcceptInviteViewModel( isTeacherInvite = false ) } + val isSharedDeviceMode = + _uiState.value.inviteInfo?.invite?.accepterPersonRole == PersonRoleEnum.SHARED_SCHOOL_DEVICE + _uiState.update { it.copy(isSharedDeviceMode = isSharedDeviceMode) } + + val title = if (isSharedDeviceMode) { + Res.string.enable_shared_school_device_mode.asUiText() + } else { + Res.string.invitation.asUiText() + } + _appUiState.update { + it.copy( + title = title, + hideBottomNavigation = true, + userAccountIconVisible = false, + showBackButton = true, + ) + } } viewModelScope.launch { - val schoolDirEntry = respectAppDataSource.schoolDirectoryEntryDataSource.getSchoolDirectoryEntryByUrl( - route.schoolUrl - ).dataOrNull() ?: return@launch + val schoolDirEntry = + respectAppDataSource.schoolDirectoryEntryDataSource.getSchoolDirectoryEntryByUrl( + route.schoolUrl + ).dataOrNull() ?: return@launch _uiState.update { it.copy(schoolName = schoolDirEntry.name) @@ -115,7 +142,8 @@ class AcceptInviteViewModel( code = invite.code, accountPersonInfo = PersonInfo(), account = RespectRedeemInviteRequest.Account( - guid = schoolPrimaryKeyGenerator.primaryKeyGenerator.nextId(Person.TABLE_ID).toString(), + guid = schoolPrimaryKeyGenerator.primaryKeyGenerator.nextId(Person.TABLE_ID) + .toString(), username = "", credential = RespectPasswordCredential(username = "", password = ""), ), @@ -126,12 +154,12 @@ class AcceptInviteViewModel( _navCommandFlow.tryEmit( NavCommand.Navigate( - destination = if(!invite.isChildUser()) { + destination = if (!invite.isChildUser()) { TermsAndCondition.create( schoolUrl = route.schoolUrl, inviteRequest = inviteRedeemRequest, ) - }else { + } else { SignupScreen.create( schoolUrl = route.schoolUrl, inviteRequest = inviteRedeemRequest, @@ -141,4 +169,63 @@ class AcceptInviteViewModel( ) } + fun updateDeviceName(deviceName: String) { + _uiState.update { currentState -> + currentState.copy(deviceName = deviceName, errorText = null) + } + } + + fun enableSharedDeviceMode() { + val deviceName = _uiState.value.deviceName + + if (deviceName.isBlank()) { + _uiState.update { it.copy(errorText = Res.string.required_field.asUiText()) } + return + } + _uiState.update { it.copy(errorText = null) } + + val invite = uiState.value.inviteInfo?.invite ?: return + + val inviteRedeemRequest = RespectRedeemInviteRequest( + code = invite.code, + accountPersonInfo = PersonInfo(name = _uiState.value.deviceName), + account = RespectRedeemInviteRequest.Account( + guid = schoolPrimaryKeyGenerator.primaryKeyGenerator.nextId(Person.TABLE_ID) + .toString(), + username = _uiState.value.deviceName, + ), + deviceName = _uiState.value.deviceName, + deviceInfo = getDeviceInfoUseCase(), + invite = invite + ) + val _useActiveUserAuth = route.useActiveUserAuth ?: true + viewModelScope.launch { + try { + val personRegistered = enableSharedDeviceModeUseCase( + redeemInviteRequest = inviteRedeemRequest, + schoolUrl = route.schoolUrl, + useActiveUserAuth = _useActiveUserAuth + ) + settings.putString(CURRENT_DEVICE_GUID, personRegistered.guid) + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = if (personRegistered.status != PersonStatusEnum.PENDING_APPROVAL) { + SelectClass( + deviceGuid = personRegistered.guid + ) + } else { + WaitingForApproval() + }, + clearBackStack = true + ) + ) + } catch (e: Exception) { + _uiState.update { + it.copy( + errorText = e.message?.asUiText() + ) + } + } + } + } } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/accountlist/AccountListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/accountlist/AccountListViewModel.kt index 4d8c3e865..b656d194a 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/accountlist/AccountListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/accountlist/AccountListViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch import world.respect.datalayer.DataLoadParams import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.ext.primaryRole import world.respect.datalayer.school.model.Person import world.respect.datalayer.school.model.PersonGenderEnum import world.respect.datalayer.school.model.PersonRoleEnum @@ -27,6 +28,8 @@ import world.respect.shared.navigation.GetStartedScreen import world.respect.shared.navigation.Home import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.PersonDetail +import world.respect.shared.navigation.RespectAppLauncher +import world.respect.shared.navigation.SelectClass import world.respect.shared.navigation.WaitingForApproval import world.respect.shared.util.ext.asUiText import world.respect.shared.util.ext.isSameAccount @@ -40,13 +43,14 @@ import world.respect.shared.viewmodel.RespectViewModel data class AccountListUiState( val selectedAccount: RespectSessionAndPerson? = null, val accounts: List = emptyList(), + val accountOwnerRole: PersonRoleEnum? = null, ) { val showSelectedAccountProfileButton: Boolean get() = selectedAccount?.person?.status != PersonStatusEnum.PENDING_APPROVAL + && accountOwnerRole != PersonRoleEnum.SHARED_SCHOOL_DEVICE // Hide for shared device val familyMembersClickEnabled: Boolean get() = selectedAccount?.person?.status != PersonStatusEnum.PENDING_APPROVAL - } class AccountListViewModel( @@ -71,8 +75,20 @@ class AccountListViewModel( viewModelScope.launch { respectAccountManager.selectedAccountAndPersonFlow.collect { accountAndPerson -> + val accountOwnerRole = respectAccountManager.activeAccount?.let { account -> + val accountScope = respectAccountManager.getOrCreateAccountScope(account) + val dataSource: SchoolDataSource = accountScope.get() + dataSource.personDataSource.findByGuid( + DataLoadParams(), + account.userGuid + ).dataOrNull()?.primaryRole() + } + _uiState.update { prev -> - prev.copy(selectedAccount = accountAndPerson) + prev.copy( + selectedAccount = accountAndPerson, + accountOwnerRole = accountOwnerRole + ) } } } @@ -166,17 +182,15 @@ class AccountListViewModel( _navCommandFlow.tryEmit( NavCommand.Navigate( - destination = if(person.dataOrNull()?.status != PersonStatusEnum.PENDING_APPROVAL) { + destination = if (person.dataOrNull()?.status != PersonStatusEnum.PENDING_APPROVAL) { Home - }else { + } else { WaitingForApproval() }, clearBackStack = true ) ) } - - } fun onClickFamilyPerson(person: Person) { @@ -184,7 +198,7 @@ class AccountListViewModel( respectAccountManager.switchProfile(person.guid) _navCommandFlow.tryEmit( NavCommand.Navigate( - destination = if(person.roles.firstOrNull()?.roleEnum == PersonRoleEnum.PARENT) { + destination = if (person.roles.firstOrNull()?.roleEnum == PersonRoleEnum.PARENT) { Home } else { AssignmentList @@ -213,13 +227,54 @@ class AccountListViewModel( } } - fun onClickLogout() { - uiState.value.selectedAccount?.also { + val isSelectedAccountSharedDevice = + uiState.value.accountOwnerRole == PersonRoleEnum.SHARED_SCHOOL_DEVICE + + // Find the shared device account in the accounts list + val sharedDeviceAccount = uiState.value.accounts.find { andPerson -> + andPerson.person.roles.any { role -> + role.roleEnum == PersonRoleEnum.SHARED_SCHOOL_DEVICE + } + } + + if (isSelectedAccountSharedDevice) { + // Currently on shared device account - just go to Select Class + uiState.value.selectedAccount?.also { selectedAccount -> + viewModelScope.launch { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = SelectClass.create(deviceGuid = selectedAccount.person.guid), + clearBackStack = true + ) + ) + } + } + } else if (sharedDeviceAccount?.person?.status == PersonStatusEnum.ACTIVE) { + // This is a teacher/admin on a shared device - switch to shared device account first viewModelScope.launch { - respectAccountManager.removeAccount(it.session.account) + // First switch to the shared device account + respectAccountManager.switchAccount(sharedDeviceAccount.session.account) + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = SelectClass.create(deviceGuid = sharedDeviceAccount.person.guid), + clearBackStack = true + ) + ) + } + } else { + // Regular logout - no shared device present + uiState.value.selectedAccount?.also { + viewModelScope.launch { + respectAccountManager.removeAccount(it.session.account) + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = GetStartedScreen(), + clearBackStack = true + ) + ) + } } } } - } \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/login/LoginViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/login/LoginViewModel.kt index 11f8d71b2..7b5ca909e 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/login/LoginViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/login/LoginViewModel.kt @@ -27,6 +27,7 @@ import world.respect.shared.generated.resources.required_field import world.respect.shared.generated.resources.something_went_wrong import world.respect.shared.navigation.EnterInviteCode import world.respect.shared.navigation.Home +import world.respect.shared.navigation.GetStartedScreen import world.respect.shared.navigation.LoginScreen import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.WaitingForApproval @@ -45,6 +46,7 @@ data class LoginUiState( val errorText: UiText? = null, val usernameError: StringResourceUiText? = null, val passwordError: StringResourceUiText? = null, + val isSharedDevice: Boolean = false ) class LoginViewModel( @@ -84,6 +86,11 @@ class LoginViewModel( } } viewModelScope.launch { + _uiState.update { prev -> + prev.copy( + isSharedDevice = route.isSharedDevice == true + ) + } try { val school = respectAppDataSource.schoolDirectoryEntryDataSource .getSchoolDirectoryEntryByUrl(route.schoolUrl) @@ -240,5 +247,9 @@ class LoginViewModel( NavCommand.Navigate(EnterInviteCode.create(route.schoolUrl)) ) } - + fun onClickSelectAnotherSchool(){ + _navCommandFlow.tryEmit( + NavCommand.Navigate(GetStartedScreen()) + ) + } } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/waitingforapproval/WaitingForApprovalViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/waitingforapproval/WaitingForApprovalViewModel.kt index d3abc2e50..1165dbe2c 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/waitingforapproval/WaitingForApprovalViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/waitingforapproval/WaitingForApprovalViewModel.kt @@ -14,6 +14,7 @@ import world.respect.datalayer.DataLoadParams import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.ext.dataOrNull import world.respect.datalayer.school.PersonDataSource +import world.respect.datalayer.school.model.PersonRoleEnum import world.respect.datalayer.school.model.PersonStatusEnum import world.respect.datalayer.shared.params.GetListCommonParams import world.respect.shared.domain.account.RespectAccountManager @@ -21,6 +22,8 @@ import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.waiting_title import world.respect.shared.navigation.Home import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.RespectAppLauncher +import world.respect.shared.navigation.SelectClass import world.respect.shared.util.ext.asUiText import world.respect.shared.viewmodel.RespectViewModel @@ -68,9 +71,16 @@ class WaitingForApprovalViewModel( ).dataOrNull() val personLoaded = personsLoaded?.firstOrNull { it.guid == activeUserUid } - if(personLoaded?.status == PersonStatusEnum.ACTIVE) { + if (personLoaded?.status == PersonStatusEnum.ACTIVE) { _navCommandFlow.tryEmit( - NavCommand.Navigate(Home) + NavCommand.Navigate( + destination = if (personLoaded.roles.firstOrNull()?.roleEnum == PersonRoleEnum.SHARED_SCHOOL_DEVICE) { + SelectClass.create(deviceGuid = personLoaded.guid) + } else { + Home + }, + clearBackStack = true + ) ) return@launch } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt index e386d7d54..76c1e0740 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt @@ -4,9 +4,6 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import io.ktor.http.Url import kotlinx.coroutines.delay -import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase -import world.respect.shared.domain.sharelink.LaunchShareLinkUseCase -import world.respect.shared.domain.sharelink.LaunchSendSmsUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest @@ -39,13 +36,18 @@ import world.respect.libutil.util.time.systemTimeInMillis import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.clipboard.SetClipboardStringUseCase import world.respect.shared.domain.createlink.CreateInviteLinkUseCase +import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase +import world.respect.shared.domain.sharelink.LaunchSendSmsUseCase +import world.respect.shared.domain.sharelink.LaunchShareLinkUseCase import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.add_shared_school_device import world.respect.shared.generated.resources.invitation import world.respect.shared.generated.resources.invite_person import world.respect.shared.navigation.InvitePerson import world.respect.shared.util.ext.asUiText import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.AppBarSearchUiState +import world.respect.shared.viewmodel.app.appstate.getTitle import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes @@ -62,7 +64,8 @@ data class InvitePersonUiState( val selectedRole: PersonRoleEnum? = null, val className: String? = null, val schoolName: String? = null, - val roleOptions: List = emptyList() + val roleOptions: List = emptyList(), + val isSharedDeviceMode: Boolean = false ) { val inviteCode: String? get() = invite.dataOrNull()?.code @@ -107,9 +110,20 @@ class InvitePersonViewModel( private val getWritableRolesListUseCase: GetWritableRolesListUseCase by inject() init { + val isSharedDeviceMode = when (val options = route.invitePersonOptions) { + is InvitePerson.NewUserInviteOptions -> { + options.presetRole == PersonRoleEnum.SHARED_SCHOOL_DEVICE + } + else -> false + } + _uiState.update { it.copy(isSharedDeviceMode = isSharedDeviceMode) } _appUiState.update { it.copy( - title = Res.string.invite_person.asUiText(), + title = if (isSharedDeviceMode) { + Res.string.add_shared_school_device.asUiText() + } else { + Res.string.invite_person.asUiText() + }, searchState = AppBarSearchUiState(visible = false), showBackButton = true, hideBottomNavigation = true, @@ -122,12 +136,16 @@ class InvitePersonViewModel( ?.person?.roles?.first()?.roleEnum ?: return@launch val writableRoles = getWritableRolesListUseCase(currentPersonRole) - val selectedRole = writableRoles.firstOrNull() ?: PersonRoleEnum.STUDENT - + val selectedRole = if (!isSharedDeviceMode) { + writableRoles.firstOrNull() ?: PersonRoleEnum.STUDENT + } else { + PersonRoleEnum.SHARED_SCHOOL_DEVICE + } _uiState.update { it.copy( roleOptions = writableRoles, - selectedRole = selectedRole + selectedRole = selectedRole, + schoolName = accountManager.activeAccount?.school?.name?.getTitle() ) } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/scanqrcode/ScanQRCodeViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/scanqrcode/ScanQRCodeViewModel.kt index 0a17c93cb..1c62daecc 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/scanqrcode/ScanQRCodeViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/scanqrcode/ScanQRCodeViewModel.kt @@ -18,6 +18,7 @@ import world.respect.shared.generated.resources.more_options import world.respect.shared.generated.resources.paste_url import world.respect.shared.generated.resources.qr_code_invalid_format import world.respect.shared.generated.resources.scan_qr_code +import world.respect.shared.navigation.AssignmentList import world.respect.shared.navigation.CreateAccountSetUsername import world.respect.shared.navigation.Home import world.respect.shared.navigation.ManageAccount @@ -157,14 +158,27 @@ class ScanQRCodeViewModel( return } - respectAccountManager.login( - credential = credential, - schoolUrl = schoolUrl - ) + if (route.isSharedDevice) { + // For shared device: login without creating a new account + respectAccountManager.loginAsProfileOnSharedDevice( + credential = credential, + schoolUrl = schoolUrl + ) + } else { + // For normal device: regular login creates new account + respectAccountManager.login( + credential = credential, + schoolUrl = schoolUrl + ) + } _navCommandFlow.tryEmit( NavCommand.Navigate( - destination = Home, + destination = if (route.isSharedDevice) { + AssignmentList + } else { + Home + }, clearBackStack = true, ) ) diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/settings/SettingsViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/settings/SettingsViewModel.kt index 23b8c3b98..0a6d29e7b 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/settings/SettingsViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/settings/SettingsViewModel.kt @@ -10,6 +10,7 @@ import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.settings import world.respect.shared.navigation.CurriculumMappingList import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.SchoolSettings import world.respect.shared.util.ext.asUiText import world.respect.shared.viewmodel.RespectViewModel @@ -46,4 +47,9 @@ class SettingsViewModel( NavCommand.Navigate(CurriculumMappingList) ) } + fun onClickSchool() { + _navCommandFlow.tryEmit( + NavCommand.Navigate(SchoolSettings) + ) + } } \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/SchoolSettingsViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/SchoolSettingsViewModel.kt new file mode 100644 index 000000000..bed54dd85 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/SchoolSettingsViewModel.kt @@ -0,0 +1,82 @@ +package world.respect.shared.viewmodel.sharedschooldevice + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +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.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.PersonDataSource +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.PersonStatusEnum +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.school +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.SharedDevicesSettings +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.getTitle + +data class SchoolSettingsUiState( + val schoolName: String? = null, + val error: UiText? = null, + val sharedSchoolDeviceCount: Int? = null, +) + +class SchoolSettingsViewModel( + savedStateHandle: SavedStateHandle, + accountManager: RespectAccountManager, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val schoolDataSource: SchoolDataSource by inject() + + private val _uiState = MutableStateFlow(SchoolSettingsUiState()) + + val uiState = _uiState.asStateFlow() + + init { + _appUiState.update { + it.copy( + title = Res.string.school.asUiText(), + hideBottomNavigation = true, + ) + } + viewModelScope.launch { + val schoolName = accountManager.activeAccount?.school?.name?.getTitle() + _uiState.update { prev -> + prev.copy( + schoolName = schoolName + ) + } + val deviceList = schoolDataSource.personDataSource.list( + loadParams = DataLoadParams(), + params = PersonDataSource.GetListParams( + filterByPersonRole = PersonRoleEnum.SHARED_SCHOOL_DEVICE, + filterByPersonStatus = PersonStatusEnum.ACTIVE + ) + ) + + _uiState.update { prev -> + prev.copy( + sharedSchoolDeviceCount = deviceList.dataOrNull()?.size + ) + } + } + } + + + fun onClickSharedSchoolDevices() { + _navCommandFlow.tryEmit( + NavCommand.Navigate(SharedDevicesSettings) + ) + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/SharedDevicesSettingsViewmodel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/SharedDevicesSettingsViewmodel.kt new file mode 100644 index 000000000..72b00fd9f --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/SharedDevicesSettingsViewmodel.kt @@ -0,0 +1,327 @@ +package world.respect.shared.viewmodel.sharedschooldevice + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.russhwolf.settings.Settings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +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.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.db.school.ext.isAdminOrTeacher +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.PersonDataSource +import world.respect.datalayer.school.ext.newUserInviteUid +import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.PersonStatusEnum +import world.respect.datalayer.shared.paging.EmptyPagingSource +import world.respect.datalayer.shared.paging.IPagingSourceFactory +import world.respect.datalayer.shared.paging.PagingSourceFactoryHolder +import world.respect.datalayer.shared.params.GetListCommonParams +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.account.sharedschooldevice.GetSharedDeviceSelfSelectUseCase +import world.respect.shared.domain.account.sharedschooldevice.SetSharedDeviceSelfSelectUseCase +import world.respect.shared.domain.account.invite.ApproveOrDeclineInviteRequestUseCase +import world.respect.shared.domain.account.sharedschooldevice.setpin.GetSharedDevicePINUseCase +import world.respect.shared.domain.account.sharedschooldevice.setpin.SetSharedDevicePINUseCase +import world.respect.shared.ext.tryOrShowSnackbarOnError +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.device +import world.respect.shared.generated.resources.pin_error +import world.respect.shared.generated.resources.shared_school_devices +import world.respect.shared.navigation.AcceptInvite +import world.respect.shared.navigation.InvitePerson +import world.respect.shared.navigation.NavCommand +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.FabUiState +import world.respect.shared.viewmodel.app.appstate.SnackBarDispatcher +import world.respect.shared.viewmodel.sharedschooldevice.SharedDevicesSettingsUiState.Companion.CURRENT_DEVICE_GUID +import kotlin.time.Clock + +data class SharedDevicesSettingsUiState( + val devices: IPagingSourceFactory = IPagingSourceFactory { + EmptyPagingSource() + }, + val pendingDevices: IPagingSourceFactory = + IPagingSourceFactory { EmptyPagingSource() }, + val error: UiText? = null, + val isPendingExpanded: Boolean = true, + val isSelfSelectClassAndName: Boolean = true, + val showEnableDialog: Boolean = false, + val showPinDialog: Boolean = false, + val pin: String = "", + val showBottomSheetOptions: Boolean = false, + val isLoadingPin: Boolean = true, + val currentDeviceGuid: String? = null +) { + companion object { + const val PIN_LENGTH = 4 + const val CURRENT_DEVICE_GUID = "current_device_guid" + } +} + +class SharedDevicesSettingsViewmodel( + savedStateHandle: SavedStateHandle, + private val accountManager: RespectAccountManager, + private val snackBarDispatcher: SnackBarDispatcher, + private val settings: Settings +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + private val schoolDataSource: SchoolDataSource by inject() + private val approveOrDeclineInviteRequestUseCase: ApproveOrDeclineInviteRequestUseCase by inject() + + private val getSharedDevicePINUseCase: GetSharedDevicePINUseCase by inject() + private val setSharedDevicePINUseCase: SetSharedDevicePINUseCase by inject() + private val getSharedDeviceSelfSelectUseCase: GetSharedDeviceSelfSelectUseCase by inject() + private val setSharedDeviceSelfSelectUseCase: SetSharedDeviceSelfSelectUseCase by inject() + + private val _uiState = MutableStateFlow(SharedDevicesSettingsUiState(isLoadingPin = true)) + val uiState = _uiState.asStateFlow() + + private val currentDeviceGuid = settings.getStringOrNull(CURRENT_DEVICE_GUID) + + val schoolUrl = accountManager.activeAccount?.school?.self + ?: throw IllegalStateException("No active school") + private val pendingPersonsPagingSource = PagingSourceFactoryHolder { + schoolDataSource.personDataSource.listAsPagingSource( + DataLoadParams(), + PersonDataSource.GetListParams( + filterByPersonStatus = PersonStatusEnum.PENDING_APPROVAL, + filterByPersonRole = PersonRoleEnum.SHARED_SCHOOL_DEVICE, + excludeSharedSchoolDevice = false + ) + ) + } + + private val pagingSourceFactoryHolder = PagingSourceFactoryHolder { + schoolDataSource.personDataSource.listAsPagingSource( + DataLoadParams(), + PersonDataSource.GetListParams( + filterByName = _appUiState.value.searchState.searchText.takeIf { it.isNotBlank() }, + filterByPersonStatus = PersonStatusEnum.ACTIVE, + filterByPersonRole = PersonRoleEnum.SHARED_SCHOOL_DEVICE, + excludeSharedSchoolDevice = false + ) + ) + } + + init { + loadSchoolPin() + loadSelfSelectSetting() + _appUiState.update { + it.copy( + title = Res.string.shared_school_devices.asUiText(), + hideBottomNavigation = true, + fabState = FabUiState( + text = Res.string.device.asUiText(), + icon = FabUiState.FabIcon.ADD, + onClick = ::onClickAdd, + visible = true, + ), + showBackButton = true, + ) + } + + _uiState.update { + it.copy( + devices = pagingSourceFactoryHolder, + pendingDevices = pendingPersonsPagingSource, + currentDeviceGuid = currentDeviceGuid + ) + } + } + + fun toggleSelfSelect(enabled: Boolean) { + _uiState.update { currentState -> + currentState.copy(isSelfSelectClassAndName = enabled) + } + viewModelScope.launch { + try { + setSharedDeviceSelfSelectUseCase(enabled) + } catch (e: Exception) { + _uiState.update { currentState -> + currentState.copy(error = e.message?.asUiText()) + } + } + } + } + + fun onClickAdd() { + _uiState.update { currentState -> + currentState.copy(showBottomSheetOptions = true) + } + } + + fun onClickAddAnotherDevice() { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + InvitePerson.create( + invitePersonOptions = InvitePerson.NewUserInviteOptions( + presetRole = PersonRoleEnum.SHARED_SCHOOL_DEVICE + ) + ) + ) + ) + } + + private fun loadSchoolPin() { + viewModelScope.launch { + try { + val pin = getSharedDevicePINUseCase() + _uiState.update { + it.copy( + pin = pin, + isLoadingPin = false + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + error = e.message?.asUiText(), + isLoadingPin = false + ) + } + } + } + } + + private fun loadSelfSelectSetting() { + viewModelScope.launch { + val selfEnableValue = getSharedDeviceSelfSelectUseCase() + _uiState.update { + it.copy(isSelfSelectClassAndName = selfEnableValue) + } + } + } + + + fun onClickEnableOnThisDevice() { + viewModelScope.launch { + val activeAccount = accountManager.activeAccount + val persons = schoolDataSource.personDataSource.list( + loadParams = DataLoadParams(), + params = PersonDataSource.GetListParams( + common = GetListCommonParams( + guid = activeAccount?.userGuid + ), + includeRelated = true, + ) + ).dataOrNull() + val activePerson = persons?.firstOrNull { person -> + person.guid == (activeAccount?.userGuid) + } + val isTeacherOrAdmin = activePerson?.isAdminOrTeacher() ?: false + + val inviteUid = InvitePerson.NewUserInviteOptions( + presetRole = PersonRoleEnum.SHARED_SCHOOL_DEVICE + ).presetRole?.newUserInviteUid + + activeAccount?.school?.self?.let { url -> + if (inviteUid != null) { + schoolDataSource.inviteDataSource.findByUidAsFlow( + uid = inviteUid, + loadParams = DataLoadParams() + ).collectLatest { invite -> + invite.dataOrNull()?.let { it -> + _navCommandFlow.tryEmit( + NavCommand.Navigate( + AcceptInvite.create( + schoolUrl = url, + code = it.code, + useActiveUserAuth = !isTeacherOrAdmin, + ) + ) + ) + } + } + } + } + } + } + + fun onShowPinDialog() { + _uiState.update { it.copy(showPinDialog = true) } + } + + fun onDismissPinDialog() { + _uiState.update { + it.copy( + showPinDialog = false, + error = null + ) + } + } + + fun onSavePin(pin: String) { + if (pin.length == SharedDevicesSettingsUiState.PIN_LENGTH && pin.all { it.isDigit() }) { + _uiState.update { + it.copy(pin = pin) + } + viewModelScope.launch { + try { + setSharedDevicePINUseCase(pin) + } catch (e: Exception) { + _uiState.update { + it.copy( + error = e.message?.asUiText() + ) + } + } + onDismissPinDialog() + } + } else { + _uiState.update { + it.copy(error = Res.string.pin_error.asUiText()) + } + } + } + + fun onTogglePendingInvites() { + _uiState.update { + it.copy(isPendingExpanded = !it.isPendingExpanded) + } + } + + fun onDismissBottomSheet() { + _uiState.update { currentState -> + currentState.copy(showBottomSheetOptions = false) + } + } + + fun onClickAcceptOrDismissInvite( + person: Person, + approved: Boolean, + ) { + viewModelScope.launch { + snackBarDispatcher.tryOrShowSnackbarOnError { + approveOrDeclineInviteRequestUseCase( + personUid = person.guid, + approved = approved, + ) + } + } + } + + fun onRemoveDevice(person: Person) { + settings.remove(CURRENT_DEVICE_GUID) + viewModelScope.launch { + schoolDataSource.personDataSource.store( + listOf( + person.copy( + status = PersonStatusEnum.TO_BE_DELETED, + lastModified = Clock.System.now(), + ) + ) + ) + } + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/TeacherPinConfirmationViewmodel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/TeacherPinConfirmationViewmodel.kt new file mode 100644 index 000000000..6f1719c69 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/TeacherPinConfirmationViewmodel.kt @@ -0,0 +1,76 @@ +package world.respect.shared.viewmodel.sharedschooldevice + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +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.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.account.sharedschooldevice.setpin.GetSharedDevicePINUseCase +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.invalid +import world.respect.shared.generated.resources.teacher_admin_login +import world.respect.shared.navigation.LoginScreen +import world.respect.shared.navigation.NavCommand +import world.respect.shared.resources.UiText +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel + +data class TeacherPinConfirmationUiState( + val errorMessage: UiText? = null, + val pin: String = "", +) + +class TeacherPinConfirmationViewmodel( + savedStateHandle: SavedStateHandle, + private val accountManager: RespectAccountManager, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val getSharedDevicePINUseCase: GetSharedDevicePINUseCase by inject() + + + private val _uiState = MutableStateFlow(TeacherPinConfirmationUiState()) + + val uiState = _uiState.asStateFlow() + + + init { + _appUiState.update { + it.copy( + title = Res.string.teacher_admin_login.asUiText(), + hideBottomNavigation = true, + userAccountIconVisible = false + ) + } + } + + fun onPinChanged(pin: String) { + _uiState.update { it.copy(pin = pin, errorMessage = null) } + } + + fun onClickNext() { + viewModelScope.launch { + if (verifyTeacherPin(_uiState.value.pin)) { + val schoolUrl = accountManager.activeAccount?.school?.self + schoolUrl?.let { url -> + _navCommandFlow.tryEmit( + NavCommand.Navigate(LoginScreen.create(url, true)) + ) + } + } else { + _uiState.update { it.copy(errorMessage = Res.string.invalid.asUiText()) } + } + } + } + + private suspend fun verifyTeacherPin(enteredPin: String): Boolean { + val correctPin = getSharedDevicePINUseCase() + return enteredPin == correctPin + } +} diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/login/SelectClassViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/login/SelectClassViewModel.kt new file mode 100644 index 000000000..30c772562 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/login/SelectClassViewModel.kt @@ -0,0 +1,118 @@ +package world.respect.shared.viewmodel.sharedschooldevice.login + +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.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.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +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.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.account.sharedschooldevice.GetSharedDeviceSelfSelectUseCase +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.login +import world.respect.shared.generated.resources.select_class +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.ScanQRCode +import world.respect.shared.navigation.SelectClass +import world.respect.shared.navigation.StudentList +import world.respect.shared.navigation.TeacherPinConfirmation +import world.respect.shared.resources.UiText +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel + +data class SelectClassUiState( + val error: UiText? = null, + val classes: IPagingSourceFactory = EmptyPagingSourceFactory(), + val isSelfSelectClassAndName: Boolean = true, + val deviceName: String = "" +) + +class SelectClassViewModel( + savedStateHandle: SavedStateHandle, + accountManager: RespectAccountManager, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val route: SelectClass = savedStateHandle.toRoute() + + private val schoolDataSource: SchoolDataSource by inject() + + private val _uiState = MutableStateFlow(SelectClassUiState()) + val uiState = _uiState.asStateFlow() + + val schoolUrl = accountManager.activeAccount?.school?.self + ?: throw IllegalStateException("No active school") + private val pagingSourceHolder = PagingSourceFactoryHolder { + schoolDataSource.classDataSource.listAsPagingSource( + loadParams = DataLoadParams(), + params = ClassDataSource.GetListParams() + ) + } + + private val getSharedDeviceSelfSelectUseCase: GetSharedDeviceSelfSelectUseCase by inject() + + init { + viewModelScope.launch { + val selfEnableValue = getSharedDeviceSelfSelectUseCase() + _uiState.update { + it.copy(isSelfSelectClassAndName = selfEnableValue) + } + _appUiState.update { + it.copy( + title = if (_uiState.value.isSelfSelectClassAndName) Res.string.select_class.asUiText() else Res.string.login.asUiText(), + hideBottomNavigation = true, + userAccountIconVisible = false, + showBackButton = false + ) + } + } + viewModelScope.launch { + val device = schoolDataSource.personDataSource.findByGuid(DataLoadParams(), route.deviceGuid) + + _uiState.update { prev -> + prev.copy( + classes = pagingSourceHolder, + deviceName = device.dataOrNull()?.givenName ?: "" + ) + } + } + } + + fun onClickScanQrCode() { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + ScanQRCode.create(isSharedDevice = true) + ) + ) + } + + fun onClickTeacherAdminLogin() { + _navCommandFlow.tryEmit( + NavCommand.Navigate(TeacherPinConfirmation) + ) + } + + fun onClickClazz(clazz: Clazz) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + StudentList.create( + className = clazz.title, + guid = clazz.guid, + ) + ) + ) + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/login/StudentListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/login/StudentListViewModel.kt new file mode 100644 index 000000000..60d99f616 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/sharedschooldevice/login/StudentListViewModel.kt @@ -0,0 +1,108 @@ +package world.respect.shared.viewmodel.sharedschooldevice.login + +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.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.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.school.PersonDataSource +import world.respect.datalayer.school.model.EnrollmentRoleEnum +import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.PersonStatusEnum +import world.respect.datalayer.school.writequeue.EnqueueRunPullSyncUseCase +import world.respect.datalayer.shared.paging.EmptyPagingSourceFactory +import world.respect.datalayer.shared.paging.IPagingSourceFactory +import world.respect.datalayer.shared.paging.PagingSourceFactoryHolder +import world.respect.libutil.util.time.localDateInCurrentTimeZone +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.navigation.AssignmentList +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.StudentList +import world.respect.shared.navigation.WaitingForApproval +import world.respect.shared.resources.UiText +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel + +data class StudentListUiState( + val error: UiText? = null, + val students: IPagingSourceFactory = EmptyPagingSourceFactory(), +) + +class StudentListViewModel( + savedStateHandle: SavedStateHandle, + private val accountManager: RespectAccountManager, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val schoolDataSource: SchoolDataSource by inject() + private val route: StudentList = savedStateHandle.toRoute() + + private val _uiState = MutableStateFlow(StudentListUiState()) + val uiState = _uiState.asStateFlow() + + private val pagingSourceHolder = PagingSourceFactoryHolder { + val params = PersonDataSource.GetListParams( + filterByClazzUid = route.guid, + filterByEnrolmentRole = EnrollmentRoleEnum.STUDENT, + inClassOnDay = localDateInCurrentTimeZone() + ) + val result = schoolDataSource.personDataSource.listAsPagingSource( + loadParams = DataLoadParams(), + params = params + ) + result + } + private val enqueuePullSyncUseCase: EnqueueRunPullSyncUseCase by inject() + + + init { + _appUiState.update { + it.copy( + title = route.className.asUiText(), + hideBottomNavigation = true, + userAccountIconVisible = false + ) + } + + _uiState.update { prev -> + prev.copy( + students = pagingSourceHolder, + ) + } + viewModelScope.launch { + enqueuePullSyncUseCase() + } + } + + fun onClickStudent(person: Person) { + viewModelScope.launch { + try { + accountManager.switchProfile(person.guid) + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = if (person.status != PersonStatusEnum.PENDING_APPROVAL) { + AssignmentList + } else { + WaitingForApproval() + }, + clearBackStack = true + ) + ) + } catch (e: Exception) { + _uiState.update { + it.copy( + error = e.message?.asUiText(), + ) + } + } + } + } +} diff --git a/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseDb.kt b/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseDb.kt index b294e00e6..06d2c72ca 100644 --- a/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseDb.kt +++ b/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseDb.kt @@ -23,11 +23,12 @@ import world.respect.datalayer.school.ext.accepterPersonRole import world.respect.datalayer.school.ext.copyWithInviteInfo import world.respect.datalayer.school.ext.isApprovalRequiredNow import world.respect.datalayer.school.model.AuthToken -import world.respect.datalayer.school.model.Invite2 -import world.respect.datalayer.school.model.NewUserInvite import world.respect.datalayer.school.model.ClassInvite import world.respect.datalayer.school.model.ClassInviteModeEnum import world.respect.datalayer.school.model.Enrollment +import world.respect.datalayer.school.model.Invite2 +import world.respect.datalayer.school.model.NewUserInvite +import world.respect.datalayer.school.model.PersonRoleEnum import world.respect.datalayer.school.model.PersonStatusEnum import world.respect.datalayer.school.model.StatusEnum import world.respect.libutil.ext.randomString @@ -39,7 +40,6 @@ import world.respect.shared.domain.account.setpassword.EncryptPersonPasswordUseC import world.respect.shared.domain.school.SchoolPrimaryKeyGenerator import world.respect.shared.util.di.SchoolDataSourceLocalProvider import world.respect.shared.util.toPerson -import java.lang.IllegalArgumentException import kotlin.time.Clock /** @@ -60,7 +60,8 @@ class RedeemInviteUseCaseDb( ) : RedeemInviteUseCase, KoinComponent { override suspend fun invoke( - redeemRequest: RespectRedeemInviteRequest + redeemRequest: RespectRedeemInviteRequest, + useActiveUserAuth: Boolean ): AuthResponse { val inviteFromDb = schoolDb.getInviteEntityDao().getInviteByInviteCode( redeemRequest.code @@ -70,25 +71,27 @@ class RedeemInviteUseCaseDb( val accountGuid = redeemRequest.account.guid - val approvalRequired = inviteFromDb.isApprovalRequiredNow() - + val isSharedDeviceInvite = + redeemRequest.invite.accepterPersonRole == PersonRoleEnum.SHARED_SCHOOL_DEVICE + val approvalRequired = if (isSharedDeviceInvite && useActiveUserAuth) { + false + } else { + inviteFromDb.isApprovalRequiredNow() + } val accountPerson = redeemRequest.accountPersonInfo.toPerson( role = redeemRequest.invite.accepterPersonRole, username = redeemRequest.account.username, guid = accountGuid, ).copy( - status = if(approvalRequired){ + status = if (approvalRequired) { PersonStatusEnum.PENDING_APPROVAL - }else { + } else { PersonStatusEnum.ACTIVE }, - ).let { - if(approvalRequired) { - it.copyWithInviteInfo(invite = redeemRequest.invite) - }else { - it - } - } + ).copyWithInviteInfo( + invite = redeemRequest.invite, + deviceInfo = redeemRequest.deviceInfo + ) val schoolDataSourceVal = schoolDataSource( schoolUrl = schoolUrl, AuthenticatedUserPrincipalId(accountGuid) @@ -180,6 +183,29 @@ class RedeemInviteUseCaseDb( is RespectQRBadgeCredential -> { throw IllegalArgumentException("Using a QR code badge to redeem invite for new account not yet supported") } + null -> { + // Handle shared school device case - no credential needed + // For shared devices, we just create the person account without authentication credentials + val token = AuthToken( + accessToken = randomString(32), + timeCreated = System.currentTimeMillis(), + ttl = GetTokenAndUserProfileWithCredentialDbImpl.TOKEN_DEFAULT_TTL, + ) + + val personGuidHash = uidNumberMapper(accountPerson.guid) + schoolDb.getAuthTokenEntityDao().insert( + token.toEntity( + pGuid = accountPerson.guid, + pGuidHash = personGuidHash, + deviceInfo = redeemRequest.deviceInfo, + ) + ) + + AuthResponse( + token = token, + person = accountPerson, + ) + } } markFirstUserInviteAsDeleted(inviteFromDb, schoolDataSourceVal) diff --git a/respect-lib-util/src/commonMain/kotlin/world/respect/libutil/util/time/LocalDateExt.kt b/respect-lib-util/src/commonMain/kotlin/world/respect/libutil/util/time/LocalDateExt.kt index ba5d78d93..094220c39 100644 --- a/respect-lib-util/src/commonMain/kotlin/world/respect/libutil/util/time/LocalDateExt.kt +++ b/respect-lib-util/src/commonMain/kotlin/world/respect/libutil/util/time/LocalDateExt.kt @@ -3,7 +3,20 @@ package world.respect.libutil.util.time import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.number +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant fun LocalDate.atStartOfDayInMillisUtc(): Long { return atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() +} + +fun String.toFormattedDate(): String = try { + val dt = Instant.parse(this).toLocalDateTime(TimeZone.currentSystemDefault()) + "${dt.month.number}/${dt.day}/${dt.year}, ${ + dt.hour.toString().padStart(2, '0') + }:${dt.minute.toString().padStart(2, '0')}" +} catch (e: Exception) { + println("Date parsing failed: ${e.message}") + this } \ No newline at end of file diff --git a/respect-server/src/main/kotlin/world/respect/server/Application.kt b/respect-server/src/main/kotlin/world/respect/server/Application.kt index 539c3633f..778bfc0f9 100644 --- a/respect-server/src/main/kotlin/world/respect/server/Application.kt +++ b/respect-server/src/main/kotlin/world/respect/server/Application.kt @@ -54,6 +54,7 @@ import world.respect.server.routes.school.respect.PersonRoute import world.respect.server.routes.school.respect.PlaylistRoute import world.respect.server.routes.school.respect.RedeemInviteRoute import world.respect.server.routes.school.respect.SchoolAppRoute +import world.respect.server.routes.school.respect.SchoolConfigSettingRoute import world.respect.server.routes.school.respect.SchoolRegistrationRoute import world.respect.server.routes.school.respect.SchoolLinkRoute import world.respect.server.routes.school.respect.SchoolPermissionGrantRoute @@ -228,9 +229,11 @@ fun Application.module() { AuthRoute() } route("invite") { - RedeemInviteRoute( - redeemInviteUseCase = { it.getSchoolKoinScope().get() } - ) + authenticate(AUTH_CONFIG_SCHOOL, optional = true) { + RedeemInviteRoute( + redeemInviteUseCase = { it.getSchoolKoinScope().get() } + ) + } InviteInfoRoute( getInviteInfoUseCase = { it.getSchoolKoinScope().get() } ) @@ -253,6 +256,7 @@ fun Application.module() { EnrollmentRoute() AssignmentRoute() PersonQrBadgeRoute() + SchoolConfigSettingRoute() AddChildAccountRoute( addChildAccountUseCase = { it.requireAccountScope().get() } ) diff --git a/respect-server/src/main/kotlin/world/respect/server/ServerKoinModule.kt b/respect-server/src/main/kotlin/world/respect/server/ServerKoinModule.kt index 648d1d44a..46875d8cf 100644 --- a/respect-server/src/main/kotlin/world/respect/server/ServerKoinModule.kt +++ b/respect-server/src/main/kotlin/world/respect/server/ServerKoinModule.kt @@ -39,7 +39,6 @@ import world.respect.libxxhash.XXStringHasher import world.respect.libxxhash.jvmimpl.XXStringHasherCommonJvm import world.respect.server.account.invite.GetInviteInfoUseCaseServer import world.respect.server.account.invite.username.UsernameSuggestionUseCaseServer -import world.respect.shared.domain.account.passkey.VerifySignInWithPasskeyUseCase import world.respect.server.domain.school.add.AddSchoolUseCase import world.respect.server.domain.school.add.AddServerManagedDirectoryCallback import world.respect.server.domain.school.add.RegisterSchoolUseCaseImpl @@ -58,15 +57,20 @@ import world.respect.shared.domain.account.invite.GetInviteInfoUseCase import world.respect.shared.domain.account.invite.RedeemInviteUseCase import world.respect.shared.domain.account.invite.RedeemInviteUseCaseDb import world.respect.shared.domain.account.passkey.DecodeUserHandleUseCaseImpl -import world.respect.shared.domain.account.passkey.GetPasskeyProviderInfoUseCaseImpl import world.respect.shared.domain.account.passkey.GetActivePersonPasskeysDbImpl import world.respect.shared.domain.account.passkey.GetActivePersonPasskeysUseCase +import world.respect.shared.domain.account.passkey.GetPasskeyProviderInfoUseCaseImpl import world.respect.shared.domain.account.passkey.LoadAaguidJsonUseCase import world.respect.shared.domain.account.passkey.LoadAaguidJsonUseCaseJvm import world.respect.shared.domain.account.passkey.RevokePasskeyUseCase import world.respect.shared.domain.account.passkey.RevokePersonPasskeyUseCaseDbImpl +import world.respect.shared.domain.account.passkey.VerifySignInWithPasskeyUseCase import world.respect.shared.domain.account.setpassword.EncryptPersonPasswordUseCase import world.respect.shared.domain.account.setpassword.EncryptPersonPasswordUseCaseImpl +import world.respect.shared.domain.account.sharedschooldevice.setpin.GetSharedDevicePINUseCase +import world.respect.shared.domain.account.sharedschooldevice.setpin.GetSharedDevicePINUseCaseImpl +import world.respect.shared.domain.account.sharedschooldevice.setpin.SetSharedDevicePINUseCase +import world.respect.shared.domain.account.sharedschooldevice.setpin.SetSharedDevicePINUseCaseImpl import world.respect.shared.domain.account.username.UsernameSuggestionUseCase import world.respect.shared.domain.account.username.filterusername.FilterUsernameUseCase import world.respect.shared.domain.account.validateauth.ValidateAuthorizationUseCase @@ -405,7 +409,14 @@ fun serverKoinModule( ) } + factory { + SetSharedDevicePINUseCaseImpl(schoolDataSource = get()) + } + factory { + GetSharedDevicePINUseCaseImpl( + schoolDataSource = get(), + setSharedDevicePINUseCase = get() + ) + } } - - -} \ No newline at end of file +} diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteRoute.kt index f49528ec4..e98315910 100644 --- a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteRoute.kt +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteRoute.kt @@ -1,12 +1,14 @@ package world.respect.server.routes.school.respect import io.ktor.server.application.ApplicationCall +import io.ktor.server.auth.UserIdPrincipal +import io.ktor.server.auth.principal import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.post -import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest import world.respect.shared.domain.account.invite.RedeemInviteUseCase +import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest fun Route.RedeemInviteRoute( redeemInviteUseCase: (ApplicationCall) -> RedeemInviteUseCase @@ -14,7 +16,14 @@ fun Route.RedeemInviteRoute( post("redeem") { val redeemRequest: RespectRedeemInviteRequest = call.receive() - call.respond(redeemInviteUseCase(call).invoke(redeemRequest)) + + val isAuthenticated = call.principal() != null + + val response = redeemInviteUseCase(call).invoke( + redeemRequest, + useActiveUserAuth = isAuthenticated + ) + call.respond(response) } } \ No newline at end of file diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt new file mode 100644 index 000000000..f15df437e --- /dev/null +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt @@ -0,0 +1,43 @@ +package world.respect.server.routes.school.respect + +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receive +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.server.util.ext.requireAccountScope +import world.respect.server.util.ext.respondDataLoadState + +@Suppress("FunctionName") +fun Route.SchoolConfigSettingRoute( + schoolDataSource: (ApplicationCall) -> SchoolDataSource = { call -> + call.requireAccountScope().get() + }, +) { + get(SchoolConfigSettingDataSource.ENDPOINT_NAME) { + call.response.header(HttpHeaders.Vary, HttpHeaders.Authorization) + call.respondDataLoadState( + schoolDataSource(call).schoolConfigSettingDataSource.list( + loadParams = DataLoadParams(), + params = SchoolConfigSettingDataSource.GetListParams.fromParams( + call.request.queryParameters + ) + ) + ) + } + + post(SchoolConfigSettingDataSource.ENDPOINT_NAME) { + val schoolDataSource = schoolDataSource(call) + val settings: List = call.receive() + schoolDataSource.schoolConfigSettingDataSource.store(settings) + call.respond(HttpStatusCode.NoContent) + } +} diff --git a/respect-test-end-to-end/README.md b/respect-test-end-to-end/README.md index b449a3d11..896c44376 100644 --- a/respect-test-end-to-end/README.md +++ b/respect-test-end-to-end/README.md @@ -14,6 +14,8 @@ End-to-end tests that start a blank new server/app and test functionality end-to 1.3 [User can login to school using school link instead of school name](test-description/001_003_login_using_school_link_test_description.md) +1.4 [Admin enables shared school device mode, manages devices, and student login via link and QR badge](test-description/001_004_shared_device_test.md) + 1.5 [Add School via Self Registration](test-description/001_005_add_school_self_registration_test_description.md) ### 2 : Apps diff --git a/respect-test-end-to-end/test-description/001_004_shared_device_test.md b/respect-test-end-to-end/test-description/001_004_shared_device_test.md new file mode 100644 index 000000000..69eda9a77 --- /dev/null +++ b/respect-test-end-to-end/test-description/001_004_shared_device_test.md @@ -0,0 +1,85 @@ +# Admin enables shared school device mode, manages devices, and student login via link and QR badge + +## Description: +This test covers the end-to-end workflow of setting up "Shared School Device" mode. It involves an admin creating classes and users, configuring the device into shared mode (kiosk), a teacher unlocking the device to approve it, and finally, adding a second device via a link where a student logs in using a QR code badge. + +## Step-by-step procedure: + +### A) Admin creates class and users + +1. Run the school admin login flow. +2. Run the subflow to add a new class named "New Class". +3. Run the subflow to add a student ("StudentA User") to "New Class" and generate their QR badge link. +4. Run the subflow to add a teacher ("TeacherA User") to "New Class". + +### B) Admin configures shared device PIN and enables mode locally + +1. Navigate to Apps > Settings > School > Shared school device. +2. Click on Set PIN. +3. Attempt to save an invalid PIN (less than 4 digits or alphanumeric) to verify error handling. +4. Enter a valid 4-digit PIN ("1234") and click Save. +5. Click Add device and select This device. +6. Enter "Test Device 1" as the device name and click Enable. +7. Verify the app enters shared mode (displays "Select class" and "Scan QR code badge"). + +### C) Teacher unlocks device and approves pending request + +1. Click Teacher/admin login. +2. Enter the device PIN (verify validation for incorrect PIN first, then enter "1234"). +3. Proceed to the standard login screen. +4. Login using the "TeacherA" credentials created in step A. +5. Navigate to Apps > Settings > School > Shared school device. +6. Toggle the "Student can self-select their class and name" switch. +7. Observe the device name and count. + + +### D) Adding a second device via invite link and Student QR login + +1. Click Add device and select Another device. +2. Toggle Approval required to OFF. +3. Copy the invite link. +4. Open the invite link (simulating a new device flow). +5. Click Get Started. +6. Enter "Test Device 2" as the device name and click Enable. +7. Click Scan QR code badge. +8. Select More Options > Paste URL. +9. Paste the Student QR badge link (generated in section A) and click OK. +10. Verify the student is successfully logged in and directed to the Apps screen. + +### E) Student Logout + +1. Student taps the User Profile icon. +2. Clicks Logout. +3. Device returns to the shared login screen. + +### F) Teacher Verifies Devices on Device 2 + +1. Tap Teacher/Admin Login. +2. Enter the Device PIN. +3. Tap Next. +4. Login using teacher credentials. +5. Navigate to: Apps → Settings → School → Shared school device +6. Verify: + - Total devices count shows 2 devices + - Test Device 2 is marked as This device + +### G) Restrict Student Self-Selection + +1. Locate the toggle: Student can self-select class and name +2. Turn the toggle OFF. +3. This ensures students cannot manually choose their identity. + +### H) Student Login via QR Badge (Restricted Mode) + +1. From shared login screen, tap Scan QR Code Badge. +2. Tap More Options. +3. Select Paste URL. +4. Paste the student QR badge link. +5. Tap OK. + +### I) Verify Restricted Access + +1. Student is logged in successfully. +2. Verify student lands on the Assignments screen. +3. Confirm student can access: Assignments & Apps +4. Confirm student cannot access: Class & People \ No newline at end of file