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..30813d54f 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 @@ -21,7 +21,7 @@ onFlowComplete: - runFlow: "subflows/school_admin_login_flow.yaml" - assertVisible: id: "app_title" - text: "Apps" + text: "Home" - tapOn: "People" - tapOn: id: "ExpandableFab" # +Person button @@ -98,8 +98,8 @@ onFlowComplete: - tapOn: "Sign-up" - assertVisible: id: "app_title" - text: "Apps" -- assertVisible: "Apps" + text: "Home" +- assertVisible: "Home" - assertVisible: "Assignments" - assertVisible: "People" - runFlow: @@ -190,7 +190,7 @@ onFlowComplete: id : "password" - inputText: "test123" - tapOn: "Login" -- assertVisible: "Apps" +- assertVisible: "Home" - tapOn: "Classes" - assertVisible: id: "app_title" diff --git a/.maestro/flows/001_002_add_user_direct_test.yaml b/.maestro/flows/001_002_add_user_direct_test.yaml index 233225b13..8d8ccb485 100644 --- a/.maestro/flows/001_002_add_user_direct_test.yaml +++ b/.maestro/flows/001_002_add_user_direct_test.yaml @@ -331,7 +331,7 @@ onFlowComplete: when: visible: "Save password for Respect?" file: "subflows/save_password_prompt_cancel.yaml" -- assertVisible: "Apps" +- assertVisible: "Home" - tapOn: "People" - tapOn: "ParentA User" - tapOn: "Manage account" @@ -363,7 +363,7 @@ onFlowComplete: - assertVisible: id: "app_title" text: "Assignments" -- assertVisible: "Apps" +- assertVisible: "Home" - assertNotVisible: "Classes" - assertNotVisible: "People" @@ -382,10 +382,10 @@ onFlowComplete: - tapOn: "Url" - inputText: ${output.SCHOOL_URL}respect_school_link/personqrbadge/id/12312 - tapOn: "OK" -- assertVisible: "Apps" +- assertVisible: "Home" - assertVisible: id: "app_title" - text: "Apps" + text: "Home" - assertVisible: "Assignments" - assertVisible: "Classes" - assertVisible: "People" \ No newline at end of file diff --git a/.maestro/flows/001_003_login_using_school_link_test.yaml b/.maestro/flows/001_003_login_using_school_link_test.yaml index 53551a1e6..0559c7f62 100644 --- a/.maestro/flows/001_003_login_using_school_link_test.yaml +++ b/.maestro/flows/001_003_login_using_school_link_test.yaml @@ -48,5 +48,5 @@ onFlowComplete: when: visible: "Save password for Respect?" file: "subflows/save_password_prompt_cancel.yaml" -- assertVisible: "Apps" +- assertVisible: "Home" diff --git a/.maestro/flows/001_005_add_school_self_registration_test.yaml b/.maestro/flows/001_005_add_school_self_registration_test.yaml index 9c67b6774..858b88e6d 100644 --- a/.maestro/flows/001_005_add_school_self_registration_test.yaml +++ b/.maestro/flows/001_005_add_school_self_registration_test.yaml @@ -84,7 +84,7 @@ onFlowComplete: - tapOn: "Sign-up" - assertVisible: id: "app_title" - text: "Apps" + text: "Home" - tapOn: id: "user_account_icon" - assertVisible: "Profile" @@ -106,4 +106,4 @@ onFlowComplete: when: visible: "Save password for Respect?" file: "subflows/save_password_prompt_cancel.yaml" -- assertVisible: "Apps" \ No newline at end of file +- assertVisible: "Home" \ No newline at end of file diff --git a/.maestro/flows/002_browse_lessons_test.yaml b/.maestro/flows/002_browse_lessons_test.yaml deleted file mode 100644 index ccfc227b5..000000000 --- a/.maestro/flows/002_browse_lessons_test.yaml +++ /dev/null @@ -1,52 +0,0 @@ -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: "002_browse_lessons_test" - -onFlowComplete: - - runScript: - file: "scripts/teardown.js" ---- -- runFlow: "subflows/school_admin_login_flow.yaml" -- tapOn: - id: "floating_action_button" -- tapOn: "Add from Link" -- tapOn: "Link*" -- inputText: ${output.SCHOOL_URL}static-resources/respect-ds/case_valid/appmanifest.json -- tapOn: "Next" -- assertVisible: - id: "app_title" - text: "App detail" -- assertVisible: "My app" -- assertVisible: "Add App" -# verify App got added to Apps section -- tapOn: "Add App" -- tapOn: "Apps" -- assertVisible: - id: "app_title" - text: "Apps" -- assertVisible: "My app" -- tapOn: "My app" -- assertVisible: "Lessons" -- tapOn: "Lessons" -- tapOn: "Grade 1" -- tapOn: "Lesson 001" -- assertVisible: "Lesson 001" -- assertVisible: "App name" -- tapOn: "Open" -- extendedWaitUntil: - visible: "Lesson 001" - timeout: 1000 -- assertVisible: "Hello World Lesson" -- tapOn: "Close" - - diff --git a/.maestro/flows/002_user_add_apps_playlists_bookmarks_test.yaml b/.maestro/flows/002_user_add_apps_playlists_bookmarks_test.yaml new file mode 100644 index 000000000..b9db753bc --- /dev/null +++ b/.maestro/flows/002_user_add_apps_playlists_bookmarks_test.yaml @@ -0,0 +1,337 @@ +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: "002_user_add_apps_playlists_bookmarks_test" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" +--- +- runFlow: "subflows/school_admin_login_flow.yaml" +- tapOn: + id: "floating_action_button" +- tapOn: "Add from Link" +- tapOn: "Link*" +- inputText: ${output.SCHOOL_URL}static-resources/respect-ds/case_valid/appmanifest.json +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "App detail" +- assertVisible: "My app" +- assertVisible: "Add App" +# verify App got added to Apps section +- tapOn: "Add App" +#- tapOn: +# id: home_btn +- tapOn: + text: "Home" + index: 0 +#- assertVisible: +# id: "app_title" +# text: "Home" +- tapOn: "Apps" +- assertVisible: "Playlists" +- assertVisible: "My app" +- tapOn: "My app" +- assertVisible: "Lessons" +- tapOn: "Lessons" +- tapOn: "Grade 1" +- assertVisible: "Lesson 001" +- assertVisible: "Lesson 002" +- tapOn: "Lesson 001" +- assertVisible: "Lesson 001" +- assertVisible: "App name" +- tapOn: "Open" +- extendedWaitUntil: + visible: "Lesson 001" + timeout: 1000 +- assertVisible: "Hello World Lesson" +- tapOn: "Close" + +- runFlow: + file: "subflows/admin_add_class.yaml" + env: + CLASSNAME: "New Class" +- tapOn: + text: "Home" + index: 0 +- tapOn: "Playlists" +- assertVisible: "All" +- assertVisible: "My Playlists" +- assertVisible: "No playlist yet" +- assertVisible: "Add a playlist by clicking on the + playlist" +- tapOn: + id: "floating_action_button" # Playlist button +- assertVisible: + id: "add_new" +- assertVisible: + id: "add_from_a_link" +- tapOn: + id: "add_new" +- assertVisible: + id: "app_title" + text: "Add playlist" +- tapOn: "Save" # To test mandatory fields +- assertVisible: "Required field" #Title field is mandatory +- tapOn: "Title*" +- inputText: "Playlist - Grade 1" +- tapOn: "Description" +- inputText: "Test list" +#- tapOn: "Subject" # Need to Implement +#- tapOn: "English" +#- tapOn: "Grade" +#- tapOn: "Grade 1" +- tapOn: "+ Section" +- assertVisible: "Choose Section Type:" +- assertVisible: + text: "Playlist section" + index: 0 +- assertVisible: "Links to other playlists." +- assertVisible: + text: "Learning item section" + index: 1 +- assertVisible: "Links direct to items e.g. lessons, assessments, videos, etc" +- tapOn: + text: "Playlist section" + index: 0 +- assertVisible: "Playlist section" +- tapOn: "Playlist section" +- runFlow: + file: "subflows/erase_text.yaml" + env: + TEXT: "Playlist section" +- inputText: "Playlist section day 1" +- hideKeyboard +- tapOn: "+ Playlist" +- assertVisible: + id: "app_title" + text: "Home" +- tapOn: "My app" +- assertVisible: + id: "app_title" + text: "My App" +- assertVisible: "Grade 1" +- tapOn: "Grade 1" +#- assertVisible: "Lesson 001" +#- assertVisible: "Lesson 002" +- tapOn: "Select playlist" +- assertVisible: + id: "app_title" + text: "Add playlist" +- assertVisible: "Grade 1" +- tapOn: "+ Section" +- tapOn: + text: "Learning item section" + index: 1 +- assertVisible: "Learning item section" +- tapOn: "+ Item" +- assertVisible: "Apps" +- tapOn: "My app" +- assertVisible: + id: "app_title" + text: "My app" +- tapOn: "Grade 1" +- assertVisible: "Lesson 001" +- tapOn: "Lesson 002" +- tapOn: "Select 1 item" +- assertVisible: + id: "app_title" + text: "Add playlist" +- assertVisible: "Playlist section day 1" +- assertVisible: "Learning item section" +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Playlist - Grade 1" +- assertVisible: "Playlist section day 1" +- tapOn: + id: "expand_collapse_icon" + index: 0 +#- assertVisible: "My app" +- assertVisible: "Learning item section" +- assertVisible: "Lesson 002" +- tapOn: + id: "expand_collapse_icon" + index: 1 +- tapOn: + id: "floating_action_button" # Edit button +- assertVisible: + id: "app_title" + text: "Edit playlist" +- tapOn: "Learning item section" +- runFlow: + file: "subflows/erase_text.yaml" + env: + TEXT: "Learning item section" +- inputText: "Learning item section day 2" +- hideKeyboard +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Playlist - Grade 1" +- assertVisible: "Playlist section day 1" +- assertVisible: "Learning item section day 2" +- assertVisible: "Share" +- assertVisible: + text: "Copy playlist" + index: 1 +- assertVisible: "Assign" +- assertVisible: "Delete" +- tapOn: "Lesson 002" +- assertVisible: "Lesson 002" +- assertVisible: "App name" +- assertVisible: "Open" +- tapOn: "Home" +- tapOn: "Playlists" +- assertVisible: "Playlist - Grade 1" +- assertVisible: "2 sections, 2 items" +- assertVisible: "Created by: Admin" +- tapOn: "My Playlists" +- assertVisible: "Playlist - Grade 1" +- tapOn: "All" +- assertVisible: "Playlist - Grade 1" +- tapOn: "Playlist - Grade 1" +- assertVisible: + id: "app_title" + text: "Playlist - Grade 1" + +# Assigning lessons in a playlist to a assignment +- tapOn: + id: "header_assign_btn" +- assertVisible: + id: "app_title" + text: "Add assignment" +- tapOn: "Assignment name*" +- inputText: "Homework 1" +- tapOn: "Class" +- tapOn: "New Class" +- tapOn: "Date" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.futureDate} +- tapOn: "Time" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.currentTime} +- assertVisible: "Task" # As per prototype +#- assertVisible: "Lesson 002" +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Homework 1" +- assertVisible: "Lesson 002" +- tapOn: "Home" +- tapOn: "Playlists" +- tapOn: "Playlist - Grade 1" +#- tapOn: "Share" +#- assertVisible: +# id: "app_title" +# text: "Share playlist" +#- tapOn: "Who can view" +#- assertVisible: "Anyone with the link" +#- assertVisible: "Anyone in my school" +#- assertVisible: "Teachers and admins in my school" +#- assertVisible: "Admins in my school" +#- tapOn: "Anyone with the link" +#- tapOn: "Who can edit" +#- assertVisible: "Admins in my school" +#- tapOn: "Teachers and admins in my school" +#- assertVisible: "Share link" +#- assertVisible: "Send link via SMS" +#- assertVisible: "Send link via email" +#- tapOn: +# id: "copy_link_button" +#- assertVisible: +# id: "app_title" +# text: "Enter link" +#- tapOn: "Link*" +#- pasteText +#- tapOn: "Next" +#- assertVisible: +# id: "app_title" +# text: "Playlist - Grade 1" + +- tapOn: + id: "copy_btn" +- assertVisible: "Make a copy" +- assertVisible: "copy the Playlist - Grade 1" +- tapOn: "Copy" +- assertVisible: + id: "app_title" + text: "Copy playlist" +- tapOn: "copy the Playlist - Grade 1" +- runFlow: + file: "subflows/erase_text.yaml" + env: + TEXT: "copy the Playlist - Grade 1" +- inputText: "Playlist 2" +- hideKeyboard +- assertVisible: "Playlist section day 1" +- assertVisible: "Learning item section day 2" +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Playlist 2" + +# Delete this copied playlist +- tapOn: + id: "delete_btn" +- assertVisible: "Permanently delete?" +- assertVisible: "Permanently delete this playlist" +- tapOn: "Delete" +- assertVisible: + id: "app_title" + text: "Home" +- assertVisible: "Playlists" +- tapOn: "Playlists" +- assertVisible: "Playlist - Grade 1" +- assertNotVisible: "Playlist 2" + +# Adding playlist to an assignment +- tapOn: "Assignments" +- tapOn: + id: "floating_action_button" # +Assignment button +- assertVisible: + id: "app_title" + text: "Add assignment" +- tapOn: "Assignment name*" +- inputText: "Homework 2" +- tapOn: "Class" +- tapOn: "New Class" +- tapOn: "Date" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.futureDate} +- tapOn: "Time" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.currentTime} +- tapOn: "Lesson/assessment" +- assertVisible: + id: "app_title" +- tapOn: "Playlists" +- tapOn: "Playlist - Grade 1" +- assertVisible: + id: "app_title" + text: "Playlist - Grade 1" +- tapOn: "Lesson 002" +- assertVisible: + id: "app_title" + text: "Add assignment" +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Homework 2" +#- assertVisible: "Lesson 002" +- tapOn: "Assignments" +- assertVisible: "Homework 1" +- assertVisible: "Homework 2" \ No newline at end of file diff --git a/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml b/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml index 163d04183..3438b6283 100644 --- a/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml +++ b/.maestro/flows/003_admin_user_assigns_assignment_to_a_class_test.yaml @@ -22,14 +22,11 @@ onFlowComplete: - launchApp: arguments: respect_directory: ${output.SCHOOL_URL} - - tapOn: "Get Started" - - runFlow: file: "subflows/get_started_select_school_by_name.yaml" env: SCHOOL_NAME: ${SCHOOL_NAME} - - tapOn: id: "username" - inputText: "teacheruser" @@ -41,7 +38,7 @@ onFlowComplete: when: visible: "Save password for Respect?" file: "subflows/save_password_prompt_cancel.yaml" -- assertVisible: "Apps" +- assertVisible: "Home" - tapOn: "Classes" - assertVisible: id: "app_title" @@ -73,11 +70,15 @@ onFlowComplete: - tapOn: "My app" - tapOn: "Grade 1" - tapOn: "Lesson 001" +- assertVisible: + id: "app_title" + text: "Add assignment" +- assertVisible: "Lesson 001" - tapOn: "Save" - assertVisible: id: "app_title" text: "Homework 1" - assertVisible: "Lesson 001" -- back +- tapOn: "Assignments" - assertVisible: "Homework 1" diff --git a/.maestro/flows/subflows/admin_add_app.yaml b/.maestro/flows/subflows/admin_add_app.yaml index e5a4be4be..d79e10ebb 100644 --- a/.maestro/flows/subflows/admin_add_app.yaml +++ b/.maestro/flows/subflows/admin_add_app.yaml @@ -3,7 +3,8 @@ appId: world.respect.app --- - assertVisible: id: "app_title" - text: "Apps" + text: "Home" +- tapOn: "Apps" - tapOn: id: "floating_action_button" - tapOn: "Add from Link" diff --git a/.maestro/flows/subflows/admin_add_app_and_teacher.yaml b/.maestro/flows/subflows/admin_add_app_and_teacher.yaml index 0fa24bc63..7a6bc25c5 100644 --- a/.maestro/flows/subflows/admin_add_app_and_teacher.yaml +++ b/.maestro/flows/subflows/admin_add_app_and_teacher.yaml @@ -5,7 +5,8 @@ appId: world.respect.app - runFlow: "school_admin_login_flow.yaml" - assertVisible: id: "app_title" - text: "Apps" + text: "Home" +- tapOn: "Apps" - tapOn: id: "floating_action_button" - tapOn: "Add from Link" @@ -16,7 +17,7 @@ appId: world.respect.app id: "app_title" text: "App detail" - tapOn: "Add App" -- tapOn: "Apps" +- tapOn: "Home" - assertVisible: "My app" # Admin add new class 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..4cf757c43 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt @@ -1,4 +1,3 @@ - package world.respect import android.content.Context @@ -156,6 +155,7 @@ import world.respect.shared.domain.report.query.RunReportUseCase import world.respect.shared.domain.school.LaunchCustomTabUseCase import world.respect.shared.domain.school.RespectSchoolPath import world.respect.shared.domain.school.SchoolPrimaryKeyGenerator +import world.respect.shared.domain.sharelink.CreatePlaylistShareLinkUseCase import world.respect.shared.domain.storage.CachePathsProviderAndroid import world.respect.shared.domain.storage.GetAndroidSdCardDirUseCase import world.respect.shared.domain.storage.GetOfflineStorageOptionsUseCaseAndroid @@ -188,6 +188,7 @@ import world.respect.shared.viewmodel.clazz.edit.ClazzEditViewModel import world.respect.shared.viewmodel.clazz.list.ClazzListViewModel import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel +import world.respect.shared.viewmodel.learningunit.list.PlaylistDetailViewModel import world.respect.shared.viewmodel.manageuser.accountlist.AccountListViewModel import world.respect.shared.viewmodel.manageuser.acceptinvite.AcceptInviteViewModel import world.respect.shared.viewmodel.manageuser.enterpasswordsignup.EnterPasswordSignupViewModel @@ -229,10 +230,11 @@ 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.playlists.mapping.edit.PlaylistEditViewModel +import world.respect.shared.viewmodel.playlists.mapping.list.PlaylistListViewModel +import world.respect.shared.viewmodel.playlists.mapping.share.PlaylistShareViewModel import world.respect.shared.viewmodel.schooldirectory.edit.SchoolDirectoryEditViewModel import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryListViewModel import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase @@ -370,8 +372,6 @@ val appKoinModule = module { viewModelOf(::IndicatorDetailViewModel) viewModelOf(::SettingsViewModel) viewModelOf(::ScanQRCodeViewModel) - viewModelOf(::CurriculumMappingListViewModel) - viewModelOf(::CurriculumMappingEditViewModel) viewModelOf(::CreateAccountSetUserNameViewModel) viewModelOf(::ChangePasswordViewModel) viewModelOf(::SchoolDirectoryListViewModel) @@ -384,7 +384,10 @@ val appKoinModule = module { viewModelOf(::EnrollmentEditViewModel) viewModelOf(::InviteQrViewModel) viewModelOf(::CreateAccountSetPasswordViewModel) - + viewModelOf(::PlaylistListViewModel) + viewModelOf(::PlaylistDetailViewModel) + viewModelOf(::PlaylistEditViewModel) + viewModelOf(::PlaylistShareViewModel) single { GetOfflineStorageOptionsUseCaseAndroid( @@ -695,15 +698,6 @@ val appKoinModule = module { ) } - /** - * The SchoolDirectoryEntry scope might be one instance per school url or one instance per account - * per url. - * - * ScopeId is set as per SchoolDirectoryEntryScopeId - * - * If the upstream server provides a list of grants/permission rules then the school database - * can be shared - */ scope { scoped { GetTokenAndUserProfileWithCredentialUseCaseClient( @@ -746,7 +740,6 @@ val appKoinModule = module { ) } - scoped { GetInviteInfoUseCaseClient( schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl, @@ -812,20 +805,9 @@ val appKoinModule = module { schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl ) } - } - /** - * ScopeId is set as per RespectAccountScopeId - * - * The RespectAccount scope will be linked to SchoolDirectoryEntry (the parent) scope. - */ scope { - /* Koin doesn't have an onScopeCreated kind of function or event listener. The - * RespectAccount scope is linked ot the SchoolDirectoryEntry scope when - * RespectAccountSchoolScopeLink is retrieved. RespectAccountSchoolScopeLink is a root - * dependency that all dependencies on RespectAccountScope require. - */ scoped { val accountScopeId = RespectAccountScopeId.parse(id) val schoolDirectoryScope = SchoolDirectoryEntryScopeId( @@ -842,7 +824,6 @@ val appKoinModule = module { RespectAccountSchoolScopeLink(accountScopeId.schoolUrl) } - scoped { get().providerFor(id) } @@ -1008,10 +989,10 @@ val appKoinModule = module { single { MockRunReportUseCaseClientImpl() } - single{ + single { ValidateEmailUseCase() } single { CreateGraphFormatterUseCase() } -} +} \ No newline at end of file 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..89480f7dd 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 @@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import kotlin.Boolean import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.compose.rememberNavController @@ -52,6 +53,7 @@ import world.respect.shared.generated.resources.parents_only import world.respect.shared.generated.resources.cancel import world.respect.shared.generated.resources.classes import world.respect.shared.generated.resources.continue_using_fingerprint_or +import world.respect.shared.generated.resources.home import world.respect.shared.generated.resources.people import world.respect.shared.navigation.AccountList import world.respect.shared.navigation.AssignmentList @@ -83,8 +85,8 @@ private val routeNamePrefix = "world.respect.shared.navigation" val APP_TOP_LEVEL_NAV_ITEMS = listOf( TopNavigationItem( destRoute = Home, - icon = Icons.Filled.GridView, - label = Res.string.apps, + icon = Icons.Filled.Home, + label = Res.string.home, routeName = "$routeNamePrefix.Home", ), TopNavigationItem( @@ -116,7 +118,7 @@ val APP_TOP_LEVEL_NAV_ITEMS_FOR_CHILD = listOf( TopNavigationItem( destRoute = Home, icon = Icons.Filled.GridView, - label = Res.string.apps, + label = Res.string.home, routeName = "$routeNamePrefix.Home", ), ) 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..6533b8886 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 @@ -17,13 +17,12 @@ import world.respect.app.view.assignment.list.AssignmentListScreen import world.respect.app.view.clazz.detail.ClazzDetailScreen import world.respect.app.view.clazz.edit.ClazzEditScreen import world.respect.app.view.clazz.list.ClazzListScreen -import world.respect.app.view.curriculum.mapping.edit.CurriculumMappingEditScreenForViewModel -import world.respect.app.view.curriculum.mapping.list.CurriculumMappingListScreenForViewModel import world.respect.app.view.enrollment.edit.EnrollmentEditScreen 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.learningunit.list.PlaylistDetailScreenForViewModel import world.respect.app.view.manageuser.accountlist.AccountListScreen import world.respect.app.view.manageuser.acceptinvite.AcceptInviteScreen import world.respect.app.view.manageuser.createaccount.CreateAccountScreen @@ -49,6 +48,9 @@ import world.respect.app.view.person.passkeyList.PasskeyListScreen import world.respect.app.view.person.qrcode.InviteQrScreen import world.respect.app.view.person.setusernameandpassword.CreateAccountSetPasswordScreen import world.respect.app.view.person.setusernameandpassword.CreateAccountSetUsernameScreen +import world.respect.app.view.playlists.mapping.edit.PlaylistEditScreenForViewModel +import world.respect.app.view.playlists.mapping.list.PlaylistListScreenForViewModel +import world.respect.app.view.playlists.mapping.share.PlaylistShareScreenForViewModel import world.respect.app.view.report.detail.ReportDetailScreen import world.respect.app.view.report.edit.ReportEditScreen import world.respect.app.view.report.filteredit.ReportFilterEditScreen @@ -77,8 +79,6 @@ import world.respect.shared.navigation.CopyCode import world.respect.shared.navigation.CreateAccount import world.respect.shared.navigation.CreateAccountSetPassword import world.respect.shared.navigation.CreateAccountSetUsername -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.EnterLink @@ -102,6 +102,10 @@ import world.respect.shared.navigation.PasskeyList import world.respect.shared.navigation.PersonDetail import world.respect.shared.navigation.PersonEdit import world.respect.shared.navigation.PersonList +import world.respect.shared.navigation.PlaylistDetail +import world.respect.shared.navigation.PlaylistEdit +import world.respect.shared.navigation.PlaylistList +import world.respect.shared.navigation.PlaylistShare import world.respect.shared.navigation.QrCode import world.respect.shared.navigation.Report import world.respect.shared.navigation.ReportDetail @@ -127,8 +131,6 @@ import world.respect.shared.viewmodel.apps.list.AppListViewModel 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 @@ -158,7 +160,6 @@ import world.respect.shared.viewmodel.schooldirectory.edit.SchoolDirectoryEditVi import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryListViewModel import world.respect.shared.viewmodel.settings.SettingsViewModel - @Composable fun AppNavHost( navController: NavHostController, @@ -168,7 +169,6 @@ fun AppNavHost( onSetAppUiState: (AppUiState) -> Unit, modifier: Modifier, ) { - NavHost( navController = navController, startDestination = Acknowledgement(), @@ -182,14 +182,14 @@ fun AppNavHost( AcknowledgementScreen(viewModel) } - composable{ + composable { val viewModel: OnboardingViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - OnboardingScreen(viewModel) } + composable { val viewModel: LoginViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -207,12 +207,9 @@ fun AppNavHost( } composable { - val viewModel: AppLauncherViewModel = respectViewModel( + HomeScreen( + respectNavController = respectNavController, onSetAppUiState = onSetAppUiState, - navController = respectNavController, - ) - AppLauncherScreen( - viewModel = viewModel ) } @@ -228,9 +225,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - AppsDetailScreen( - viewModel = viewModel - ) + AppsDetailScreen(viewModel = viewModel) } composable { @@ -265,9 +260,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - ClazzListScreen( - viewModel = viewModel - ) + ClazzListScreen(viewModel = viewModel) } composable { @@ -275,9 +268,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - ClazzEditScreen( - viewModel = viewModel - ) + ClazzEditScreen(viewModel = viewModel) } composable { @@ -285,9 +276,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - ClazzDetailScreen( - viewModel = viewModel - ) + ClazzDetailScreen(viewModel = viewModel) } composable { @@ -295,9 +284,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - EnrollmentListScreen( - viewModel = viewModel - ) + EnrollmentListScreen(viewModel = viewModel) } composable { @@ -305,9 +292,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - EnrollmentEditScreen( - viewModel = viewModel - ) + EnrollmentEditScreen(viewModel = viewModel) } composable { @@ -317,6 +302,7 @@ fun AppNavHost( ) ReportDetailScreen(navController = navController, viewModel = viewModel) } + composable { val viewModel: ReportEditViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -324,6 +310,7 @@ fun AppNavHost( ) ReportEditScreen(viewModel = viewModel) } + composable { val viewModel: ReportListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -331,6 +318,7 @@ fun AppNavHost( ) ReportListScreen(viewModel = viewModel) } + composable { val viewModel: ReportTemplateListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -338,6 +326,7 @@ fun AppNavHost( ) ReportTemplateListScreen(viewModel = viewModel) } + composable { val viewModel: IndicatorEditViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -345,6 +334,7 @@ fun AppNavHost( ) IndictorEditScreen(viewModel = viewModel) } + composable { val viewModel: ReportFilterEditViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -352,6 +342,7 @@ fun AppNavHost( ) ReportFilterEditScreen(navController = navController, viewModel = viewModel) } + composable { val viewModel: IndicatorListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -359,6 +350,7 @@ fun AppNavHost( ) IndicatorListScreen(viewModel = viewModel) } + composable { val viewModel: IndicatorDetailViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -374,23 +366,21 @@ fun AppNavHost( ) HowPasskeyWorksScreen(viewModel = viewModel) } + composable { val viewModel: AppListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - AppListScreen( - viewModel = viewModel - ) + AppListScreen(viewModel = viewModel) } + composable { val viewModel: EnterLinkViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - EnterLinkScreen( - viewModel = viewModel - ) + EnterLinkScreen(viewModel = viewModel) } composable { @@ -414,9 +404,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - LearningUnitListScreen( - viewModel = viewModel - ) + LearningUnitListScreen(viewModel = viewModel) } composable { @@ -440,9 +428,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - LearningUnitDetailScreen( - viewModel = viewModel - ) + LearningUnitDetailScreen(viewModel = viewModel) } composable { @@ -450,9 +436,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - SignupScreen( - viewModel = viewModel - ) + SignupScreen(viewModel = viewModel) } composable { @@ -460,9 +444,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - AcceptInviteScreen( - viewModel = viewModel - ) + AcceptInviteScreen(viewModel = viewModel) } composable { @@ -470,9 +452,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - TermsAndConditionScreen( - viewModel = viewModel - ) + TermsAndConditionScreen(viewModel = viewModel) } composable { @@ -480,9 +460,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - CreateAccountScreen( - viewModel = viewModel - ) + CreateAccountScreen(viewModel = viewModel) } composable { @@ -490,9 +468,7 @@ fun AppNavHost( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - WaitingForApprovalScreen( - viewModel = viewModel - ) + WaitingForApprovalScreen(viewModel = viewModel) } composable { @@ -530,6 +506,7 @@ fun AppNavHost( ) ) } + composable { PasskeyListScreen( viewModel = respectViewModel( @@ -547,15 +524,15 @@ fun AppNavHost( ) ) } + composable { val viewModel: SettingsViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - SettingsScreenForViewModel( - viewModel = viewModel - ) + SettingsScreenForViewModel(viewModel = viewModel) } + composable { ScanQRCodeScreen( viewModel = respectViewModel( @@ -565,40 +542,54 @@ fun AppNavHost( ) } - composable { - val viewModel: CurriculumMappingListViewModel = respectViewModel( - onSetAppUiState = onSetAppUiState, - navController = respectNavController + composable { + PlaylistListScreenForViewModel( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) ) - CurriculumMappingListScreenForViewModel( - viewModel = viewModel + } + + composable { + PlaylistDetailScreenForViewModel( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) ) } - composable { - val viewModel: CurriculumMappingEditViewModel = respectViewModel( - onSetAppUiState = onSetAppUiState, - navController = respectNavController + composable { + PlaylistEditScreenForViewModel( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) ) - CurriculumMappingEditScreenForViewModel( - viewModel = viewModel + } + composable { + PlaylistShareScreenForViewModel( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) ) } - composable{ + + composable { val viewModel: SchoolDirectoryListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - SchoolDirectoryListScreen(viewModel) } - composable{ + composable { val viewModel: SchoolDirectoryEditViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - SchoolDirectoryEditScreen(viewModel) } @@ -656,7 +647,4 @@ fun AppNavHost( ) } } -} - - - +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt index 5a81bb08e..f4bb04ddb 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/launcher/AppLauncherScreen.kt @@ -63,7 +63,6 @@ fun AppLauncherScreen( viewModel: AppLauncherViewModel, ) { val uiState by viewModel.uiState.collectAsState() - AppLauncherScreen( uiState = uiState, onClickApp = { viewModel.onClickApp(it) }, @@ -71,7 +70,6 @@ fun AppLauncherScreen( ) } - @Composable fun AppLauncherScreen( uiState: AppLauncherUiState, @@ -250,4 +248,4 @@ fun AppGridItem( Text(text = "-") } } -} +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/assignment/edit/AssignmentEditScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/assignment/edit/AssignmentEditScreen.kt index 7acf30bdc..b87bcc53c 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/assignment/edit/AssignmentEditScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/assignment/edit/AssignmentEditScreen.kt @@ -50,8 +50,9 @@ import world.respect.shared.generated.resources.assignment_tasks import world.respect.shared.generated.resources.clazz import world.respect.shared.generated.resources.delete import world.respect.shared.generated.resources.description -import world.respect.shared.generated.resources.lesson_assessment +import world.respect.shared.generated.resources.task import world.respect.shared.generated.resources.assignment_name +import world.respect.shared.generated.resources.lesson_assessment import world.respect.shared.generated.resources.required import world.respect.shared.util.ext.asUiText import world.respect.shared.viewmodel.app.appstate.getTitle @@ -196,7 +197,7 @@ fun AssignmentEditScreen( Text( modifier = Modifier.defaultItemPadding(), - text = stringResource(Res.string.assignment_tasks), + text = stringResource(Res.string.task), style = MaterialTheme.typography.titleMedium ) diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/curriculum/mapping/edit/CurriculumMappingEditScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/curriculum/mapping/edit/CurriculumMappingEditScreen.kt deleted file mode 100644 index ed08be68a..000000000 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/curriculum/mapping/edit/CurriculumMappingEditScreen.kt +++ /dev/null @@ -1,379 +0,0 @@ -package world.respect.app.view.curriculum.mapping.edit - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.DragHandle -import androidx.compose.material.icons.outlined.ContentPaste -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.Flow -import org.jetbrains.compose.resources.stringResource -import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyListState -import world.respect.app.app.RespectAsyncImage -import world.respect.app.components.defaultItemPadding -import world.respect.app.components.uiTextStringResource -import world.respect.datalayer.DataLoadState -import world.respect.datalayer.DataLoadingState -import world.respect.datalayer.ext.dataOrNull -import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.description -import world.respect.shared.generated.resources.drag -import world.respect.shared.generated.resources.lesson -import world.respect.shared.generated.resources.no_sections_yet -import world.respect.shared.generated.resources.remove_chapter -import world.respect.shared.generated.resources.remove_lesson -import world.respect.shared.generated.resources.required -import world.respect.shared.generated.resources.section -import world.respect.shared.generated.resources.sections -import world.respect.shared.generated.resources.title -import world.respect.shared.generated.resources.section_name -import world.respect.shared.util.ext.asUiText -import world.respect.shared.viewmodel.curriculum.mapping.edit.CurriculumMappingEditUiState -import world.respect.shared.viewmodel.curriculum.mapping.edit.CurriculumMappingEditViewModel -import world.respect.shared.viewmodel.curriculum.mapping.edit.CurriculumMappingSectionUiState -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMappingSection -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMappingSectionLink - - -@Composable -fun CurriculumMappingEditScreenForViewModel( - viewModel: CurriculumMappingEditViewModel -) { - val uiState by viewModel.uiState.collectAsState() - - CurriculumMappingEditScreen( - uiState = uiState, - sectionLinkUiState = viewModel::sectionLinkUiStateFor, - onTitleChanged = viewModel::onTitleChanged, - onDescriptionChanged = viewModel::onDescriptionChanged, - onClickAddSection = viewModel::onClickAddSection, - onClickRemoveSection = viewModel::onClickRemoveSection, - onSectionTitleChanged = viewModel::onSectionTitleChanged, - onSectionMoved = viewModel::onSectionMoved, - onClickAddLesson = viewModel::onClickAddLesson, - onClickRemoveLesson = viewModel::onClickRemoveLesson, - ) -} - -@Composable -fun CurriculumMappingEditScreen( - uiState: CurriculumMappingEditUiState = CurriculumMappingEditUiState(), - sectionLinkUiState: (CurriculumMappingSectionLink) -> Flow>, - onTitleChanged: (String) -> Unit = {}, - onDescriptionChanged: (String) -> Unit = {}, - onClickAddSection: () -> Unit = {}, - onClickRemoveSection: (Int) -> Unit = {}, - onSectionTitleChanged: (Int, String) -> Unit = { _, _ -> }, - onSectionMoved: (Int, Int) -> Unit = { _, _ -> }, - onClickAddLesson: (Int) -> Unit = {}, - onClickRemoveLesson: (Int, Int) -> Unit = { _, _ -> }, -) { - val haptic = LocalHapticFeedback.current - val lazyListState = rememberLazyListState() - val reorderableLazyListState = rememberReorderableLazyListState( - lazyListState = lazyListState, - onMove = { from, to -> - val headerItemCount = 4 //TODO: This MUST be explained - val fromIndex = from.index - headerItemCount - val toIndex = to.index - headerItemCount - - if (fromIndex >= 0 && toIndex >= 0 && - fromIndex < uiState.sections.size && - toIndex < uiState.sections.size) { - onSectionMoved(fromIndex, toIndex) - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - } - } - ) - - - LazyColumn( - state = lazyListState, - modifier = Modifier.fillMaxWidth(), - ) { - item("title") { - OutlinedTextField( - value = uiState.mapping?.title ?: "", - onValueChange = onTitleChanged, - label = { Text(stringResource(Res.string.title)+ "*") }, - modifier = Modifier - .fillMaxWidth() - .defaultItemPadding() - .testTag("name"), - singleLine = true, - isError = uiState.titleError != null, - supportingText = { - Text( - uiTextStringResource( - uiState.titleError ?: Res.string.required.asUiText() - ) - ) - } - ) - } - - item("description") { - OutlinedTextField( - value = uiState.description, - onValueChange = onDescriptionChanged, - label = { Text(stringResource(Res.string.description)) }, - modifier = Modifier.fillMaxWidth().defaultItemPadding(), - singleLine = false, - minLines = 1, - maxLines = Int.MAX_VALUE - ) - } - - item("mapping_title") { - Text( - modifier = Modifier.defaultItemPadding(), - text = stringResource(Res.string.sections), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - item("add_section_button") { - ListItem( - modifier = Modifier.clickable { - onClickAddSection() - }, - headlineContent = { - Text(stringResource(Res.string.section)) - }, - leadingContent = { - Icon(Icons.Filled.Add, contentDescription = null) - }, - ) - } - - if (uiState.sections.isEmpty()) { - item("empty_sections") { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Outlined.ContentPaste, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(Res.string.no_sections_yet), - modifier = Modifier.sizeIn(maxWidth = 160.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } else { - itemsIndexed( - items = uiState.sections, - key = { _, section -> section.uid } - ) { sectionIndex, section -> - ReorderableItem( - state = reorderableLazyListState, - key = section.uid - ) { isDragging -> - SectionItem( - section = section, - sectionLinkUiState = sectionLinkUiState, - sectionIndex = sectionIndex, - isDragging = isDragging, - onSectionTitleChanged = onSectionTitleChanged, - onClickRemoveSection = onClickRemoveSection, - onClickAddLesson = onClickAddLesson, - onClickRemoveLesson = onClickRemoveLesson, - dragModifier = Modifier.draggableHandle( - onDragStarted = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - }, - onDragStopped = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - ) - ) - } - } - } - } -} - - -@Composable -private fun SectionItem( - section: CurriculumMappingSection, - sectionLinkUiState: (CurriculumMappingSectionLink) -> Flow>, - sectionIndex: Int, - isDragging: Boolean, - onSectionTitleChanged: (Int, String) -> Unit, - onClickRemoveSection: (Int) -> Unit, - onClickAddLesson: (Int) -> Unit, - onClickRemoveLesson: (Int, Int) -> Unit, - dragModifier: Modifier = Modifier -) { - Card( - modifier = Modifier.fillMaxWidth().defaultItemPadding(), - elevation = CardDefaults.cardElevation( - defaultElevation = if (isDragging) 8.dp else 2.dp - ) - ) { - Column( - modifier = Modifier.padding(8.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - Icon( - Icons.Filled.DragHandle, - contentDescription = stringResource(Res.string.drag), - modifier = dragModifier - .size(24.dp), - tint = if (isDragging) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.width(8.dp)) - - OutlinedTextField( - value = section.title, - label = { - Text(stringResource(Res.string.section_name)) - }, - onValueChange = { onSectionTitleChanged(sectionIndex, it) }, - modifier = Modifier.weight(1f), - singleLine = true, - enabled = !isDragging - ) - } - - IconButton( - onClick = { onClickRemoveSection(sectionIndex) }, - modifier = Modifier.size(24.dp), - enabled = !isDragging - ) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(Res.string.remove_chapter), - modifier = Modifier.size(16.dp) - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 32.dp, end = 24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton( - onClick = { onClickAddLesson(sectionIndex) }, - modifier = Modifier.fillMaxWidth(), - enabled = !isDragging - ) { - Icon( - Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(Res.string.lesson)) - } - } - - section.items.forEachIndexed { linkIndex, link -> - LessonItem( - link = link, - sectionLinkUiState = sectionLinkUiState, - sectionIndex = sectionIndex, - linkIndex = linkIndex, - onClickRemoveLesson = onClickRemoveLesson, - enabled = !isDragging - ) - if (linkIndex < section.items.size - 1) { - Spacer(modifier = Modifier.height(8.dp)) - } - } - } - } -} - -@Composable -private fun LessonItem( - link: CurriculumMappingSectionLink, - sectionLinkUiState: (CurriculumMappingSectionLink) -> Flow>, - sectionIndex: Int, - linkIndex: Int, - onClickRemoveLesson: (Int, Int) -> Unit, - enabled: Boolean -) { - - val stateFlow = remember(link.href) { - sectionLinkUiState(link) - } - - val linkUiState by stateFlow.collectAsState(initial = DataLoadingState()) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 32.dp, top = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - linkUiState.dataOrNull()?.icon?.also { iconUrl -> - RespectAsyncImage( - uri = iconUrl.toString(), - contentDescription = "", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) - ) - } - - Spacer(Modifier.width(16.dp)) - - Text( - text = link.title ?: "${stringResource(Res.string.lesson)} ${linkIndex + 1}", - modifier = Modifier.weight(1f) - ) - - Spacer(Modifier.width(16.dp)) - - IconButton( - onClick = { onClickRemoveLesson(sectionIndex, linkIndex) }, - modifier = Modifier.size(24.dp), - enabled = enabled - ) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(Res.string.remove_lesson), - modifier = Modifier.size(16.dp) - ) - } - } -} diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/curriculum/mapping/list/CurriculumMappingListScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/curriculum/mapping/list/CurriculumMappingListScreen.kt deleted file mode 100644 index 2458caa36..000000000 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/curriculum/mapping/list/CurriculumMappingListScreen.kt +++ /dev/null @@ -1,165 +0,0 @@ -package world.respect.app.view.curriculum.mapping.list - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Book -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.* -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.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -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.map -import world.respect.shared.generated.resources.more_options -import world.respect.shared.generated.resources.no_textbooks_available -import world.respect.shared.generated.resources.textbooks -import world.respect.shared.viewmodel.curriculum.mapping.list.CurriculumMappingListUiState -import world.respect.shared.viewmodel.curriculum.mapping.list.CurriculumMappingListViewModel -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMapping - -@Composable -fun CurriculumMappingListScreen( - uiState: CurriculumMappingListUiState = CurriculumMappingListUiState(), - onClickMapping: (CurriculumMapping) -> Unit = {}, - onClickMoreOptions: (CurriculumMapping) -> Unit = {}, - onClickMap: () -> Unit = {}, -) { - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - if (uiState.mappings.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(Res.string.no_textbooks_available), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) - } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - items( - items = uiState.mappings, - key = { mapping -> mapping.uid } - ) { mapping -> - MappingCard( - mapping = mapping, - onClickMapping = onClickMapping, - onClickMoreOptions = onClickMoreOptions - ) - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MappingCard( - mapping: CurriculumMapping, - onClickMapping: (CurriculumMapping) -> Unit, - onClickMoreOptions: (CurriculumMapping) -> Unit -) { - Card( - onClick = { onClickMapping(mapping) }, - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(12.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - Icons.Filled.Book, - contentDescription = stringResource(Res.string.textbooks), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = mapping.title, - style = MaterialTheme.typography.titleSmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - IconButton( - onClick = { onClickMoreOptions(mapping) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - Icons.Filled.MoreVert, - contentDescription = stringResource(Res.string.more_options) - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = mapping.title.split(" ") - .mapNotNull { it.firstOrNull()?.uppercase() } - .take(2) - .joinToString(""), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } -} - -@Composable -fun CurriculumMappingListScreenForViewModel( - viewModel: CurriculumMappingListViewModel -) { - val uiState by viewModel.uiState.collectAsState() - - CurriculumMappingListScreen( - uiState = uiState, - onClickMapping = viewModel::onClickMapping, - onClickMoreOptions = viewModel::onClickMoreOptions, - onClickMap = viewModel::onClickMap - ) -} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/home/HomeScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/home/HomeScreen.kt index 47caed99a..255e38b16 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/home/HomeScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/home/HomeScreen.kt @@ -15,22 +15,23 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import world.respect.app.view.apps.launcher.AppLauncherScreen -import world.respect.app.view.assignment.list.AssignmentListScreen +import world.respect.app.view.playlists.mapping.list.PlaylistListScreenForViewModel import world.respect.app.viewmodel.respectViewModel import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.apps -import world.respect.shared.generated.resources.textbooks +import world.respect.shared.generated.resources.playlists import world.respect.shared.navigation.RespectComposeNavController import world.respect.shared.viewmodel.app.appstate.AppUiState import world.respect.shared.viewmodel.apps.launcher.AppLauncherViewModel +import world.respect.shared.viewmodel.playlists.mapping.list.PlaylistListViewModel enum class HomeScreenTabs(val label: StringResource) { APPS(Res.string.apps), //Temporary example //PLAYLISTS(Res.string.textbooks) + PLAYLISTS(Res.string.playlists) } - @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( @@ -79,15 +80,13 @@ fun HomeScreen( ) } - /* Temporary example. HomeScreenTabs.PLAYLISTS -> { - AssignmentListScreen( - viewModel = respectViewModel( - onSetAppUiState = onSetAppUiState, - navController = respectNavController, - ) + val viewModel: PlaylistListViewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, ) - }*/ + PlaylistListScreenForViewModel(viewModel = viewModel) + } } } } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/list/LearningUnitListScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/list/LearningUnitListScreen.kt index 71cabda51..c8b216e57 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/list/LearningUnitListScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/list/LearningUnitListScreen.kt @@ -2,11 +2,13 @@ package world.respect.app.view.learningunit.list -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -17,41 +19,75 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Task +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +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.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import world.respect.app.app.RespectAsyncImage +import world.respect.lib.opds.model.OpdsPublication +import world.respect.lib.opds.model.ReadiumLink import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.assign +import world.respect.shared.generated.resources.cancel import world.respect.shared.generated.resources.classes +import world.respect.shared.generated.resources.copy +import world.respect.shared.generated.resources.copy_of_playlist +import world.respect.shared.generated.resources.copy_playlist +import world.respect.shared.generated.resources.delete import world.respect.shared.generated.resources.duration -import world.respect.app.app.RespectAsyncImage -import world.respect.app.components.RespectListSortHeader -import world.respect.app.components.defaultItemPadding +import world.respect.shared.generated.resources.make_a_copy +import world.respect.shared.generated.resources.name +import world.respect.shared.generated.resources.permanently_delete +import world.respect.shared.generated.resources.permanently_delete_description +import world.respect.shared.generated.resources.select_count_items +import world.respect.shared.generated.resources.select_playlist +import world.respect.shared.generated.resources.share +import world.respect.shared.util.SortOrderOption import world.respect.shared.viewmodel.app.appstate.getTitle import world.respect.shared.viewmodel.learningunit.list.LearningUnitListUiState import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel -import world.respect.shared.util.SortOrderOption -import world.respect.lib.opds.model.OpdsPublication -import world.respect.lib.opds.model.ReadiumLink import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel.Companion.ICON +import world.respect.shared.viewmodel.learningunit.list.PlaylistDetailViewModel @Composable fun LearningUnitListScreen( - viewModel: LearningUnitListViewModel + viewModel: LearningUnitListViewModel, ) { val uiState by viewModel.uiState.collectAsState() LearningUnitListScreen( uiState = uiState, onSortOrderChanged = viewModel::onSortOrderChanged, - onClickPublication = { viewModel.onClickPublication(it) }, - onClickNavigation = { viewModel.onClickNavigation(it) } + onClickPublication = viewModel::onClickPublication, + onLongPressPublication = viewModel::onLongPressPublication, + onClickNavigation = viewModel::onClickNavigation, + onClickConfirmSelection = viewModel::onClickConfirmSelection, + onClickSelectPlaylist = viewModel::onClickSelectPlaylist, ) } @@ -60,224 +96,536 @@ fun LearningUnitListScreen( uiState: LearningUnitListUiState, @Suppress("unused") onSortOrderChanged: (SortOrderOption) -> Unit = { }, onClickPublication: (OpdsPublication) -> Unit, - onClickNavigation: (ReadiumLink) -> Unit - + onLongPressPublication: (OpdsPublication) -> Unit = {}, + onClickNavigation: (ReadiumLink) -> Unit, + onClickConfirmSelection: () -> Unit = {}, + onClickSelectPlaylist: () -> Unit = {}, ) { - Column( - modifier = Modifier.fillMaxSize() - ) { + Box(modifier = Modifier.fillMaxSize()) { LazyColumn( - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = if (uiState.showSelectPlaylistButton || (uiState.isMultiSelectMode && uiState.selectedCount > 0)) { + PaddingValues(bottom = 72.dp) + } else { + PaddingValues() + }, ) { itemsIndexed( items = uiState.navigation, - key = { index, navigation -> - navigation.href - } - ) { index, navigation -> + key = { _, navigation -> navigation.href } + ) { _, navigation -> NavigationListItem( - navigation, - onClickNavigation = { - onClickNavigation(navigation) - } + navigation = navigation, + isMultiSelectMode = uiState.isMultiSelectMode, + isSelected = uiState.isNavigationSelected(navigation), + onClickNavigation = { onClickNavigation(navigation) }, ) } itemsIndexed( items = uiState.publications, - key = { index, publications -> - publications.metadata.identifier.toString() - } - ) { index, publication -> + key = { _, publication -> publication.metadata.identifier.toString() } + ) { _, publication -> PublicationListItem( - publication, - onClickPublication = { - onClickPublication(publication) - } + publication = publication, + isMultiSelectMode = uiState.isMultiSelectMode, + isSelected = uiState.isPublicationSelected(publication), + onClickPublication = { onClickPublication(publication) }, + onLongPressPublication = { onLongPressPublication(publication) }, ) } uiState.group.forEach { group -> item { ListItem( - headlineContent = { - Text( - text = group.metadata.title, - ) - } + headlineContent = { Text(text = group.metadata.title) } ) } itemsIndexed( items = group.navigation ?: emptyList(), - key = { index, navigation -> - navigation.href - } - ) { index, navigation -> + key = { _, navigation -> navigation.href } + ) { _, navigation -> NavigationListItem( - navigation, - onClickNavigation = { - onClickNavigation(navigation) - } + navigation = navigation, + isMultiSelectMode = uiState.isMultiSelectMode, + isSelected = uiState.isNavigationSelected(navigation), + onClickNavigation = { onClickNavigation(navigation) }, ) } + itemsIndexed( items = group.publications ?: emptyList(), - key = { index, publication -> - publication.metadata.identifier.toString() - } - ) { index, publication -> + key = { _, publication -> publication.metadata.identifier.toString() } + ) { _, publication -> PublicationListItem( - publication, - onClickPublication = { - onClickPublication(publication) - } + publication = publication, + isMultiSelectMode = uiState.isMultiSelectMode, + isSelected = uiState.isPublicationSelected(publication), + onClickPublication = { onClickPublication(publication) }, + onLongPressPublication = { onLongPressPublication(publication) }, ) } + } + } + if (uiState.showSelectPlaylistButton) { + Button( + onClick = onClickSelectPlaylist, + enabled = uiState.selectedNavigation != null, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag("select_playlist_button"), + ) { + Text(text = stringResource(Res.string.select_playlist)) + } + } else if (uiState.isMultiSelectMode && uiState.selectedCount > 0) { + Button( + onClick = onClickConfirmSelection, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag("confirm_selection_button"), + ) { + Text( + text = stringResource( + Res.string.select_count_items, + uiState.selectedCount, + ), + ) } } - } + } } @Composable -fun NavigationListItem( - navigation: ReadiumLink, - onClickNavigation: (ReadiumLink) -> Unit +fun PlaylistDetailScreenForViewModel( + viewModel: PlaylistDetailViewModel, ) { - ListItem( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Max) - .clickable { - onClickNavigation(navigation) - }, + val uiState by viewModel.uiState.collectAsState() + val copyOfPlaylistTemplate = stringResource(Res.string.copy_of_playlist) + LaunchedEffect(uiState.showCopyDialog) { + if (uiState.showCopyDialog) { + viewModel.onCopyDialogNameChanged( + copyOfPlaylistTemplate.format(uiState.copyDialogName) + ) + } + } - leadingContent = { + PlaylistDetailScreen( + uiState = uiState, + onClickToggleSection = viewModel::onClickToggleSection, + onClickShare = viewModel::onClickShare, + onClickCopyPlaylist = viewModel::onClickCopyPlaylist, + onClickDelete = viewModel::onClickDelete, + onClickAssignSection = viewModel::onClickAssignSection, + onClickPublication = viewModel::onClickPublication, + onClickNavigation = viewModel::onClickNavigation, + onCopyDialogDismiss = viewModel::onCopyDialogDismiss, + onCopyDialogNameChanged = viewModel::onCopyDialogNameChanged, + onCopyDialogConfirm = viewModel::onCopyDialogConfirm, + onDeleteDialogDismiss = viewModel::onDeleteDialogDismiss, + onDeleteDialogConfirm = viewModel::onDeleteDialogConfirm, + ) +} + +@Composable +fun PlaylistDetailScreen( + uiState: LearningUnitListUiState, + onClickToggleSection: (String) -> Unit, + onClickShare: () -> Unit, + onClickCopyPlaylist: () -> Unit, + onClickDelete: () -> Unit, + onClickAssignSection: (Int) -> Unit = {}, + onClickPublication: (OpdsPublication) -> Unit, + onClickNavigation: (ReadiumLink) -> Unit, + onCopyDialogDismiss: () -> Unit = {}, + onCopyDialogNameChanged: (String) -> Unit = {}, + onCopyDialogConfirm: () -> Unit = {}, + onDeleteDialogDismiss: () -> Unit = {}, + onDeleteDialogConfirm: () -> Unit = {}, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + PlaylistDetailHeader( + uiState = uiState, + onClickShare = onClickShare, + onClickCopyPlaylist = onClickCopyPlaylist, + onClickDelete = onClickDelete, + onClickAssign = { onClickAssignSection(PlaylistDetailViewModel.ASSIGN_HEADER_SECTION_INDEX) } + ) + HorizontalDivider() + } - val iconUrl = navigation.alternate?.find { - it.rel?.contains(ICON) == true - }?.href + uiState.group.forEachIndexed { sectionIndex, group -> + item(key = "section_$sectionIndex") { + PlaylistSectionHeader( + title = group.metadata.title, + isCollapsed = uiState.isSectionCollapsed(sectionIndex.toString()), + showAssignButton = group.publications?.isNotEmpty() == true, + onClickToggle = { onClickToggleSection(sectionIndex.toString()) }, + onClickAssign = { onClickAssignSection(sectionIndex) }, + ) + } - Box( - modifier = Modifier - .fillMaxHeight() - .width(48.dp), - contentAlignment = Alignment.Center - ) { - iconUrl.also { icon -> - RespectAsyncImage( - uri = icon, - contentDescription = "", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) + if (!uiState.isSectionCollapsed(sectionIndex.toString())) { + itemsIndexed( + items = group.navigation ?: emptyList(), + key = { itemIndex, _ -> "nav_${sectionIndex}_${itemIndex}" } + ) { _, navigation -> + NavigationListItem( + navigation = navigation, + isMultiSelectMode = uiState.isMultiSelectMode, + isSelected = uiState.isNavigationSelected(navigation), + onClickNavigation = { onClickNavigation(navigation) }, + ) + } + itemsIndexed( + items = group.publications ?: emptyList(), + key = { itemIndex, _ -> "pub_${sectionIndex}_${itemIndex}" } + ) { _, publication -> + PublicationListItem( + publication = publication, + isMultiSelectMode = false, + isSelected = false, + onClickPublication = { onClickPublication(publication) }, + onLongPressPublication = {}, ) } } - }, + } + } - headlineContent = { - Text( - text = navigation.title.toString() + if (uiState.showCopyDialog) { + CopyPlaylistDialog( + name = uiState.copyDialogName, + onNameChanged = onCopyDialogNameChanged, + onDismiss = onCopyDialogDismiss, + onConfirm = onCopyDialogConfirm, + ) + } + + if (uiState.showDeleteDialog) { + DeletePlaylistDialog( + onDismiss = onDeleteDialogDismiss, + onConfirm = onDeleteDialogConfirm, + ) + } +} + +@Composable +private fun CopyPlaylistDialog( + name: String, + onNameChanged: (String) -> Unit, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(Res.string.make_a_copy)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = onNameChanged, + label = { Text(stringResource(Res.string.name)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag("copy_dialog_name_field"), ) }, + confirmButton = { + TextButton( + onClick = onConfirm, + modifier = Modifier.testTag("copy_dialog_confirm"), + ) { + Text(text = stringResource(Res.string.copy)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag("copy_dialog_dismiss"), + ) { + Text(text = stringResource(Res.string.cancel)) + } + }, + ) +} - supportingContent = { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp) +@Composable +private fun DeletePlaylistDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(imageVector = Icons.Filled.Delete, contentDescription = null) }, + title = { Text(text = stringResource(Res.string.permanently_delete)) }, + text = { Text(text = stringResource(Res.string.permanently_delete_description)) }, + confirmButton = { + TextButton( + onClick = onConfirm, + modifier = Modifier.testTag("delete_dialog_confirm"), ) { Text( - text = stringResource(Res.string.classes), + text = stringResource(Res.string.delete), ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.testTag("delete_dialog_dismiss"), + ) { + Text(text = stringResource(Res.string.cancel)) + } + }, + ) +} + +@Composable +private fun PlaylistDetailHeader( + uiState: LearningUnitListUiState, + onClickShare: () -> Unit, + onClickCopyPlaylist: () -> Unit, + onClickDelete: () -> Unit, + onClickAssign: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + Box( + modifier = Modifier + .fillMaxHeight() + .width(48.dp), + contentAlignment = Alignment.Center, ) { + RespectAsyncImage( + uri = uiState.group + .flatMap { it.publications ?: emptyList() } + .firstOrNull() + ?.images?.firstOrNull()?.href, + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier.size(36.dp), + ) + } + }, + headlineContent = { + Text(text = uiState.feed?.metadata?.description ?: "") + }, + supportingContent = { + uiState.feed?.metadata?.subtitle?.let { subtitle -> + Text(text = subtitle) + } + }, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + PlaylistActionButton( + icon = Icons.Filled.Share, + label = stringResource(Res.string.share), + onClick = onClickShare, + testTag = "share_btn", + ) + PlaylistActionButton( + icon = Icons.Filled.ContentCopy, + label = stringResource(Res.string.copy_playlist), + onClick = onClickCopyPlaylist, + testTag = "copy_btn", + ) + if (uiState.isTeacherOrAdmin && uiState.hasLearningUnitSections) { + PlaylistActionButton( + icon = Icons.Filled.Task, + label = stringResource(Res.string.assign), + onClick = onClickAssign, + testTag = "header_assign_btn", + ) + } + if (uiState.isTeacherOrAdmin) { + PlaylistActionButton( + icon = Icons.Filled.Delete, + label = stringResource(Res.string.delete), + onClick = onClickDelete, + testTag = "delete_btn", + ) + } + } + } +} - navigation.language - ?.let { language -> - Text( - text = language.joinToString(", ") - ) - } +@Composable +private fun PlaylistActionButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + testTag: String, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + IconButton( + onClick = onClick, + modifier = Modifier.testTag(testTag), + ) { + Icon(imageVector = icon, contentDescription = label) + } + Text(text = label, style = MaterialTheme.typography.labelSmall) + } +} - navigation.duration - ?.let { duration -> - Text( - text = "${stringResource(Res.string.duration)} - $duration" - ) - } +@Composable +private fun PlaylistSectionHeader( + title: String, + isCollapsed: Boolean, + showAssignButton: Boolean, + onClickToggle: () -> Unit, + onClickAssign: () -> Unit, +) { + ListItem( + headlineContent = { + Text(text = title, style = MaterialTheme.typography.titleMedium) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showAssignButton) { + IconButton( + onClick = onClickAssign, + modifier = Modifier.testTag("assign_btn"), + ) { + Icon( + imageVector = Icons.Filled.Task, + contentDescription = stringResource(Res.string.assign), + ) + } + } + IconButton( + onClick = onClickToggle, + modifier = Modifier.testTag("expand_collapse_icon"), + ) { + Icon( + imageVector = if (isCollapsed) { + Icons.Filled.ExpandMore + } else { + Icons.Filled.ExpandLess + }, + contentDescription = null, + ) } } }, ) } +@OptIn(ExperimentalFoundationApi::class) @Composable -fun PublicationListItem( - publication: OpdsPublication, onClickPublication: (OpdsPublication) -> Unit +private fun FeedListItem( + title: String, + iconUrl: String?, + language: List?, + duration: Double?, + isMultiSelectMode: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + onLongPress: () -> Unit, ) { ListItem( modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Max) - .clickable { - onClickPublication(publication) - }, - + .combinedClickable( + onClick = onClick, + onLongClick = onLongPress, + ), leadingContent = { - val iconUrl = publication.images?.firstOrNull()?.href - Box( modifier = Modifier .fillMaxHeight() .width(48.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { - iconUrl.also { icon -> - RespectAsyncImage( - uri = icon, - contentDescription = "", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) - ) - } + RespectAsyncImage( + uri = iconUrl, + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier.size(36.dp), + ) } }, - - headlineContent = { - Text( - text = publication.metadata.title.getTitle() - ) - }, - + headlineContent = { Text(text = title) }, supportingContent = { - Column( - verticalArrangement = - Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(Res.string.classes), - ) - Row( - horizontalArrangement = - Arrangement.spacedBy(8.dp) - ) { - publication.metadata.language - ?.let { language -> - Text( - text = language.joinToString(", ") - ) - } - - publication.metadata.duration - ?.let { duration -> - Text(text = "${stringResource(Res.string.duration)} - $duration") - } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(text = stringResource(Res.string.classes)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + language?.let { Text(text = it.joinToString(", ")) } + duration?.let { + Text(text = "${stringResource(Res.string.duration)} - $it") + } } } }, + trailingContent = if (isMultiSelectMode) { + { + Checkbox( + checked = isSelected, + onCheckedChange = null, + modifier = Modifier.testTag("check_box"), + ) + } + } else { + null + }, + ) +} + +@Composable +fun NavigationListItem( + navigation: ReadiumLink, + isMultiSelectMode: Boolean = false, + isSelected: Boolean = false, + onClickNavigation: (ReadiumLink) -> Unit, +) { + FeedListItem( + title = navigation.title + ?.takeIf { it != "null" && it.isNotBlank() } + ?: navigation.href, + iconUrl = navigation.alternate?.find { + it.rel?.contains(ICON) == true + }?.href, + language = navigation.language, + duration = navigation.duration, + isMultiSelectMode = isMultiSelectMode, + isSelected = isSelected, + onClick = { onClickNavigation(navigation) }, + onLongPress = {}, + ) +} + +@Composable +fun PublicationListItem( + publication: OpdsPublication, + isMultiSelectMode: Boolean, + isSelected: Boolean, + onClickPublication: (OpdsPublication) -> Unit, + onLongPressPublication: (OpdsPublication) -> Unit, +) { + FeedListItem( + title = publication.metadata.title.getTitle(), + iconUrl = publication.images?.firstOrNull()?.href, + language = publication.metadata.language, + duration = publication.metadata.duration, + isMultiSelectMode = isMultiSelectMode, + isSelected = isSelected, + onClick = { onClickPublication(publication) }, + onLongPress = { onLongPressPublication(publication) }, ) } \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/edit/PlaylistEditScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/edit/PlaylistEditScreen.kt new file mode 100644 index 000000000..b96272bef --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/edit/PlaylistEditScreen.kt @@ -0,0 +1,582 @@ +package world.respect.app.view.playlists.mapping.edit + +import androidx.compose.foundation.clickable +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.filled.Book +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.platform.testTag +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import sh.calvin.reorderable.ReorderableColumn +import world.respect.app.components.defaultItemPadding +import world.respect.app.components.uiTextStringResource +import world.respect.lib.opds.model.OpdsGroup +import world.respect.lib.opds.model.OpdsPublication +import world.respect.lib.opds.model.ReadiumLink +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.add_item +import world.respect.shared.generated.resources.add_new_playlist +import world.respect.shared.generated.resources.add_section +import world.respect.shared.generated.resources.cancel +import world.respect.shared.generated.resources.choose_section_type +import world.respect.shared.generated.resources.delete +import world.respect.shared.generated.resources.description +import world.respect.shared.generated.resources.learning_item_section +import world.respect.shared.generated.resources.learning_item_section_description +import world.respect.shared.generated.resources.move +import world.respect.shared.generated.resources.move_to_section +import world.respect.shared.generated.resources.n_items +import world.respect.shared.generated.resources.playlist_section +import world.respect.shared.generated.resources.playlist_section_description +import world.respect.shared.generated.resources.required +import world.respect.shared.generated.resources.section_title +import world.respect.shared.generated.resources.sections +import world.respect.shared.generated.resources.title +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.app.appstate.getTitle +import world.respect.shared.viewmodel.playlists.mapping.edit.MovingItemState +import world.respect.shared.viewmodel.playlists.mapping.edit.PlaylistEditUiState +import world.respect.shared.viewmodel.playlists.mapping.edit.PlaylistEditViewModel +import world.respect.shared.viewmodel.playlists.mapping.edit.PlaylistSectionType + +@Composable +fun PlaylistEditScreenForViewModel( + viewModel: PlaylistEditViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + PlaylistEditScreen( + uiState = uiState, + onTitleChanged = viewModel::onTitleChanged, + onDescriptionChanged = viewModel::onDescriptionChanged, + onSectionTitleChanged = viewModel::onSectionTitleChanged, + onClickAddSection = viewModel::onClickAddSection, + onDismissSectionTypeBottomSheet = viewModel::onDismissSectionTypeDialog, + onClickSectionType = viewModel::onClickSectionType, + onClickDeleteSection = viewModel::onClickDeleteSection, + onSectionsReordered = viewModel::onSectionsReordered, + onClickAddItem = viewModel::onClickAddItem, + onClickAddPlaylist = viewModel::onClickAddPlaylist, + onClickDeleteItem = viewModel::onClickDeleteItem, + onClickMoveItem = viewModel::onClickMoveItem, + onClickMoveItemToSection = viewModel::onClickMoveItemToSection, + onDismissMoveDialog = viewModel::onDismissMoveDialog, + onItemsReordered = viewModel::onItemsReordered, + ) +} + +@Composable +fun PlaylistEditScreen( + uiState: PlaylistEditUiState = PlaylistEditUiState(), + onTitleChanged: (String) -> Unit = {}, + onDescriptionChanged: (String) -> Unit = {}, + onSectionTitleChanged: (Int, String) -> Unit = { _, _ -> }, + onClickAddSection: () -> Unit = {}, + onDismissSectionTypeBottomSheet: () -> Unit = {}, + onClickSectionType: (PlaylistSectionType) -> Unit = {}, + onClickDeleteSection: (Int) -> Unit = {}, + onSectionsReordered: (List) -> Unit = {}, + onClickAddItem: (Int) -> Unit = {}, + onClickAddPlaylist: (Int) -> Unit = {}, + onClickDeleteItem: (Int, Int) -> Unit = { _, _ -> }, + onClickMoveItem: (Int, Int) -> Unit = { _, _ -> }, + onClickMoveItemToSection: (Int) -> Unit = {}, + onDismissMoveDialog: () -> Unit = {}, + onItemsReordered: (Int, List) -> Unit = { _, _ -> }, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + OutlinedTextField( + value = uiState.title, + onValueChange = onTitleChanged, + label = { Text(stringResource(Res.string.title) + "*") }, + isError = uiState.titleError != null, + supportingText = { + Text(uiTextStringResource(uiState.titleError ?: Res.string.required.asUiText())) + + }, + modifier = Modifier + .fillMaxWidth() + .defaultItemPadding() + .testTag("playlist_title_field"), + singleLine = true, + ) + + OutlinedTextField( + value = uiState.description, + onValueChange = onDescriptionChanged, + label = { Text(stringResource(Res.string.description)) }, + modifier = Modifier + .fillMaxWidth() + .defaultItemPadding() + .testTag("playlist_description_field"), + minLines = 2, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(Res.string.sections), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.defaultItemPadding(), + ) + OutlinedButton( + onClick = onClickAddSection, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag("add_section_button"), + ) { + Text(text = stringResource(Res.string.add_section)) + } + + ReorderableColumn( + list = uiState.sections, + onSettle = { fromIndex, toIndex -> + val sections = uiState.sections.toMutableList() + val item = sections.removeAt(fromIndex) + sections.add(toIndex, item) + onSectionsReordered(sections) + }, + modifier = Modifier.fillMaxWidth(), + ) { sectionIndex, section, _ -> + key(sectionIndex) { + ReorderableItem { + val sectionDragHandleModifier = Modifier + .draggableHandle() + .testTag("section_drag_handle_$sectionIndex") + + PlaylistSectionEditItem( + sectionIndex = sectionIndex, + section = section, + allSections = uiState.sections, + dragHandleModifier = sectionDragHandleModifier, + onSectionTitleChanged = { t -> onSectionTitleChanged(sectionIndex, t) }, + onClickDeleteSection = { onClickDeleteSection(sectionIndex) }, + onClickAddItem = { onClickAddItem(sectionIndex) }, + onClickAddPlaylist = { onClickAddPlaylist(sectionIndex) }, + onClickDeleteItem = { itemIndex -> + onClickDeleteItem( + sectionIndex, + itemIndex + ) + }, + onClickMoveItem = { itemIndex -> onClickMoveItem(sectionIndex, itemIndex) }, + onItemsReordered = { items -> onItemsReordered(sectionIndex, items) }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + if (uiState.isSectionTypeDialogVisible) { + SectionTypeBottomSheet( + onDismiss = onDismissSectionTypeBottomSheet, + onClickSectionType = onClickSectionType, + ) + } + + uiState.movingItem?.let { movingItem -> + MoveToSectionDialog( + compatibleSections = movingItem.compatibleSections, + allSections = uiState.sections, + onClickSection = onClickMoveItemToSection, + onDismiss = onDismissMoveDialog, + ) + } +} + +@Composable +private fun PlaylistSectionEditItem( + sectionIndex: Int, + section: OpdsGroup, + allSections: List, + dragHandleModifier: Modifier, + onSectionTitleChanged: (String) -> Unit, + onClickDeleteSection: () -> Unit, + onClickAddItem: () -> Unit, + onClickAddPlaylist: () -> Unit, + onClickDeleteItem: (Int) -> Unit, + onClickMoveItem: (Int) -> Unit, + onItemsReordered: (List) -> Unit, +) { + val isNavigationSection = section.navigation != null + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + IconButton( + onClick = {}, + modifier = dragHandleModifier, + ) { + Icon(imageVector = Icons.Filled.DragHandle, contentDescription = null) + } + + OutlinedTextField( + value = section.metadata.title, + onValueChange = onSectionTitleChanged, + label = { Text(stringResource(Res.string.section_title)) }, + modifier = Modifier + .weight(1f) + .testTag("section_title_field_$sectionIndex"), + singleLine = true, + ) + + IconButton( + onClick = onClickDeleteSection, + modifier = Modifier.testTag("delete_section_$sectionIndex"), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(Res.string.delete), + ) + } + } + + if (isNavigationSection) { + val navItems = section.navigation ?: emptyList() + + ReorderableColumn( + list = navItems, + onSettle = { from, to -> + val items = navItems.toMutableList() + val item = items.removeAt(from) + items.add(to, item) + onItemsReordered(items) + }, + modifier = Modifier.fillMaxWidth(), + ) { itemIndex, navLink, _ -> + key(itemIndex) { + ReorderableItem { + PlaylistNavItemRow( + itemIndex = itemIndex, + navLink = navLink, + sectionIndex = sectionIndex, + hasMovableSections = allSections.any { s -> + s != section && s.navigation != null + }, + dragHandleModifier = Modifier + .draggableHandle() + .testTag("nav_drag_handle_${sectionIndex}_$itemIndex"), + onClickDelete = { onClickDeleteItem(itemIndex) }, + onClickMove = { onClickMoveItem(itemIndex) }, + ) + } + } + } + + OutlinedButton( + onClick = onClickAddPlaylist, + modifier = Modifier + .padding(start = 16.dp) + .testTag("add_playlist_button_$sectionIndex"), + ) { + Text(text = stringResource(Res.string.add_new_playlist)) + } + } else { + val pubItems = section.publications ?: emptyList() + + ReorderableColumn( + list = pubItems, + onSettle = { from, to -> + val items = pubItems.toMutableList() + val item = items.removeAt(from) + items.add(to, item) + onItemsReordered(items) + }, + modifier = Modifier.fillMaxWidth(), + ) { itemIndex, publication, _ -> + key(itemIndex) { + ReorderableItem { + PlaylistPublicationItemRow( + itemIndex = itemIndex, + publication = publication, + sectionIndex = sectionIndex, + hasMovableSections = allSections.any { s -> + s != section && s.publications != null + }, + dragHandleModifier = Modifier + .draggableHandle() + .testTag("pub_drag_handle_${sectionIndex}_$itemIndex"), + onClickDelete = { onClickDeleteItem(itemIndex) }, + onClickMove = { onClickMoveItem(itemIndex) }, + ) + } + } + } + + OutlinedButton( + onClick = onClickAddItem, + modifier = Modifier + .padding(start = 16.dp) + .testTag("add_item_button_$sectionIndex"), + ) { + Text(text = stringResource(Res.string.add_item)) + } + } + } +} + +@Composable +private fun PlaylistNavItemRow( + itemIndex: Int, + navLink: ReadiumLink, + sectionIndex: Int, + hasMovableSections: Boolean, + dragHandleModifier: Modifier, + onClickDelete: () -> Unit, + onClickMove: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .testTag("nav_item_${sectionIndex}_$itemIndex"), + ) { + IconButton(onClick = {}, modifier = dragHandleModifier) { + Icon(imageVector = Icons.Filled.DragHandle, contentDescription = null) + } + ListItem( + headlineContent = { + Text( + text = navLink.title?.takeIf { it.isNotBlank() } ?: navLink.href, + ) + }, + modifier = Modifier.weight(1f), + ) + Box { + ItemMenuButton( + sectionIndex = sectionIndex, + itemIndex = itemIndex, + hasMovableSections = hasMovableSections, + onClickDelete = onClickDelete, + onClickMove = onClickMove, + ) + } + } +} + +@Composable +private fun PlaylistPublicationItemRow( + itemIndex: Int, + publication: OpdsPublication, + sectionIndex: Int, + hasMovableSections: Boolean, + dragHandleModifier: Modifier, + onClickDelete: () -> Unit, + onClickMove: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .testTag("pub_item_${sectionIndex}_$itemIndex"), + ) { + IconButton(onClick = {}, modifier = dragHandleModifier) { + Icon(imageVector = Icons.Filled.DragHandle, contentDescription = null) + } + ListItem( + headlineContent = { Text(text = publication.metadata.title.getTitle()) }, + supportingContent = { + publication.metadata.description?.let { Text(text = it) } + }, + modifier = Modifier.weight(1f), + ) + Box { + ItemMenuButton( + sectionIndex = sectionIndex, + itemIndex = itemIndex, + hasMovableSections = hasMovableSections, + onClickDelete = onClickDelete, + onClickMove = onClickMove, + ) + } + } +} + +@Composable +private fun ItemMenuButton( + sectionIndex: Int, + itemIndex: Int, + hasMovableSections: Boolean, + onClickDelete: () -> Unit, + onClickMove: () -> Unit, +) { + var menuExpanded by remember { mutableStateOf(false) } + + IconButton( + onClick = { menuExpanded = true }, + modifier = Modifier.testTag("item_menu_${sectionIndex}_$itemIndex"), + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(Res.string.move) + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + if (hasMovableSections) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.move)) }, + onClick = { menuExpanded = false; onClickMove() }, + modifier = Modifier.testTag("item_move_${sectionIndex}_$itemIndex"), + ) + } + DropdownMenuItem( + text = { Text(stringResource(Res.string.delete)) }, + onClick = { menuExpanded = false; onClickDelete() }, + modifier = Modifier.testTag("item_delete_${sectionIndex}_$itemIndex"), + ) + } +} + +@Composable +private fun MoveToSectionDialog( + compatibleSections: List, + allSections: List, + onClickSection: (Int) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(Res.string.move_to_section)) }, + text = { + compatibleSections.forEach { section -> + val actualSection = allSections[section.sectionIndex] + val sectionTitle = actualSection.metadata.title + .takeIf { it.isNotBlank() } + ?: stringResource(Res.string.section_title) + val itemCount = (actualSection.navigation?.size ?: 0) + + (actualSection.publications?.size ?: 0) + ListItem( + headlineContent = { Text(text = sectionTitle) }, + supportingContent = { + Text(text = stringResource(Res.string.n_items, itemCount)) + }, + modifier = Modifier + .fillMaxWidth() + .clickable { onClickSection(section.sectionIndex) }, + ) + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(Res.string.cancel)) + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SectionTypeBottomSheet( + onDismiss: () -> Unit, + onClickSectionType: (PlaylistSectionType) -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Text( + text = stringResource(Res.string.choose_section_type), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.defaultItemPadding(), + ) + HorizontalDivider() + SectionTypeItem( + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistPlay, + contentDescription = stringResource(Res.string.playlist_section), + ) + }, + title = stringResource(Res.string.playlist_section), + description = stringResource(Res.string.playlist_section_description), + onClick = { onClickSectionType(PlaylistSectionType.NAVIGATION) }, + testTag = "section_type_playlist", + ) + SectionTypeItem( + icon = { + Icon( + imageVector = Icons.Filled.Book, + contentDescription = stringResource(Res.string.learning_item_section), + ) + }, + title = stringResource(Res.string.learning_item_section), + description = stringResource(Res.string.learning_item_section_description), + onClick = { onClickSectionType(PlaylistSectionType.PUBLICATION) }, + testTag = "section_type_learning_item", + ) + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun SectionTypeItem( + icon: @Composable () -> Unit, + title: String, + description: String, + onClick: () -> Unit, + testTag: String, +) { + ListItem( + leadingContent = icon, + headlineContent = { Text(text = title) }, + supportingContent = { Text(text = description) }, + modifier = Modifier + .fillMaxWidth() + .testTag(testTag) + .clickable { onClick() }, + ) +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/list/PlaylistListScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/list/PlaylistListScreen.kt new file mode 100644 index 000000000..4ac14d8ed --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/list/PlaylistListScreen.kt @@ -0,0 +1,202 @@ +package world.respect.app.view.playlists.mapping.list + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Book +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import world.respect.datalayer.school.domain.MakePlaylistOpdsFeedUseCase +import world.respect.lib.opds.model.OpdsFeed +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.* +import world.respect.shared.viewmodel.playlists.mapping.list.* + +@Composable +fun PlaylistListScreenForViewModel( + viewModel: PlaylistListViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + + PlaylistListScreen( + uiState = uiState, + onClickFilter = viewModel::onClickFilter, + onClickPlaylist = viewModel::onClickPlaylist, + onClickDismissFabMenu = viewModel::onClickDismissFabMenu, + onClickAddNew = viewModel::onClickAddNew, + onClickAddFromLink = viewModel::onClickAddFromLink, + ) +} + +@Composable +fun PlaylistListScreen( + uiState: PlaylistListUiState = PlaylistListUiState(), + onClickFilter: (PlaylistFilter) -> Unit = {}, + onClickPlaylist: (OpdsFeed) -> Unit = {}, + onClickDismissFabMenu: () -> Unit = {}, + onClickAddNew: () -> Unit = {}, + onClickAddFromLink: () -> Unit = {}, +) { + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + FilterChip( + selected = uiState.activeFilter == PlaylistFilter.ALL, + onClick = { onClickFilter(PlaylistFilter.ALL) }, + label = { Text(stringResource(Res.string.all)) }, + ) + } + item { + FilterChip( + selected = uiState.activeFilter == PlaylistFilter.MY_PLAYLISTS, + onClick = { onClickFilter(PlaylistFilter.MY_PLAYLISTS) }, + label = { Text(stringResource(Res.string.my_playlists)) }, + ) + } + } + + if (uiState.showPlaylists.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 64.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(Res.drawable.empty), + contentDescription = null, + modifier = Modifier.size(200.dp), + contentScale = ContentScale.Fit, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(stringResource(Res.string.no_playlist_yet)) + Text(stringResource(Res.string.no_playlist_yet_description)) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed( + items = uiState.showPlaylists, + key = { index, feed -> + feed.metadata.identifier?.toString() + ?: "${feed.metadata.title}_$index" + } + ) { _, feed -> + PlaylistListItem( + feed = feed, + onClickFeed = { onClickPlaylist(feed) }, + ) + } + } + } + } + + if (uiState.isFabMenuExpanded) { + Surface( + modifier = Modifier + .fillMaxSize() + .clickable { onClickDismissFabMenu() }, + ) {} + } + + if (uiState.isFabMenuExpanded) { + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 88.dp, end = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.End, + ) { + ExtendedFloatingActionButton( + modifier = Modifier + .testTag("add_new"), + onClick = onClickAddNew, + icon = + { Icon(Icons.Filled.Add, null) }, + text + = { Text(stringResource(Res.string.add_new)) }, + ) + ExtendedFloatingActionButton( + modifier = Modifier + .testTag("add_from_a_link"), + onClick = onClickAddFromLink, + icon = + { Icon(Icons.Filled.Link, null) }, + text = + { Text(stringResource(Res.string.add_from_a_link)) }, + ) + } + } + } +} + +@Composable +private fun PlaylistListItem( + feed: OpdsFeed, + onClickFeed: () -> Unit, +) { + val sectionCount = feed.groups?.size ?: 0 + val itemCount = feed.groups?.sumOf { group -> + (group.publications?.size ?: 0) + (group.navigation?.size ?: 0) + } ?: 0 + + val ownerUsername = feed.links + .firstOrNull { link -> + link.rel?.contains(MakePlaylistOpdsFeedUseCase.REL_OWNER) == true + } + ?.href + ?.trimEnd('/') + ?.substringAfterLast('/') + ?.takeIf { it.isNotBlank() } + + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClickFeed() }, + leadingContent = { + Icon( + imageVector = Icons.Filled.Book, + contentDescription = null, + ) + }, + headlineContent = { + Text(feed.metadata.title) + }, + supportingContent = { + Column { + Text( + stringResource( + Res.string.sections_and_items, + sectionCount, + itemCount, + ) + ) + if (ownerUsername != null) { + Text( + text = stringResource(Res.string.created_by, ownerUsername), + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + ) +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/share/PlaylistShareScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/share/PlaylistShareScreen.kt new file mode 100644 index 000000000..be4f6ee4b --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/share/PlaylistShareScreen.kt @@ -0,0 +1,262 @@ +package world.respect.app.view.playlists.mapping.share + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +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.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Sms +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +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.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.platform.testTag +import androidx.compose.ui.unit.dp +import io.github.alexzhirkevich.qrose.rememberQrCodePainter +import org.jetbrains.compose.resources.stringResource +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.admins_in_my_school +import world.respect.shared.generated.resources.anyone_in_my_school +import world.respect.shared.generated.resources.anyone_with_the_link +import world.respect.shared.generated.resources.copy_link +import world.respect.shared.generated.resources.send_link_via_email +import world.respect.shared.generated.resources.send_link_via_sms +import world.respect.shared.generated.resources.share_link +import world.respect.shared.generated.resources.teachers_and_admins_in_my_school +import world.respect.shared.generated.resources.who_can_edit +import world.respect.shared.generated.resources.who_can_view +import world.respect.shared.viewmodel.playlists.mapping.share.PlaylistShareUiState +import world.respect.shared.viewmodel.playlists.mapping.share.PlaylistShareViewModel + +@Composable +fun PlaylistShareScreenForViewModel( + viewModel: PlaylistShareViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + + PlaylistShareScreen( + uiState = uiState, + onViewPermissionChanged = viewModel::onViewPermissionChanged, + onEditPermissionChanged = viewModel::onEditPermissionChanged, + onClickCopyLink = viewModel::onClickCopyLink, + onClickShareLink = viewModel::onClickShareLink, + onClickSendViaSms = viewModel::onClickSendViaSms, + onClickSendViaEmail = viewModel::onClickSendViaEmail, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaylistShareScreen( + uiState: PlaylistShareUiState = PlaylistShareUiState(), + onViewPermissionChanged: (Int) -> Unit = {}, + onEditPermissionChanged: (Int) -> Unit = {}, + onClickCopyLink: () -> Unit = {}, + onClickShareLink: () -> Unit = {}, + onClickSendViaSms: () -> Unit = {}, + onClickSendViaEmail: () -> Unit = {}, +) { + + val viewPermissionOptions = listOf( + stringResource(Res.string.anyone_with_the_link), + stringResource(Res.string.anyone_in_my_school), + stringResource(Res.string.teachers_and_admins_in_my_school), + stringResource(Res.string.admins_in_my_school), + ) + val editPermissionOptions = listOf( + stringResource(Res.string.teachers_and_admins_in_my_school), + stringResource(Res.string.admins_in_my_school), + ) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = uiState.playlistTitle, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (uiState.shareUrl.isNotBlank()) { + Image( + painter = rememberQrCodePainter(uiState.shareUrl), + contentDescription = uiState.playlistTitle, + modifier = Modifier + .size(200.dp) + .testTag("share_qr_code"), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = uiState.shareUrl, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider() + + // Who can view dropdown + PermissionDropdown( + label = stringResource(Res.string.who_can_view), + options = viewPermissionOptions, + selectedIndex = uiState.viewPermissionIndex, + onSelectionChanged = onViewPermissionChanged, + testTag = "view_permission_dropdown", + ) + + // Who can edit dropdown + PermissionDropdown( + label = stringResource(Res.string.who_can_edit), + options = editPermissionOptions, + selectedIndex = uiState.editPermissionIndex, + onSelectionChanged = onEditPermissionChanged, + testTag = "edit_permission_dropdown", + ) + + HorizontalDivider() + + ListItem( + leadingContent = { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = stringResource(Res.string.share_link), + ) + }, + headlineContent = { Text(text = stringResource(Res.string.share_link)) }, + modifier = Modifier + .fillMaxWidth() + .clickable { onClickShareLink() } + .testTag("share_link_button"), + ) + + ListItem( + leadingContent = { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = stringResource(Res.string.copy_link), + ) + }, + headlineContent = { Text(text = stringResource(Res.string.copy_link)) }, + modifier = Modifier + .fillMaxWidth() + .clickable { onClickCopyLink() } + .testTag("copy_link_button"), + ) + + ListItem( + leadingContent = { + Icon( + imageVector = Icons.Filled.Sms, + contentDescription = stringResource(Res.string.send_link_via_sms), + ) + }, + headlineContent = { Text(text = stringResource(Res.string.send_link_via_sms)) }, + modifier = Modifier + .fillMaxWidth() + .clickable { onClickSendViaSms() } + .testTag("send_sms_button"), + ) + + ListItem( + leadingContent = { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(Res.string.send_link_via_email), + ) + }, + headlineContent = { Text(text = stringResource(Res.string.send_link_via_email)) }, + modifier = Modifier + .fillMaxWidth() + .clickable { onClickSendViaEmail() } + .testTag("send_email_button"), + ) + } +} + +/** + * Reusable permission dropdown — used for both "Who can view" and "Who can edit". + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PermissionDropdown( + label: String, + options: List, + selectedIndex: Int, + onSelectionChanged: (Int) -> Unit, + testTag: String, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + OutlinedTextField( + value = options.getOrElse(selectedIndex) { options.first() }, + onValueChange = {}, + readOnly = true, + label = { Text(text = label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .testTag(testTag), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEachIndexed { index, option -> + DropdownMenuItem( + text = { Text(text = option) }, + onClick = { + expanded = false + onSelectionChanged(index) + }, + modifier = Modifier.testTag("${testTag}_option_$index"), + ) + } + } + } +} \ No newline at end of file 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..68b05b32c 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 @@ -76,9 +76,7 @@ private fun SettingsListItem( @Composable fun SettingsScreenForViewModel( - viewModel: SettingsViewModel + viewModel: SettingsViewModel, ) { - SettingsScreen( - onNavigateToMapping = viewModel::onNavigateToMapping - ) + SettingsScreen() } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/OpdsFeedDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/OpdsFeedDataSourceDb.kt index ad33bf5c4..703ae8aac 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/OpdsFeedDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/OpdsFeedDataSourceDb.kt @@ -24,10 +24,12 @@ import world.respect.datalayer.db.shared.adapters.asNetworkValidationInfo import world.respect.datalayer.ext.EPOCH import world.respect.datalayer.networkvalidation.NetworkValidationInfo import world.respect.datalayer.school.opds.ext.requireSelfUrl +import world.respect.datalayer.school.opds.OpdsFeedDataSource import world.respect.datalayer.school.opds.OpdsFeedDataSourceLocal import world.respect.datalayer.school.opds.ext.dataLoadMetaInfoForPlaylist import world.respect.lib.opds.model.OpdsFeed import world.respect.lib.primarykeygen.PrimaryKeyGenerator +import world.respect.libutil.ext.appendEndpointSegments import kotlin.time.Clock class OpdsFeedDataSourceDb( @@ -145,6 +147,34 @@ class OpdsFeedDataSourceDb( } } + override fun getPlaylistsAsFlow(schoolUrl: Url): Flow>> { + val playlistPrefix = + schoolUrl.appendEndpointSegments(OpdsFeedDataSource.PLAYLIST_ENDPOINT_NAME) + .toString() + "/" + return schoolDb.getOpdsFeedEntityDao().findByUrlPrefixAsFlow(playlistPrefix) + .map { feedEntities -> + schoolDb.useReaderConnection { + DataReadyState( + data = feedEntities.map { it.loadModel() } + ) + } + } + } + + override suspend fun deleteByUrl(url: Url) { + val feedUid = uidNumberMapper(url.toString()) + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + schoolDb.getOpdsFeedEntityDao().deleteByFeedUid(feedUid) + schoolDb.getOpdsFeedMetadataEntityDao().deleteByFeedUid(feedUid) + schoolDb.getLangMapEntityDao().deleteAllByFeedUid(feedUid) + schoolDb.getReadiumLinkEntityDao().deleteAllByFeedUid(feedUid) + schoolDb.getOpdsPublicationEntityDao().deleteAllByFeedUid(feedUid) + schoolDb.getOpdsGroupEntityDao().deleteByFeedUid(feedUid) + } + } + } + override suspend fun updateLocal( url: Url, dataLoadResult: DataReadyState, diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/daos/OpdsFeedEntityDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/daos/OpdsFeedEntityDao.kt index 6325b1a6a..f54edaa5c 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/daos/OpdsFeedEntityDao.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/opds/daos/OpdsFeedEntityDao.kt @@ -64,6 +64,14 @@ abstract class OpdsFeedEntityDao { urlHashes: List ): List - + @Query( + """ + SELECT OpdsFeedEntity.* + FROM OpdsFeedEntity + WHERE ofeUrl LIKE :urlPrefix || '%' ESCAPE '\' + ORDER BY ofeStored DESC + """ + ) + abstract fun findByUrlPrefixAsFlow(urlPrefix: String): Flow> } \ No newline at end of file diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/opds/OpdsFeedDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/opds/OpdsFeedDataSourceHttp.kt index aa0f7c933..e2e00b342 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/opds/OpdsFeedDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/opds/OpdsFeedDataSourceHttp.kt @@ -7,10 +7,12 @@ import io.ktor.http.ContentType import io.ktor.http.Url import io.ktor.http.contentType import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import world.respect.datalayer.AuthTokenProvider import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState +import world.respect.datalayer.NoDataLoadedState import world.respect.datalayer.ext.getAsDataLoadState import world.respect.datalayer.ext.getDataLoadResultAsFlow import world.respect.datalayer.ext.map @@ -68,4 +70,13 @@ class OpdsFeedDataSourceHttp( } } + override fun getPlaylistsAsFlow(schoolUrl: Url): Flow>> { + return flowOf(NoDataLoadedState.notFound()) + } + + override suspend fun deleteByUrl(url: Url) { + + + } + } \ No newline at end of file diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/opds/OpdsFeedDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/opds/OpdsFeedDataSourceRepository.kt index f650ae62c..c251be023 100644 --- a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/opds/OpdsFeedDataSourceRepository.kt +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/opds/OpdsFeedDataSourceRepository.kt @@ -69,4 +69,12 @@ class OpdsFeedDataSourceRepository( } ) } + + override fun getPlaylistsAsFlow(schoolUrl: Url): Flow>> { + return local.getPlaylistsAsFlow(schoolUrl) + } + + override suspend fun deleteByUrl(url: Url) { + local.deleteByUrl(url) + } } \ No newline at end of file diff --git a/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/PlaylistRepositoryIntegrationTest.kt b/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/PlaylistRepositoryIntegrationTest.kt index cf5364342..31d6a244e 100644 --- a/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/PlaylistRepositoryIntegrationTest.kt +++ b/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/PlaylistRepositoryIntegrationTest.kt @@ -52,7 +52,10 @@ class PlaylistRepositoryIntegrationTest { ) } - val playlistFeed = MakePlaylistOpdsFeedUseCase(schoolUrl = schoolUrl).invoke(baseFeed) + val playlistFeed = MakePlaylistOpdsFeedUseCase(schoolUrl = schoolUrl).invoke( + base = baseFeed, + username = "test-user-name", + ) serverSchoolDataSource.opdsFeedDataSource.store( listOf(playlistFeed) @@ -90,7 +93,10 @@ class PlaylistRepositoryIntegrationTest { server.start() - val playlistFeed = MakePlaylistOpdsFeedUseCase(schoolUrl = schoolUrl).invoke(baseFeed) + val playlistFeed = MakePlaylistOpdsFeedUseCase(schoolUrl = schoolUrl).invoke( + base = baseFeed, + username = "test-user-name", + ) clients.first().schoolDataSource.opdsFeedDataSource.store( listOf(playlistFeed) ) diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/MakePlaylistOpdsFeedUseCase.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/MakePlaylistOpdsFeedUseCase.kt index 5bd580519..a9555643b 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/MakePlaylistOpdsFeedUseCase.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/MakePlaylistOpdsFeedUseCase.kt @@ -4,6 +4,7 @@ import com.eygraber.uri.Uri import io.ktor.http.Url import world.respect.datalayer.school.opds.ext.withAbsoluteSelfUrl import world.respect.lib.opds.model.OpdsFeed +import world.respect.lib.opds.model.ReadiumLink import world.respect.libutil.ext.appendEndpointSegments import world.respect.libutil.util.time.systemTimeInMillis import kotlin.time.Instant @@ -15,6 +16,7 @@ import kotlin.uuid.Uuid * 1) Change the URL to the schoolurl/playlist/uuid * e.g. https://schoolname.example.org/playlist/00112233-4455-6677-8899-aabbccddeeff * 2) Set the last modified time. + * 3) Add an owner link to identify the creator of the playlist. */ class MakePlaylistOpdsFeedUseCase( private val schoolUrl: Url @@ -23,16 +25,27 @@ class MakePlaylistOpdsFeedUseCase( @OptIn(ExperimentalUuidApi::class) operator fun invoke( base: OpdsFeed, + username: String, uuid: Uuid = Uuid.random(), ): OpdsFeed { val feedUrl = schoolUrl.appendEndpointSegments("playlist/$uuid") + val ownerLink = ReadiumLink( + href = "${schoolUrl}user/$username", + rel = listOf(REL_OWNER), + ) + return base.copy( metadata = base.metadata.copy( identifier = Uri.parseOrNull(feedUrl.toString()), modified = Instant.fromEpochMilliseconds(systemTimeInMillis()), - ) + ), + links = base.links + ownerLink, ).withAbsoluteSelfUrl(feedUrl) } + companion object { + const val REL_OWNER = "https://respect.ustadmobile.com/ns/owner" + + } } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/opds/OpdsFeedDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/opds/OpdsFeedDataSource.kt index 0b2445265..d8fdb8e83 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/opds/OpdsFeedDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/opds/OpdsFeedDataSource.kt @@ -61,6 +61,12 @@ interface OpdsFeedDataSource : WritableDataSource{ params: DataLoadParams ): DataLoadState + fun getPlaylistsAsFlow( + schoolUrl: Url + ): Flow>> + + suspend fun deleteByUrl(url: Url) + companion object { diff --git a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml index 52b9cb015..24b6b2abb 100644 --- a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml +++ b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml @@ -429,8 +429,6 @@ First names Mappings Mapping - Sections - Section Edit mapping Textbooks Chapter @@ -502,6 +500,7 @@ Date Time Assignment tasks + Task Assignment name Lesson/assessment @@ -570,4 +569,53 @@ Select Host + + Home + Playlists + Playlist + My Playlists + %1$d sections, %2$d items + Copy playlist + Playlist section + Add new + Add from a link + No playlist yet + Add a playlist by clicking on the + playlist + Edit playlist + Add playlist + + Section + Sections + Choose Section Type: + Links to other playlists. + Learning item section + Links direct to items e.g. lessons, assessments, videos, etc + Section title + + Item + + Playlist + Move + Select %1$d item + Make a copy + Permanently delete? + Permanently delete this playlist + Created by: %1$s + Select all + Select none + copy the %1$s + Add %1$d task to assignment + Move to section… + %1$d items + Share Playlist + Who can view + Who can edit + Anyone with the link + Anyone in my school + Teachers and admins in my school + Admins in my school + Send link via SMS + Send link via email + Share link + Select playlist + + + diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/username/GetActiveUsernameUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/username/GetActiveUsernameUseCase.kt new file mode 100644 index 000000000..2e3eac196 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/username/GetActiveUsernameUseCase.kt @@ -0,0 +1,25 @@ +package world.respect.shared.domain.account.username + +import kotlinx.coroutines.flow.first +import world.respect.shared.domain.account.RespectAccountManager + +class GetActiveUsernameUseCase( + private val accountManager: RespectAccountManager, +) { + suspend operator fun invoke(): String { + accountManager.activeAccount + ?: throw IllegalStateException("No active account") + + val sessionAndPerson = accountManager.selectedAccountAndPersonFlow + .first { it != null } + ?: throw IllegalStateException("No active session and person") + + return sessionAndPerson.person.username + ?.takeIf { it.isNotBlank() } + ?: listOfNotNull( + sessionAndPerson.person.givenName.takeIf { it.isNotBlank() }, + sessionAndPerson.person.familyName.takeIf { it.isNotBlank() }, + ).joinToString(" ").takeIf { it.isNotBlank() } + ?: sessionAndPerson.person.guid + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/sharelink/CreatePlaylistShareLinkUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/sharelink/CreatePlaylistShareLinkUseCase.kt new file mode 100644 index 000000000..236c05da4 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/sharelink/CreatePlaylistShareLinkUseCase.kt @@ -0,0 +1,30 @@ +package world.respect.shared.domain.sharelink + +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import world.respect.libutil.ext.RESPECT_SCHOOL_LINK_SEGMENT +import world.respect.libutil.ext.appendEndpointPathSegments + +class CreatePlaylistShareLinkUseCase( + private val schoolUrl: Url, +) { + operator fun invoke(playlistUrl: String): Url { + val playlistUuid = playlistUrl + .trimEnd('/') + .substringAfterLast('/') + .takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException( + "Cannot extract playlist UUID from URL: $playlistUrl" + ) + + return URLBuilder(schoolUrl).apply { + appendEndpointPathSegments( + listOf(RESPECT_SCHOOL_LINK_SEGMENT, PATH, playlistUuid) + ) + }.build() + } + + companion object { + const val PATH = "playlist" + } +} \ No newline at end of file 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..497251d48 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 @@ -14,38 +14,28 @@ import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest import world.respect.datalayer.school.model.PersonRoleEnum import world.respect.datalayer.school.model.report.ReportFilter import world.respect.shared.ext.NextAfterScan -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMapping import world.respect.shared.viewmodel.learningunit.LearningUnitSelection import world.respect.shared.viewmodel.manageuser.signup.SignupScreenModeEnum import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryMode -/** - * Mostly TypeSafe navigation for the RESPECT app. All serialized properties must be primitives or - * strings (8/July/25: Compose multiplatform navigation does not like custom types when used with - * toRoute). - * - * If using a non-primitive type (e.g. Url) then use a private constructor property with a primitive - * type and then add a transient property - */ - @Serializable sealed interface RespectAppRoute @Serializable -data class Acknowledgement ( - val schoolUrlStr: String?=null, +data class Acknowledgement( + val schoolUrlStr: String? = null, val inviteCode: String? = null - ) : RespectAppRoute { +) : RespectAppRoute { @Transient - val schoolUrl = schoolUrlStr?.let { Url(it) } + val schoolUrl = schoolUrlStr?.let { Url(it) } companion object { - fun create(schoolUrl: Url? = null,inviteCode: String?=null) = - Acknowledgement(schoolUrl.toString(),inviteCode) + fun create(schoolUrl: Url? = null, inviteCode: String? = null) = + Acknowledgement(schoolUrl.toString(), inviteCode) } - } + @Serializable data class EnterInviteCode( val schoolUrlStr: String @@ -57,7 +47,6 @@ data class EnterInviteCode( companion object { fun create(schoolUrl: Url) = EnterInviteCode(schoolUrl.toString()) } - } @Serializable @@ -76,7 +65,6 @@ data class SchoolDirectoryList( mode: SchoolDirectoryMode = SchoolDirectoryMode.MANAGE ) = SchoolDirectoryList(mode.value) } - } @Serializable @@ -93,7 +81,6 @@ data class LoginScreen( companion object { fun create(schoolUrl: Url) = LoginScreen(schoolUrl.toString()) } - } @Serializable @@ -102,7 +89,7 @@ object Home : RespectAppRoute @Serializable data class RespectAppLauncher( val resultDestStr: String? = null, -) : RespectAppRoute, RouteWithResultDest{ +) : RespectAppRoute, RouteWithResultDest { @Transient override val resultDest: ResultDest? = ResultDest.fromStringOrNull(resultDestStr) @@ -128,7 +115,7 @@ data class AssignmentDetail( data class AssignmentEdit( val guid: String?, private val learningUnitStr: String? = null, -): RespectAppRoute { +) : RespectAppRoute { @Transient val learningUnitSelected: LearningUnitSelection? = learningUnitStr?.let { @@ -136,7 +123,6 @@ data class AssignmentEdit( } companion object { - fun create( uid: String?, learningUnitSelected: LearningUnitSelection? = null, @@ -146,9 +132,7 @@ data class AssignmentEdit( Json.encodeToString(LearningUnitSelection.serializer(), it) }, ) - } - } @Serializable @@ -169,12 +153,12 @@ data class EnrollmentList( @Transient val role = EnrollmentRoleEnum.fromValue(roleStr) - companion object { + companion object { fun create( filterByPersonUid: String, role: EnrollmentRoleEnum, filterByClassUid: String - ) : EnrollmentList { + ): EnrollmentList { return EnrollmentList( filterByPersonUid = filterByPersonUid, roleStr = role.value, @@ -182,7 +166,6 @@ data class EnrollmentList( ) } } - } @Serializable @@ -213,7 +196,6 @@ class AddPersonToClazz( } } - @Serializable data class ClazzEdit( val guid: String? @@ -274,9 +256,6 @@ object OtherOption : RespectAppRoute @Serializable object HowPasskeyWorks : RespectAppRoute -/** - * @property manifestUrl the URL to the RespectAppManifest for the given Respect compatible app - */ @Serializable class AppsDetail private constructor( private val manifestUrlStr: String, @@ -290,7 +269,6 @@ class AppsDetail private constructor( override val resultDest: ResultDest? = ResultDest.fromStringOrNull(resultDestStr) companion object { - fun create( manifestUrl: Url, resultDest: ResultDest? = null, @@ -300,20 +278,15 @@ class AppsDetail private constructor( resultDestStr = resultDest?.encodeToJsonStringOrNull() ) } - } } - -/** - * @property opdsFeedUrl the URL for an OPDS feed containing a list of learning units and/or links - * to other feeds - */ @Serializable class LearningUnitList( private val opdsFeedUrlStr: String, private val appManifestUrlStr: String, private val resultDestStr: String?, + val title: String? = null, ) : RespectAppRoute, RouteWithResultDest { @Transient @@ -326,22 +299,22 @@ class LearningUnitList( override val resultDest: ResultDest? = ResultDest.fromStringOrNull(resultDestStr) companion object { - fun create( opdsFeedUrl: Url, appManifestUrl: Url, resultDest: ResultDest? = null, + title: String? = null, ): LearningUnitList { return LearningUnitList( opdsFeedUrlStr = opdsFeedUrl.toString(), appManifestUrlStr = appManifestUrl.toString(), - resultDestStr = resultDest.encodeToJsonStringOrNull() + resultDestStr = resultDest.encodeToJsonStringOrNull() , + title = title, ) } - } - } + @Serializable class EnterPasswordSignup private constructor( private val schoolUrlStr: String, @@ -349,7 +322,7 @@ class EnterPasswordSignup private constructor( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString(inviteRedeemRequestStr) @Transient @@ -365,7 +338,6 @@ class EnterPasswordSignup private constructor( Json.encodeToString(inviteRequest) ) } - } } @@ -376,25 +348,22 @@ class OtherOptionsSignup private constructor( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString(inviteRedeemRequestStr) @Transient val schoolUrl = Url(schoolUrlStr) companion object { - fun create( schoolUrl: Url, inviteRequest: RespectRedeemInviteRequest, ): OtherOptionsSignup { val respectRedeemInviteRequest = Json.encodeToString(inviteRequest) - return OtherOptionsSignup( respectRedeemInviteRequest, schoolUrl.toString() ) } - } } @@ -433,7 +402,7 @@ class SignupScreen( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString(inviteRedeemRequestStr) @Transient @@ -469,7 +438,7 @@ class TermsAndCondition( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString(inviteRedeemRequestStr) @Transient @@ -501,7 +470,7 @@ class CreateAccount( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = Json.decodeFromString( + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString( inviteRedeemRequestStr ) @@ -521,17 +490,6 @@ class CreateAccount( } } -/** - * @property learningUnitManifestUrl the URL of the OPDS Publication (Readium Manifest) for the - * learning unit as per RESPECT integration guide: - * https://github.com/UstadMobile/RESPECT-Consumer-App-Integration-Guide?tab=readme-ov-file#5-support-listing-and-launching-learning-units - * @property refererUrl (optional), where available, the URL of the OPDS feed that referred the - * user to this learning unit. This allows the use of cached information from the feed - * to avoid waiting for the learningUnitManifestUrl to load to show the user the title, - * description, etc. - * @property expectedIdentifier (optional), where a refererUrl is provided, to use cached feed - * metadata as above, the identifier of the publication within the feed. - */ @Serializable class LearningUnitDetail( private val learningUnitManifestUrlStr: String, @@ -550,7 +508,6 @@ class LearningUnitDetail( val appManifestUrl = Url(appManifestUrlStr) companion object { - fun create( learningUnitManifestUrl: Url, appManifestUrl: Url, @@ -562,9 +519,7 @@ class LearningUnitDetail( refererUrlStr = refererUrl?.toString(), expectedIdentifier = expectedIdentifier, ) - } - } @Serializable @@ -582,19 +537,11 @@ class LearningUnitViewer( ) } } - } @Serializable object AccountList : RespectAppRoute - -/** - * @property addToClassUid if the PersonList screen has been navigated when the user clicks - * add student or add teacher on the ClassDetail screen, then the classUid. - * @property addToClassRoleStr if the PersonList screen has been navigated when the user clicks - * * add student or add teacher on the ClassDetail screen, then the role - */ @Serializable data class PersonList( private val filterByRoleStr: String? = null, @@ -612,15 +559,16 @@ data class PersonList( val filterByRole: PersonRoleEnum? = filterByRoleStr?.let { PersonRoleEnum.fromValue(it) } + @Transient val role: EnrollmentRoleEnum? = addToClassRoleStr?.let { EnrollmentRoleEnum.fromValue(it) } + @Transient override val resultDest: ResultDest? = ResultDest.fromStringOrNull(resultDestStr) companion object { - fun create( filterByRole: PersonRoleEnum? = null, isTopLevel: Boolean = false, @@ -642,7 +590,6 @@ data class PersonList( personGuidStr = personGuid, hideInvite = hideInvite, ) - } } @@ -656,13 +603,6 @@ data class PasskeyList( val guid: String, ) : RespectAppRoute - -/** - * @param guid the Uid of the Person account to manage as person Person.guid - * @param setPersonQrBadgeUrlStr see setPersonQrBadgeUrl - * @param setPersonQrBadgeUsername When setPersonQrBadgeUrl is non-null, this is the username that - * should be assigned to the person as per guid. - */ @Serializable data class ManageAccount( val guid: String, @@ -670,15 +610,9 @@ data class ManageAccount( private val setPersonQrBadgeUrlStr: String? = null, ) : RespectAppRoute { - /** - * When a QR badge is first assigned as part of creating an account, this is the URL for the - * badge. When the user flow is PersonDetail, CreateAccountSetUsername, ScanQRCode, ManageAccount. - * ScanQRCode is not scoped to a particular school and cannot handle saving the QR code badge. - */ @Transient val setPersonQrBadgeUrl: Url? = setPersonQrBadgeUrlStr?.let { Url(it) } - companion object { fun create( guid: String, @@ -718,8 +652,6 @@ data class PersonEdit( presetRoleStr = presetRole?.value, ) } - - } @Serializable @@ -761,38 +693,74 @@ data class ScanQRCode( ) } } +@Serializable +class PlaylistList private constructor( + private val resultDestStr: String? = null, +) : RespectAppRoute, RouteWithResultDest { + @Transient + override val resultDest: ResultDest? = ResultDest.fromStringOrNull(resultDestStr) + + companion object { + fun create( + resultDest: ResultDest? = null, + ) = PlaylistList( + resultDestStr = resultDest.encodeToJsonStringOrNull() + ) + } +} @Serializable -data object CurriculumMappingList : RespectAppRoute +class PlaylistDetail private constructor( + private val playlistUrlStr: String, + private val resultDestStr: String? = null, +) : RespectAppRoute, RouteWithResultDest { + + @Transient + val playlistUrl = Url(playlistUrlStr) + + @Transient + override val resultDest: ResultDest? = ResultDest.fromStringOrNull(resultDestStr) + companion object { + fun create( + playlistUrl: Url, + resultDest: ResultDest? = null, + ) = PlaylistDetail( + playlistUrlStr = playlistUrl.toString(), + resultDestStr = resultDest.encodeToJsonStringOrNull(), + ) + } +} @Serializable -data class CurriculumMappingEdit( - val textbookUid: Long = 0L, - private val mappingDataJson: String? = null +class PlaylistEdit private constructor( + private val playlistUrlStr: String? = null, + val isCopy: Boolean = false, ) : RespectAppRoute { @Transient - val mappingData: CurriculumMapping? = mappingDataJson?.let { jsonString -> - try { - Json.decodeFromString(CurriculumMapping.serializer(), jsonString) - } catch (e: Exception) { - null - } - } + val playlistUrl: Url? = playlistUrlStr?.let { Url(it) } companion object { fun create( - uid: Long, - mappingData: CurriculumMapping? = null - ) = CurriculumMappingEdit( - textbookUid = uid, - mappingDataJson = mappingData?.let { mapping -> - try { - Json.encodeToString(CurriculumMapping.serializer(), mapping) - } catch (e: Exception) { - null - } - } + playlistUrl: Url? = null, + isCopy: Boolean = false, + ) = PlaylistEdit( + playlistUrlStr = playlistUrl?.toString(), + isCopy = isCopy, + ) + } +} +@Serializable +class PlaylistShare private constructor( + private val playlistUrlStr: String, +) : RespectAppRoute { + + @Transient + val playlistUrl = Url(playlistUrlStr) + + companion object { + fun create(playlistUrl: Url) = PlaylistShare( + playlistUrlStr = playlistUrl.toString() ) } } @@ -807,39 +775,30 @@ data class CreateAccountSetPassword( val username: String? = null, ) : RespectAppRoute - - @Serializable data class ChangePassword( val guid: String, -): RespectAppRoute +) : RespectAppRoute @Serializable data class InvitePerson( val invitePersonOptionsStr: String, ) : RespectAppRoute { - /** - * As there are three types of invitations, so there are three different types of invite options - */ @Serializable sealed interface InvitePersonOptions - /** - * @property if presetRole is set - then dropdown will not be displayed. - */ @Serializable @SerialName("newuser") data class NewUserInviteOptions( val presetRole: PersonRoleEnum? - ): InvitePersonOptions + ) : InvitePersonOptions @Serializable @SerialName("class") data class ClassInviteOptions( val inviteUid: String, - ): InvitePersonOptions - + ) : InvitePersonOptions @Transient val invitePersonOptions: InvitePersonOptions = Json.decodeFromString( @@ -847,7 +806,6 @@ data class InvitePerson( ) companion object { - fun create( invitePersonOptions: InvitePersonOptions ) = InvitePerson( @@ -858,11 +816,11 @@ data class InvitePerson( @Serializable data class QrCode( - val inviteLink:String?=null, - val schoolOrClass:String?=null -): RespectAppRoute + val inviteLink: String? = null, + val schoolOrClass: String? = null +) : RespectAppRoute @Serializable data class CopyCode( - val inviteCode:String?=null -): RespectAppRoute + val inviteCode: String? = null +) : RespectAppRoute \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/enterlink/EnterLinkViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/enterlink/EnterLinkViewModel.kt index 5e08bd688..81dddfd86 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/enterlink/EnterLinkViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/enterlink/EnterLinkViewModel.kt @@ -1,6 +1,7 @@ package world.respect.shared.viewmodel.apps.enterlink import androidx.lifecycle.SavedStateHandle +import io.ktor.http.URLBuilder import io.ktor.http.Url import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,8 +20,12 @@ import world.respect.datalayer.DataErrorResult import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataReadyState import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.school.opds.OpdsFeedDataSource +import world.respect.libutil.ext.RESPECT_SCHOOL_LINK_SEGMENT import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.sharelink.CreatePlaylistShareLinkUseCase import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.PlaylistDetail import world.respect.shared.util.ext.asUiText import kotlin.getValue @@ -64,6 +69,17 @@ class EnterLinkViewModel( launchWithLoadingIndicator { try { val linkUrl = Url(uiState.value.linkUrl) + + if (isPlaylistShareLink(linkUrl)) { + val playlistUrl = resolvePlaylistUrlFromShareLink(linkUrl) + _navCommandFlow.tryEmit( + NavCommand.Navigate( + PlaylistDetail.create(playlistUrl) + ) + ) + return@launchWithLoadingIndicator + } + val appResult = schoolDataSource.opdsPublicationDataSource.getByUrl( url = linkUrl, params = DataLoadParams(), @@ -90,4 +106,21 @@ class EnterLinkViewModel( } } -} + private fun isPlaylistShareLink(url: Url): Boolean { + val segments = url.pathSegments.filter { it.isNotBlank() } + return segments.size >= 3 && + segments[segments.size - 3] == RESPECT_SCHOOL_LINK_SEGMENT && + segments[segments.size - 2] == CreatePlaylistShareLinkUseCase.PATH + } + + private fun resolvePlaylistUrlFromShareLink(shareLink: Url): Url { + val playlistUuid = shareLink.rawSegments.last { it.isNotBlank() } + return URLBuilder(shareLink).apply { + pathSegments = shareLink.pathSegments + .filter { it.isNotBlank() } + .dropLast(3) + + listOf(OpdsFeedDataSource.PLAYLIST_ENDPOINT_NAME, playlistUuid) + }.build() + } + } + 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..a64e08ede 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 @@ -27,7 +26,7 @@ import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.devmode.GetDevModeEnabledUseCase import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.app -import world.respect.shared.generated.resources.apps +import world.respect.shared.generated.resources.home import world.respect.shared.generated.resources.empty_list_description_admin import world.respect.shared.generated.resources.empty_list_description_non_admin import world.respect.shared.navigation.AppsDetail @@ -43,6 +42,7 @@ import world.respect.datalayer.ext.dataOrNull import world.respect.datalayer.ext.map import world.respect.lib.opds.model.OpdsPublication import world.respect.lib.opds.model.respectAppDefaultLessonList +import world.respect.lib.opds.model.toStringMap import world.respect.libutil.ext.resolve import world.respect.shared.util.ext.resolve import world.respect.shared.viewmodel.RespectViewModel @@ -92,7 +92,7 @@ class AppLauncherViewModel( init { _appUiState.update { it.copy( - title = Res.string.apps.asUiText(), + title = Res.string.home.asUiText(), onClickSettings = ::onClickSettings, fabState = FabUiState( icon = FabUiState.FabIcon.ADD, @@ -159,6 +159,7 @@ class AppLauncherViewModel( opdsFeedUrl = defaultLessonUrl, appManifestUrl = url, resultDest = route.resultDest, + title = app.dataOrNull()?.metadata?.title?.toStringMap()?.values?.firstOrNull(), ) }else { AppsDetail.create( @@ -203,4 +204,3 @@ class AppLauncherViewModel( } } } - diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/edit/AssignmentEditViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/edit/AssignmentEditViewModel.kt index 772293f93..22695456f 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/edit/AssignmentEditViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/edit/AssignmentEditViewModel.kt @@ -300,10 +300,9 @@ class AssignmentEditViewModel( } } - companion object { - - const val KEY_LEARNING_UNIT = "result_learning_unit" + companion object { + const val KEY_LEARNING_UNIT = "result_learning_unit_single" + } } -} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/CurriculumMappingAdapter.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/CurriculumMappingAdapter.kt deleted file mode 100644 index d7f5bf874..000000000 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/CurriculumMappingAdapter.kt +++ /dev/null @@ -1,66 +0,0 @@ -package world.respect.shared.viewmodel.curriculum.mapping - -//Add functions that convert CurriculumMapping to OpdsFeed and vice versa. See the adapters in the -//database module - -//e.g. have CurriculumMapping.toOpds (convert from CurriculumMapping data class to Opds) -// and OpdsFeed.toCurriculumMapping (convert from OpdsFeed to CurriculumMapping) - -import world.respect.lib.opds.model.OpdsFeed -import world.respect.lib.opds.model.OpdsFeedMetadata -import world.respect.lib.opds.model.OpdsGroup -import world.respect.lib.opds.model.ReadiumLink -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMapping -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMappingSection -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMappingSectionLink - - -fun CurriculumMapping.toOpds(selfLink: String): OpdsFeed { - return OpdsFeed( - metadata = OpdsFeedMetadata( - title = this.title, - description = this.description - ), - links = listOf( - ReadiumLink( - rel = listOf("self"), - href = selfLink, - type = OpdsFeed.MEDIA_TYPE - ) - ), - groups = this.sections.map { section -> - OpdsGroup( - metadata = OpdsFeedMetadata(title = section.title), - navigation = section.items.map { item -> - ReadiumLink( - href = item.href, - title = item.title, - type = OpdsFeed.MEDIA_TYPE, - rel = listOf("related") - ) - } - ) - } - ) -} - -fun OpdsFeed.toCurriculumMapping(): CurriculumMapping { - return CurriculumMapping( - uid = System.currentTimeMillis(), - title = this.metadata.title, - description = this.metadata.description ?: "", - sections = this.groups?.map { group -> - CurriculumMappingSection( - uid = System.currentTimeMillis(), - title = group.metadata.title, - items = group.navigation?.map { navLink -> - CurriculumMappingSectionLink( - uid = System.currentTimeMillis(), - href = navLink.href, - title = navLink.title ?: "" - ) - } ?: emptyList() - ) - } ?: emptyList() - ) -} diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/edit/CurriculumMappingEditViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/edit/CurriculumMappingEditViewModel.kt deleted file mode 100644 index 0ad4fb422..000000000 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/edit/CurriculumMappingEditViewModel.kt +++ /dev/null @@ -1,284 +0,0 @@ -package world.respect.shared.viewmodel.curriculum.mapping.edit - -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 -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -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.DataLoadState -import world.respect.datalayer.SchoolDataSource -import world.respect.datalayer.ext.map -import world.respect.lib.opds.model.findIcons -import world.respect.libutil.ext.moveItem -import world.respect.libutil.ext.updateAtIndex -import world.respect.libutil.ext.resolve -import world.respect.shared.domain.account.RespectAccountManager -import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.edit_mapping -import world.respect.shared.generated.resources.required_field -import world.respect.shared.generated.resources.save -import world.respect.shared.navigation.CurriculumMappingEdit -import world.respect.shared.navigation.NavCommand -import world.respect.shared.navigation.NavResult -import world.respect.shared.navigation.NavResultReturner -import world.respect.shared.navigation.RespectAppLauncher -import world.respect.shared.resources.UiText -import world.respect.shared.util.ext.asUiText -import world.respect.shared.viewmodel.RespectViewModel -import world.respect.shared.viewmodel.app.appstate.ActionBarButtonUiState -import world.respect.shared.viewmodel.assignment.edit.AssignmentEditViewModel.Companion.KEY_LEARNING_UNIT -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMapping -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMappingSection -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMappingSectionLink -import world.respect.shared.viewmodel.learningunit.LearningUnitSelection -import world.respect.shared.navigation.RouteResultDest -import world.respect.shared.viewmodel.app.appstate.getTitle - -data class CurriculumMappingEditUiState( - val mapping: CurriculumMapping? = null, - val loading: Boolean = false, - val isNew: Boolean = true, - val titleError: UiText? = null, - val error: UiText? = null, - val pendingLessonSectionIndex: Int? = null, - val sectionUiState: (CurriculumMappingSection) -> Flow = { emptyFlow() }, -) { - val fieldsEnabled: Boolean - get() = !loading - - val title: String - get() = mapping?.title ?: "" - - val description: String - get() = mapping?.description ?: "" - - val sections: List - get() = mapping?.sections ?: emptyList() -} - -data class CurriculumMappingSectionUiState( - val icon: Url? = null, -) - -class CurriculumMappingEditViewModel( - savedStateHandle: SavedStateHandle, - private val resultReturner: NavResultReturner, - private val json: Json, - accountManager: RespectAccountManager, -) : RespectViewModel(savedStateHandle), KoinScopeComponent { - - - override val scope: Scope = accountManager.requireActiveAccountScope() - - private val schoolDataSource: SchoolDataSource by inject() - - private val route: CurriculumMappingEdit = savedStateHandle.toRoute() - - private val mappingUid = route.textbookUid - - private val _uiState = MutableStateFlow( - CurriculumMappingEditUiState( - mapping = CurriculumMapping(uid = mappingUid), - isNew = mappingUid == 0L - ) - ) - - val uiState = _uiState.asStateFlow() - - init { - _appUiState.update { prev -> - prev.copy( - title = Res.string.edit_mapping.asUiText(), - userAccountIconVisible = false, - actionBarButtonState = ActionBarButtonUiState( - visible = false, - text = Res.string.save.asUiText(), - onClick = ::onClickSave - ), - hideBottomNavigation = true - ) - } - - viewModelScope.launch { - resultReturner.filteredResultFlowForKey( - KEY_LEARNING_UNIT - ).collect { result -> - val selectedLearningUnit = result.result as? LearningUnitSelection ?: return@collect - val pendingSectionIndex = _uiState.value.pendingLessonSectionIndex ?: return@collect - - updateUiStateAndCommit { prev -> - prev.copy( - mapping = prev.mapping?.copy( - sections = prev.mapping.sections.updateAtIndex(pendingSectionIndex) { - it.copy( - items = it.items + CurriculumMappingSectionLink( - href = selectedLearningUnit.learningUnitManifestUrl.toString(), - title = selectedLearningUnit.selectedPublication.metadata.title.getTitle() - ) - ) - } - ), - pendingLessonSectionIndex = null, - ) - } - } - } - } - - private fun updateUiStateAndCommit(block: (CurriculumMappingEditUiState) -> CurriculumMappingEditUiState) { - val mappingToCommit = _uiState.updateAndGet(block).mapping ?: return - - savedStateHandle[KEY_MAPPING] = json.encodeToString( - CurriculumMapping.serializer(), mappingToCommit - ) - } - - - fun onTitleChanged(title: String) { - updateUiStateAndCommit { prev -> - prev.copy( - mapping = prev.mapping?.copy(title = title), - titleError = null, - ) - } - } - - fun onDescriptionChanged(description: String) { - updateUiStateAndCommit { prev -> - prev.copy( - mapping = prev.mapping?.copy(description = description) - ) - } - } - - fun onClickAddSection() { - updateUiStateAndCommit { prev -> - prev.copy( - mapping = prev.mapping?.copy( - sections = prev.mapping.sections + CurriculumMappingSection(title = "") - ) - ) - } - } - - fun onSectionTitleChanged(sectionIndex: Int, title: String) { - updateUiStateAndCommit { prev -> - prev.copy( - mapping = prev.mapping?.copy( - sections = prev.mapping.sections.updateAtIndex(sectionIndex) { - it.copy(title = title) - } - ) - ) - } - } - - fun onClickRemoveSection(sectionIndex: Int) { - updateUiStateAndCommit { prev -> - prev.copy( - mapping = prev.mapping?.copy( - sections = prev.mapping.sections.filterIndexed { index, _ -> - index != sectionIndex - } - ) - ) - } - } - - fun onSectionMoved(fromIndex: Int, toIndex: Int) { - updateUiStateAndCommit { prev -> - prev.copy( - mapping = prev.mapping?.copy( - sections = prev.sections.moveItem(from = fromIndex, to = toIndex) - ) - ) - } - } - - fun onClickAddLesson(sectionIndex: Int) { - _uiState.update { it.copy(pendingLessonSectionIndex = sectionIndex) } - _navCommandFlow.tryEmit( - NavCommand.Navigate( - RespectAppLauncher.create( - resultDest = RouteResultDest( - resultPopUpTo = route, - resultKey = KEY_LEARNING_UNIT - ) - ) - ) - ) - } - - fun onClickRemoveLesson(sectionIndex: Int, linkIndex: Int) { - updateUiStateAndCommit { prev -> - prev.copy( - mapping = prev.mapping?.copy( - sections = prev.mapping.sections.updateAtIndex(sectionIndex) { section -> - section.copy( - items = section.items.filterIndexed { index, _ -> index != linkIndex } - ) - } - ) - ) - } - } - - /** - * Provide a flow that creates the SectionLinkUiState . - */ - fun sectionLinkUiStateFor( - link: CurriculumMappingSectionLink - ): Flow> { - val publicationUrl = Url(link.href) - return schoolDataSource.opdsPublicationDataSource.getByUrlAsFlow( - url = Url(link.href), - params = DataLoadParams(), - referrerUrl = null, - expectedPublicationId = null, - ).map { opdsLoadState -> - opdsLoadState.map { publication -> - CurriculumMappingSectionUiState( - icon = publication.findIcons().firstOrNull()?.let { - publicationUrl.resolve(it.href) - } - ) - } - } - } - - fun onClickSave() { - val mapping = _uiState.value.mapping ?: return - if (mapping.title.isBlank()) { - _uiState.update { it.copy(titleError = Res.string.required_field.asUiText()) } - return - } - resultReturner.sendResult( - NavResult( - key = KEY_SAVED_MAPPING, - result = mapping - ) - ) - _navCommandFlow.tryEmit(NavCommand.PopUp()) - } - - fun onClearError() { - _uiState.update { it.copy(titleError = null) } - } - - companion object { - private const val KEY_MAPPING = "curriculum_mapping" - const val KEY_SAVED_MAPPING = "saved_curriculum_mapping" - } -} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/list/CurriculumMappingListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/list/CurriculumMappingListViewModel.kt deleted file mode 100644 index cbc3c75d2..000000000 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/list/CurriculumMappingListViewModel.kt +++ /dev/null @@ -1,145 +0,0 @@ -package world.respect.shared.viewmodel.curriculum.mapping.list - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import io.ktor.http.Url -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.error_unexpected_result_type -import world.respect.shared.generated.resources.mapping -import world.respect.shared.generated.resources.mappings -import world.respect.shared.navigation.CurriculumMappingEdit -import world.respect.shared.navigation.NavCommand -import world.respect.shared.navigation.NavResultReturner -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.curriculum.mapping.edit.CurriculumMappingEditViewModel -import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMapping - -data class CurriculumMappingListUiState( - val mappings: List = emptyList(), - val error: UiText? = null, -) - -class CurriculumMappingListViewModel( - savedStateHandle: SavedStateHandle, - private val json: Json, - private val resultReturner: NavResultReturner, -) : RespectViewModel(savedStateHandle) { - - private val _uiState = MutableStateFlow( - CurriculumMappingListUiState( - mappings = loadMappingsFromSavedState(savedStateHandle) - ) - ) - val uiState = _uiState.asStateFlow() - - init { - _appUiState.update { prev -> - prev.copy( - title = Res.string.mappings.asUiText(), - userAccountIconVisible = true, - fabState = FabUiState( - visible = true, - icon = FabUiState.FabIcon.ADD, - text = Res.string.mapping.asUiText(), - onClick = ::onClickMap, - ), - hideBottomNavigation = true, - ) - } - viewModelScope.launch { - resultReturner.resultFlowForKey( - CurriculumMappingEditViewModel.KEY_SAVED_MAPPING - ).collect { result -> - val savedMapping = result.result as? CurriculumMapping - if (savedMapping == null) { - _uiState.update { - it.copy(error = Res.string.error_unexpected_result_type.asUiText()) - } - return@collect - } - addOrUpdateMapping(savedMapping) - } - } - } - - private fun loadMappingsFromSavedState(savedStateHandle: SavedStateHandle): List { - val mappingsJson = savedStateHandle.get(KEY_MAPPINGS_LIST) ?: return emptyList() - return try { - json.decodeFromString>(mappingsJson) - } catch (e: Exception) { - emptyList() - } - } - - private fun saveMappingsToSavedState(mappings: List) { - savedStateHandle[KEY_MAPPINGS_LIST] = json.encodeToString( - kotlinx.serialization.builtins.ListSerializer(CurriculumMapping.serializer()), - mappings - ) - } - - private fun addOrUpdateMapping(mapping: CurriculumMapping) { - val currentMappings = _uiState.value.mappings.toMutableList() - val existingIndex = currentMappings.indexOfFirst { it.uid == mapping.uid } - - if (existingIndex >= 0) { - currentMappings[existingIndex] = mapping - } else { - val newMapping = if (mapping.uid == 0L) { - mapping.copy(uid = System.currentTimeMillis()) - } else { - mapping - } - currentMappings.add(newMapping) - } - - updateMappings(currentMappings) - } - - fun onClickMapping(mapping: CurriculumMapping) { - _navCommandFlow.tryEmit( - NavCommand.Navigate( - CurriculumMappingEdit.create( - uid = mapping.uid, - mappingData = mapping - ) - ) - ) - } - - fun onClickMap() { - _navCommandFlow.tryEmit( - NavCommand.Navigate( - CurriculumMappingEdit.create(uid = 0L, mappingData = null) - ) - ) - } - - fun onClickMoreOptions(mapping: CurriculumMapping) { - // TODO - } - - private fun updateMappings(newMappings: List) { - _uiState.update { it.copy(mappings = newMappings) } - saveMappingsToSavedState(newMappings) - } - - fun removeMapping(mapping: CurriculumMapping) { - val updated = _uiState.value.mappings.filter { it.uid != mapping.uid } - updateMappings(updated) - } - - companion object { - private const val KEY_MAPPINGS_LIST = "mappings_list" - } -} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMapping.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMapping.kt deleted file mode 100644 index 34253f1bc..000000000 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMapping.kt +++ /dev/null @@ -1,11 +0,0 @@ -package world.respect.shared.viewmodel.curriculum.mapping.model - -import kotlinx.serialization.Serializable - -@Serializable -data class CurriculumMapping( - val uid: Long = System.currentTimeMillis(), - val title: String = "", - val description: String = "", - val sections: List = emptyList() -) diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMappingSection.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMappingSection.kt deleted file mode 100644 index 7aff9ea37..000000000 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMappingSection.kt +++ /dev/null @@ -1,10 +0,0 @@ -package world.respect.shared.viewmodel.curriculum.mapping.model - -import kotlinx.serialization.Serializable - -@Serializable -data class CurriculumMappingSection( - val uid: Long = System.currentTimeMillis(), - val title: String, - val items: List = emptyList() -) diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMappingSectionLink.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMappingSectionLink.kt deleted file mode 100644 index 8003e7ae9..000000000 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMappingSectionLink.kt +++ /dev/null @@ -1,13 +0,0 @@ -package world.respect.shared.viewmodel.curriculum.mapping.model - -import kotlinx.serialization.Serializable - -/** - * @property href Absolute URL to the OPDS publication linked (NOT the Learning Unit ID URL). - */ -@Serializable -data class CurriculumMappingSectionLink( - val uid: Long = System.currentTimeMillis(), - val href: String, - val title: String? = "" -) diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt index 60974fafb..099e4e4a9 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/list/LearningUnitListViewModel.kt @@ -11,28 +11,43 @@ 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.navigation.LearningUnitDetail -import world.respect.shared.navigation.LearningUnitList -import world.respect.shared.viewmodel.app.appstate.AppBarSearchUiState -import world.respect.shared.viewmodel.RespectViewModel import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataReadyState import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.db.school.ext.isAdmin +import world.respect.datalayer.school.domain.MakePlaylistOpdsFeedUseCase +import world.respect.datalayer.school.opds.ext.selfUrl import world.respect.lib.opds.model.OpdsFacet +import world.respect.lib.opds.model.OpdsFeed import world.respect.lib.opds.model.OpdsGroup import world.respect.lib.opds.model.OpdsPublication import world.respect.lib.opds.model.ReadiumLink import world.respect.libutil.ext.resolve import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.account.username.GetActiveUsernameUseCase import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.edit import world.respect.shared.generated.resources.language +import world.respect.shared.navigation.AssignmentEdit +import world.respect.shared.navigation.LearningUnitDetail +import world.respect.shared.navigation.LearningUnitList import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.NavResultReturner +import world.respect.shared.navigation.PlaylistDetail +import world.respect.shared.navigation.PlaylistEdit +import world.respect.shared.navigation.PlaylistShare +import world.respect.shared.navigation.RespectAppLauncher import world.respect.shared.navigation.sendResultIfResultExpected import world.respect.shared.util.SortOrderOption import world.respect.shared.util.ext.asUiText import world.respect.shared.util.ext.resolve +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.viewmodel.app.appstate.AppBarSearchUiState +import world.respect.shared.viewmodel.app.appstate.FabUiState +import world.respect.shared.viewmodel.assignment.edit.AssignmentEditViewModel import world.respect.shared.viewmodel.learningunit.LearningUnitSelection +import world.respect.shared.viewmodel.playlists.mapping.edit.PlaylistEditViewModel +import kotlin.uuid.ExperimentalUuidApi data class LearningUnitListUiState( val publications: List = emptyList(), @@ -44,8 +59,41 @@ data class LearningUnitListUiState( val activeSortOrderOption: SortOrderOption = SortOrderOption( Res.string.language, 1, true ), - val fieldsEnabled: Boolean = true -) + val fieldsEnabled: Boolean = true, + val feed: OpdsFeed? = null, + val isTeacherOrAdmin: Boolean = false, + val collapsedSections: Set = emptySet(), + val isMultiSelectMode: Boolean = false, + val selectedPublications: Set = emptySet(), + val showCopyDialog: Boolean = false, + val copyDialogName: String = "", + val showDeleteDialog: Boolean = false, + val showSelectPlaylistButton: Boolean = false, + val selectedNavigation: ReadiumLink? = null +) { + fun isSectionCollapsed(sectionKey: String) = sectionKey in collapsedSections + + fun isPublicationSelected(publication: OpdsPublication): Boolean = + publication.metadata.identifier?.toString() in selectedPublications + + fun isNavigationSelected(navigation: ReadiumLink): Boolean = + navigation.href == selectedNavigation?.href + + val selectedCount: Int + get() = selectedPublications.size + + val hasLearningUnitSections: Boolean + get() = group.any { it.publications != null } +} + +private fun LearningUnitListUiState.withFeedContent(feed: OpdsFeed): LearningUnitListUiState { + return copy( + feed = feed, + navigation = feed.navigation ?: emptyList(), + publications = feed.publications ?: emptyList(), + group = feed.groups ?: emptyList(), + ) +} class LearningUnitListViewModel( savedStateHandle: SavedStateHandle, @@ -63,14 +111,16 @@ class LearningUnitListViewModel( private val schoolDataSource: SchoolDataSource by inject() + init { + _uiState.update { + it.copy( + showSelectPlaylistButton = route.resultDest?.resultKey == PlaylistEditViewModel.KEY_PLAYLIST + ) + } viewModelScope.launch { _appUiState.update { - it.copy( - searchState = AppBarSearchUiState( - visible = true - ) - ) + it.copy(searchState = AppBarSearchUiState(visible = true)) } schoolDataSource.opdsFeedDataSource.getByUrlAsFlow( @@ -79,36 +129,29 @@ class LearningUnitListViewModel( ).collect { result -> when (result) { is DataReadyState -> { - val resolvedFeed = result.data.resolve(route.opdsFeedUrl) - val appBarTitle = result.data.metadata.title val facetOptions = result.data.facets ?: emptyList() - val sortOptions = facetOptions.mapIndexed { index, facet -> + val sortOptions = facetOptions.mapIndexed { index, _ -> SortOrderOption( fieldMessageId = Res.string.language, flag = index + 1, order = true ) } - _appUiState.update { it.copy( - title = appBarTitle.asUiText(), + title = result.data.metadata.title.asUiText(), searchState = AppBarSearchUiState(visible = true) ) } _uiState.update { - it.copy( - navigation = resolvedFeed.navigation ?: emptyList(), - publications = resolvedFeed.publications?: emptyList(), - group = resolvedFeed.groups?: emptyList(), + it.withFeedContent(resolvedFeed).copy( facetOptions = facetOptions, - sortOptions = sortOptions + sortOptions = sortOptions, ) } } - else -> {} } } @@ -116,12 +159,58 @@ class LearningUnitListViewModel( } fun onSortOrderChanged(sortOption: SortOrderOption) { - _uiState.update { - it.copy(activeSortOrderOption = sortOption) - } + _uiState.update { it.copy(activeSortOrderOption = sortOption) } } - fun onClickPublication(publication: OpdsPublication) { + if (route.resultDest != null && + route.resultDest.resultKey != PlaylistEditViewModel.KEY_PLAYLIST + ) { + if (route.resultDest.resultKey == AssignmentEditViewModel.KEY_LEARNING_UNIT) { + val publicationHref = publication.links.find { + it.rel?.contains(SELF) == true + }?.href.toString() + val learningUnitManifestUrl = route.opdsFeedUrl.resolve(publicationHref) + resultReturner.sendResultIfResultExpected( + route = route, + navCommandFlow = _navCommandFlow, + result = LearningUnitSelection( + learningUnitManifestUrl = learningUnitManifestUrl, + selectedPublication = publication, + appManifestUrl = route.appManifestUrl, + ) + ) + return + } + if (!_uiState.value.isMultiSelectMode) { + _uiState.update { it.copy(isMultiSelectMode = true) } + } + toggleSelection(publication) + return + } + if (route.resultDest?.resultKey == PlaylistEditViewModel.KEY_PLAYLIST) { + val publicationHref = publication.links.find { + it.rel?.contains(SELF) == true + }?.href.toString() + val refererUrl = route.opdsFeedUrl.resolve(publicationHref).toString() + val learningUnitManifestUrl = route.opdsFeedUrl.resolve(publicationHref) + _navCommandFlow.tryEmit( + value = NavCommand.Navigate( + LearningUnitDetail.create( + learningUnitManifestUrl = learningUnitManifestUrl, + appManifestUrl = route.appManifestUrl, + refererUrl = Url(refererUrl), + expectedIdentifier = publication.metadata.identifier.toString() + ) + ) + ) + return + } + + if (_uiState.value.isMultiSelectMode) { + toggleSelection(publication) + return + } + val publicationHref = publication.links.find { it.rel?.contains(SELF) == true }?.href.toString() @@ -145,26 +234,85 @@ class LearningUnitListViewModel( LearningUnitDetail.create( learningUnitManifestUrl = learningUnitManifestUrl, appManifestUrl = route.appManifestUrl, - refererUrl = Url( - refererUrl - ), + refererUrl = Url(refererUrl), expectedIdentifier = publication.metadata.identifier.toString() ) ) ) } } + fun onLongPressPublication(publication: OpdsPublication) { + _uiState.update { it.copy(isMultiSelectMode = true) } + toggleSelection(publication) + } - fun onClickNavigation(navigation: ReadiumLink) { + private fun toggleSelection(publication: OpdsPublication) { + val id = publication.metadata.identifier?.toString() ?: return + _uiState.update { prev -> + val updated = if (id in prev.selectedPublications) { + prev.selectedPublications - id + } else { + prev.selectedPublications + id + } + prev.copy( + selectedPublications = updated, + isMultiSelectMode = updated.isNotEmpty(), + ) + } + } + + fun onClickConfirmSelection() { + val currentState = _uiState.value + if (currentState.selectedPublications.isEmpty()) return + + val allPublications = currentState.publications + + currentState.group.flatMap { it.publications ?: emptyList() } + + val selections = allPublications + .filter { pub -> + pub.metadata.identifier?.toString() in currentState.selectedPublications + } + .map { publication -> + val publicationHref = publication.links.find { + it.rel?.contains(SELF) == true + }?.href.toString() + LearningUnitSelection( + learningUnitManifestUrl = route.opdsFeedUrl.resolve(publicationHref), + selectedPublication = publication, + appManifestUrl = route.appManifestUrl, + ) + } + + resultReturner.sendResultIfResultExpected( + route = route, + navCommandFlow = _navCommandFlow, + result = selections, + ) + } + fun onClickNavigation(navigation: ReadiumLink) { val navigationHref = navigation.href + val resolvedUrl = route.opdsFeedUrl.resolve(navigationHref) + + if (route.resultDest?.resultKey == PlaylistEditViewModel.KEY_PLAYLIST) { + _uiState.update { prev -> + val isDeselecting = prev.selectedNavigation?.href == resolvedUrl.toString() + prev.copy( + isMultiSelectMode = !isDeselecting, + selectedNavigation = if (isDeselecting) { + null + } else { + navigation.copy(href = resolvedUrl.toString()) + } + ) + } + return + } _navCommandFlow.tryEmit( NavCommand.Navigate( LearningUnitList.create( - opdsFeedUrl = route.opdsFeedUrl.resolve( - navigationHref - ), + opdsFeedUrl = resolvedUrl, appManifestUrl = route.appManifestUrl, resultDest = route.resultDest, ) @@ -172,10 +320,266 @@ class LearningUnitListViewModel( ) } + + fun onClickSelectPlaylist() { + resultReturner.sendResultIfResultExpected( + route = route, + navCommandFlow = _navCommandFlow, + result = _uiState.value.selectedNavigation ?: return + ) + } companion object { const val SELF = "self" const val ICON = "icon" + } +} + +class PlaylistDetailViewModel( + savedStateHandle: SavedStateHandle, + private val accountManager: RespectAccountManager, + private val resultReturner: NavResultReturner, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val schoolDataSource: SchoolDataSource by inject() + + private val getActiveUsernameUseCase = GetActiveUsernameUseCase(accountManager) + + private val route: PlaylistDetail = savedStateHandle.toRoute() + + private val _uiState = MutableStateFlow(LearningUnitListUiState()) + + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + accountManager.selectedAccountAndPersonFlow.collect { sessionAndPerson -> + val isTeacherOrAdmin = sessionAndPerson?.person?.isAdmin() == true + _uiState.update { it.copy(isTeacherOrAdmin = isTeacherOrAdmin) } + _appUiState.update { + it.copy( + fabState = FabUiState( + visible = isTeacherOrAdmin, + icon = FabUiState.FabIcon.EDIT, + text = Res.string.edit.asUiText(), + onClick = ::onClickEdit, + ) + ) + } + } + } + + viewModelScope.launch { + schoolDataSource.opdsFeedDataSource.getByUrlAsFlow( + url = route.playlistUrl, + params = DataLoadParams(), + ).collect { result -> + when (result) { + is DataReadyState -> { + _appUiState.update { + it.copy(title = result.data.metadata.title.asUiText()) + } + _uiState.update { it.withFeedContent(result.data) } + } + else -> {} + } + } + } + } + fun onClickToggleSection(sectionKey: String) { + _uiState.update { prev -> + val updatedCollapsed = if (sectionKey in prev.collapsedSections) { + prev.collapsedSections - sectionKey + } else { + prev.collapsedSections + sectionKey + } + prev.copy(collapsedSections = updatedCollapsed) + } + } + fun onClickShare() { + val playlistUrl = _uiState.value.feed?.selfUrl() + ?: throw IllegalStateException( + "Cannot share playlist: feed has no self URL" + ) + _navCommandFlow.tryEmit( + NavCommand.Navigate(PlaylistShare.create(playlistUrl = playlistUrl)) + ) + } + fun onClickCopyPlaylist() { + val feed = _uiState.value.feed ?: return + _uiState.update { + it.copy( + showCopyDialog = true, + copyDialogName = feed.metadata.title, + ) + } } -} + + fun onCopyDialogDismiss() { + _uiState.update { it.copy(showCopyDialog = false, copyDialogName = "") } + } + + fun onCopyDialogNameChanged(name: String) { + _uiState.update { it.copy(copyDialogName = name) } + } + + fun onCopyDialogConfirm() { + val feed = _uiState.value.feed ?: return + val newName = _uiState.value.copyDialogName.trim() + if (newName.isBlank()) return + + viewModelScope.launch { + val activeAccount = accountManager.activeAccount + ?: throw IllegalStateException("No active account when copying playlist") + + val username = getActiveUsernameUseCase() + @OptIn(ExperimentalUuidApi::class) + val copiedFeed = MakePlaylistOpdsFeedUseCase( + schoolUrl = activeAccount.school.self + ).invoke( + base = feed.copy( + metadata = feed.metadata.copy(title = newName) + ), + username = username, + ) + + schoolDataSource.opdsFeedDataSource.store(listOf(copiedFeed)) + + _uiState.update { it.copy(showCopyDialog = false, copyDialogName = "") } + + val copiedUrl = copiedFeed.selfUrl() + ?: throw IllegalStateException("Copied feed has no self URL") + + _navCommandFlow.tryEmit( + NavCommand.Navigate( + PlaylistEdit.create( + playlistUrl = copiedUrl, + isCopy = true, + ) + ) + ) + } + } + + fun onClickDelete() { + _uiState.update { it.copy(showDeleteDialog = true) } + } + + fun onDeleteDialogDismiss() { + _uiState.update { it.copy(showDeleteDialog = false) } + } + + fun onDeleteDialogConfirm() { + viewModelScope.launch { + val feed = _uiState.value.feed ?: return@launch + val selfUrl = feed.selfUrl() ?: return@launch + + schoolDataSource.opdsFeedDataSource.deleteByUrl(selfUrl) + + _uiState.update { it.copy(showDeleteDialog = false) } + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = RespectAppLauncher.create() + ) + ) + } + } + + fun onClickNavigation(navigation: ReadiumLink) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + PlaylistDetail.create(playlistUrl = Url(navigation.href)) + ) + ) + } + fun onClickPublication(publication: OpdsPublication) { + val selfHref = publication.links.find { + it.rel?.contains(LearningUnitListViewModel.SELF) == true + }?.href ?: throw IllegalStateException( + "Publication has no self link: ${publication.metadata.title}" + ) + + val appManifestUrl = _uiState.value.feed?.selfUrl() + ?: throw IllegalStateException( + "Cannot navigate to publication: playlist feed has no self URL" + ) + + if (!resultReturner.sendResultIfResultExpected( + route = route, + navCommandFlow = _navCommandFlow, + result = LearningUnitSelection( + learningUnitManifestUrl = Url(selfHref), + selectedPublication = publication, + appManifestUrl = appManifestUrl, + ) + ) + ) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + LearningUnitDetail.create( + learningUnitManifestUrl = Url(selfHref), + appManifestUrl = appManifestUrl, + expectedIdentifier = publication.metadata.identifier?.toString(), + ) + ) + ) + } + } + fun onClickAssignSection(sectionIndex: Int) { + val feed = _uiState.value.feed ?: throw IllegalStateException( + "Cannot assign: no playlist feed loaded" + ) + val playlistUrl = feed.selfUrl() + ?: throw IllegalStateException("Cannot assign: playlist feed has no self URL") + + val targetSection = if (sectionIndex == ASSIGN_HEADER_SECTION_INDEX) { + _uiState.value.group.firstOrNull { it.publications?.isNotEmpty() == true } + ?: throw IllegalStateException("No learning unit section with items found to assign") + } else { + _uiState.value.group.getOrNull(sectionIndex) + ?: throw IllegalStateException("No section at index $sectionIndex") + } + + val firstPublication = targetSection.publications?.firstOrNull() + ?: throw IllegalStateException( + "Assign clicked but section at index $sectionIndex has no learning items" + ) + + val publicationSelfHref = firstPublication.links.find { + it.rel?.contains(LearningUnitListViewModel.SELF) == true + }?.href ?: throw IllegalStateException( + "Publication has no self link: ${firstPublication.metadata.title}" + ) + + val learningUnitManifestUrl = playlistUrl.resolve(publicationSelfHref) + + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = AssignmentEdit.create( + uid = null, + learningUnitSelected = LearningUnitSelection( + learningUnitManifestUrl = learningUnitManifestUrl, + selectedPublication = firstPublication, + appManifestUrl = playlistUrl, + ) + ) + ) + ) + } + + fun onClickEdit() { + val playlistUrl = _uiState.value.feed?.selfUrl() + ?: throw IllegalStateException( + "Cannot edit playlist: feed has no self URL" + ) + _navCommandFlow.tryEmit( + NavCommand.Navigate(PlaylistEdit.create(playlistUrl = playlistUrl)) + ) + } + companion object { + const val ASSIGN_HEADER_SECTION_INDEX = -1 + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/edit/PlaylistEditViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/edit/PlaylistEditViewModel.kt new file mode 100644 index 000000000..edcbdd932 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/edit/PlaylistEditViewModel.kt @@ -0,0 +1,521 @@ +package world.respect.shared.viewmodel.playlists.mapping.edit + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +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.DataReadyState +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.school.domain.MakePlaylistOpdsFeedUseCase +import world.respect.datalayer.school.opds.ext.selfUrl +import world.respect.lib.opds.model.OpdsFeed +import world.respect.lib.opds.model.OpdsFeedMetadata +import world.respect.lib.opds.model.OpdsGroup +import world.respect.lib.opds.model.OpdsPublication +import world.respect.lib.opds.model.ReadiumLink +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.account.username.GetActiveUsernameUseCase +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.add_playlist +import world.respect.shared.generated.resources.copy_playlist +import world.respect.shared.generated.resources.edit_playlist +import world.respect.shared.generated.resources.learning_item_section +import world.respect.shared.generated.resources.playlist_section +import world.respect.shared.generated.resources.required_field +import world.respect.shared.generated.resources.save +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.NavResultReturner +import world.respect.shared.navigation.PlaylistDetail +import world.respect.shared.navigation.PlaylistEdit +import world.respect.shared.navigation.PlaylistList +import world.respect.shared.navigation.RespectAppLauncher +import world.respect.shared.navigation.RouteResultDest +import world.respect.shared.resources.UiText +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.viewmodel.app.appstate.ActionBarButtonUiState +import world.respect.shared.viewmodel.learningunit.LearningUnitSelection +import world.respect.shared.viewmodel.playlists.mapping.list.PlaylistListUiState +import kotlin.uuid.ExperimentalUuidApi + +enum class PlaylistSectionType { + NAVIGATION, + PUBLICATION, +} + +data class MovingItemState( + val fromSectionIndex: Int, + val itemIndex: Int, + val compatibleSections: List, +) { + data class CompatibleSection( + val sectionIndex: Int, + ) +} + +data class PlaylistEditUiState( + val feed: OpdsFeed? = null, + val isSectionTypeDialogVisible: Boolean = false, + val titleError: UiText? = null, + val movingItem: MovingItemState? = null, +) { + val title: String + get() = feed?.metadata?.title ?: "" + + val description: String + get() = feed?.metadata?.description ?: "" + + val sections: List + get() = feed?.groups ?: emptyList() + + val hasErrors: Boolean + get() = titleError!=null +} + +class PlaylistEditViewModel( + savedStateHandle: SavedStateHandle, + private val accountManager: RespectAccountManager, + private val resultReturner: NavResultReturner, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val schoolDataSource: SchoolDataSource by inject() + private val getActiveUsernameUseCase = GetActiveUsernameUseCase(accountManager) + + + private val route: PlaylistEdit = savedStateHandle.toRoute() + + private val _uiState = MutableStateFlow(PlaylistEditUiState()) + + val uiState = _uiState.asStateFlow() + + private var pendingAddItemSectionIndex: Int? + get() = savedStateHandle.get(KEY_PENDING_ADD_ITEM_SECTION_INDEX) + set(value) { savedStateHandle[KEY_PENDING_ADD_ITEM_SECTION_INDEX] = value } + + private var pendingAddPlaylistSectionIndex: Int? + get() = savedStateHandle.get(KEY_PENDING_ADD_PLAYLIST_SECTION_INDEX) + set(value) { savedStateHandle[KEY_PENDING_ADD_PLAYLIST_SECTION_INDEX] = value } + + init { + restoreAppBarState() + val existingPlaylistUrl = route.playlistUrl + if (existingPlaylistUrl != null) { + viewModelScope.launch { + schoolDataSource.opdsFeedDataSource.getByUrlAsFlow( + url = existingPlaylistUrl, + params = DataLoadParams(), + ).collect { result -> + when (result) { + is DataReadyState -> { + val fixedFeed = result.data.copy( + groups = result.data.groups?.map { group -> + group.copy( + navigation = group.navigation?.takeIf { it.isNotEmpty() }, + ) + } + ) + _uiState.update { it.copy(feed = fixedFeed) } + } + else -> {} + } + } + } + } else { + viewModelScope.launch { + val activeAccount = accountManager.activeAccount + ?: throw IllegalStateException( + "No active account when initializing PlaylistEditViewModel" + ) + val username = getActiveUsernameUseCase() + + @OptIn(ExperimentalUuidApi::class) + _uiState.update { + it.copy( + feed = MakePlaylistOpdsFeedUseCase( + schoolUrl = activeAccount.school.self + ).invoke( + base = OpdsFeed( + metadata = OpdsFeedMetadata(title = ""), + links = emptyList(), + publications = emptyList(), + groups = emptyList(), + ), + username = username, + ) + ) + } + restoreAppBarState() + } + } + + viewModelScope.launch { + resultReturner.filteredResultFlowForKey(KEY_LEARNING_UNIT).collect { result -> + val sectionIndex = pendingAddItemSectionIndex + ?: throw IllegalStateException( + "Received learning unit result but no pending section index" + ) + pendingAddItemSectionIndex = null + + val selections: List = when (val r = result.result) { + is LearningUnitSelection -> listOf(r) + is List<*> -> r.filterIsInstance() + else -> throw IllegalStateException( + "Expected LearningUnitSelection or List but got: ${result.result}" + ) + } + _uiState.first { it.feed != null } + + _uiState.update { prev -> + val sections = (prev.feed?.groups ?: emptyList()).toMutableList() + val section = sections.getOrNull(sectionIndex) + ?: throw IllegalStateException("No section at index $sectionIndex") + sections[sectionIndex] = section.copy( + publications = (section.publications ?: emptyList()) + + selections.map { it.selectedPublication } + ) + prev.copy(feed = prev.feed?.copy(groups = sections)) + } + restoreAppBarState() + } + } + viewModelScope.launch { + resultReturner.filteredResultFlowForKey(KEY_PLAYLIST).collect { result -> + val sectionIndex = pendingAddPlaylistSectionIndex + ?: throw IllegalStateException( + "Received playlist result but no pending section index" + ) + pendingAddPlaylistSectionIndex = null + + val navLink = when (val data = result.result) { + is OpdsPublication -> { + val selfLink = data.links.firstOrNull { + it.rel?.contains(PlaylistListUiState.REL_SELF) == true + } ?: data.links.firstOrNull() + + ReadiumLink( + href = selfLink?.href + ?: throw IllegalStateException("No href for playlist"), + title = data.metadata.title.toString().takeIf { it.isNotBlank() } + ?: selfLink.title, + rel = listOf(PlaylistListUiState.REL_SELF), + type = OpdsFeed.MEDIA_TYPE, + ) + } + is ReadiumLink -> { + if (data.title.isNullOrBlank()) { + throw IllegalStateException( + "ReadiumLink result has no title" + ) + } + data + } + else -> throw IllegalStateException( + "Expected OpdsPublication or ReadiumLink but got: ${result.result}" + ) + } + + require(!navLink.title.isNullOrBlank()) { + "Playlist navigation must have a title" + } + + _uiState.first { it.feed != null } + + _uiState.update { prev -> + val sections = (prev.feed?.groups ?: emptyList()).toMutableList() + + val section = sections.getOrNull(sectionIndex) + ?: throw IllegalStateException("No section at index $sectionIndex") + + sections[sectionIndex] = section.copy( + navigation = (section.navigation ?: emptyList()) + navLink + ) + + prev.copy( + feed = prev.feed?.copy(groups = sections) + ) + } + + restoreAppBarState() + } + } + + } + + fun restoreAppBarState() { + _appUiState.update { prev -> + prev.copy( + title = when { + route.isCopy -> Res.string.copy_playlist.asUiText() + route.playlistUrl == null -> Res.string.add_playlist.asUiText() + else -> Res.string.edit_playlist.asUiText() + }, + userAccountIconVisible = false, + actionBarButtonState = ActionBarButtonUiState( + visible = true, + text = Res.string.save.asUiText(), + onClick = ::onClickSave, + ), + hideBottomNavigation = true, + ) + } + } + + fun onTitleChanged(title: String) { + _uiState.update { prev -> + prev.copy( + feed = prev.feed?.copy( + metadata = prev.feed.metadata.copy(title = title) + ), + titleError = null, + ) + } + } + + fun onDescriptionChanged(description: String) { + _uiState.update { prev -> + prev.copy( + feed = prev.feed?.copy( + metadata = prev.feed.metadata.copy(description = description) + ) + ) + } + } + + fun onSectionTitleChanged(sectionIndex: Int, title: String) { + _uiState.update { prev -> + val sections = (prev.feed?.groups ?: emptyList()).toMutableList() + val section = sections.getOrNull(sectionIndex) + ?: throw IllegalStateException("No section at index $sectionIndex") + sections[sectionIndex] = section.copy( + metadata = section.metadata.copy(title = title) + ) + prev.copy(feed = prev.feed?.copy(groups = sections)) + } + } + + fun onClickAddSection() { + _uiState.update { it.copy(isSectionTypeDialogVisible = true) } + } + + fun onDismissSectionTypeDialog() { + _uiState.update { it.copy(isSectionTypeDialogVisible = false) } + } + + fun onClickSectionType(sectionType: PlaylistSectionType) { + viewModelScope.launch { + val sectionTitle = when (sectionType) { + PlaylistSectionType.NAVIGATION -> getString(Res.string.playlist_section) + PlaylistSectionType.PUBLICATION -> getString(Res.string.learning_item_section) + } + _uiState.update { prev -> + val newSection = OpdsGroup( + metadata = OpdsFeedMetadata(title = sectionTitle), + navigation = if (sectionType == PlaylistSectionType.NAVIGATION) emptyList() else null, + publications = if (sectionType == PlaylistSectionType.PUBLICATION) emptyList() else null, + ) + prev.copy( + feed = prev.feed?.copy( + groups = (prev.feed.groups ?: emptyList()) + newSection + ), + isSectionTypeDialogVisible = false, + ) + } + } + } + + fun onClickDeleteSection(sectionIndex: Int) { + _uiState.update { prev -> + val sections = (prev.feed?.groups ?: emptyList()).toMutableList() + sections.removeAt(sectionIndex) + prev.copy(feed = prev.feed?.copy(groups = sections)) + } + } + + fun onSectionsReordered(sections: List) { + _uiState.update { prev -> + prev.copy(feed = prev.feed?.copy(groups = sections)) + } + } + + fun onItemsReordered(sectionIndex: Int, items: List) { + _uiState.update { prev -> + val sections = (prev.feed?.groups ?: emptyList()).toMutableList() + val section = sections.getOrNull(sectionIndex) + ?: throw IllegalStateException("No section at index $sectionIndex") + sections[sectionIndex] = if (section.navigation != null) { + section.copy(navigation = items.filterIsInstance()) + } else { + section.copy(publications = items.filterIsInstance()) + } + prev.copy(feed = prev.feed?.copy(groups = sections)) + } + } + + fun onClickDeleteItem(sectionIndex: Int, itemIndex: Int) { + _uiState.update { prev -> + val sections = (prev.feed?.groups ?: emptyList()).toMutableList() + val section = sections.getOrNull(sectionIndex) + ?: throw IllegalStateException("No section at index $sectionIndex") + sections[sectionIndex] = if (section.navigation != null) { + val items = (section.navigation ?: emptyList()).toMutableList() + items.removeAt(itemIndex) + section.copy(navigation = items) + } else { + val items = (section.publications ?: emptyList()).toMutableList() + items.removeAt(itemIndex) + section.copy(publications = items) + } + prev.copy(feed = prev.feed?.copy(groups = sections)) + } + } + + fun onClickMoveItem(sectionIndex: Int, itemIndex: Int) { + val sections = _uiState.value.feed?.groups ?: emptyList() + val fromSection = sections.getOrNull(sectionIndex) + ?: throw IllegalStateException("No section at index $sectionIndex") + + val compatibleSections = sections.mapIndexedNotNull { index, section -> + if (index == sectionIndex) return@mapIndexedNotNull null + val isCompatible = if (fromSection.navigation != null) { + section.navigation != null + } else { + section.publications != null + } + if (!isCompatible) return@mapIndexedNotNull null + MovingItemState.CompatibleSection( + sectionIndex = index, + ) + } + + if (compatibleSections.size == 1) { + moveItemToSection(sectionIndex, itemIndex, compatibleSections.first().sectionIndex) + } else { + _uiState.update { + it.copy( + movingItem = MovingItemState( + fromSectionIndex = sectionIndex, + itemIndex = itemIndex, + compatibleSections = compatibleSections, + ) + ) + } + } + } + + fun onClickMoveItemToSection(targetSectionIndex: Int) { + val moving = _uiState.value.movingItem + ?: throw IllegalStateException( + "onClickMoveItemToSection called but no item is being moved" + ) + _uiState.update { it.copy(movingItem = null) } + moveItemToSection(moving.fromSectionIndex, moving.itemIndex, targetSectionIndex) + } + + fun onDismissMoveDialog() { + _uiState.update { it.copy(movingItem = null) } + } + + private fun moveItemToSection(sectionIndex: Int, itemIndex: Int, targetSectionIndex: Int) { + _uiState.update { prev -> + val sections = (prev.feed?.groups ?: emptyList()).toMutableList() + val fromSection = sections.getOrNull(sectionIndex) + ?: throw IllegalStateException("No section at index $sectionIndex") + val toSection = sections.getOrNull(targetSectionIndex) + ?: throw IllegalStateException("No section at index $targetSectionIndex") + + sections[sectionIndex] = if (fromSection.navigation != null) { + val items = (fromSection.navigation ?: emptyList()).toMutableList() + val item = items.removeAt(itemIndex) + sections[targetSectionIndex] = toSection.copy( + navigation = (toSection.navigation ?: emptyList()) + item + ) + fromSection.copy(navigation = items) + } else { + val items = (fromSection.publications ?: emptyList()).toMutableList() + val item = items.removeAt(itemIndex) + sections[targetSectionIndex] = toSection.copy( + publications = (toSection.publications ?: emptyList()) + item + ) + fromSection.copy(publications = items) + } + prev.copy(feed = prev.feed?.copy(groups = sections)) + } + } + + fun onClickAddItem(sectionIndex: Int) { + pendingAddItemSectionIndex = sectionIndex + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = RespectAppLauncher.create( + resultDest = RouteResultDest( + resultPopUpTo = route, + resultKey = KEY_LEARNING_UNIT, + ) + ), + ) + ) + } + + fun onClickAddPlaylist(sectionIndex: Int) { + pendingAddPlaylistSectionIndex = sectionIndex + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = RespectAppLauncher.create( + resultDest = RouteResultDest( + resultPopUpTo = route, + resultKey = KEY_PLAYLIST, + ) + ), + ) + ) + } + + fun onClickSave() { + val feed = _uiState.value.feed + ?: throw IllegalStateException("onClickSave called but feed is null") + if (feed.metadata.title.isBlank()) { + _uiState.update { + it.copy( + titleError = + Res.string.required_field.asUiText() + ) + } + if(uiState.value.hasErrors) + return + } + + viewModelScope.launch { + schoolDataSource.opdsFeedDataSource.store(listOf(feed)) + + val savedPlaylistUrl = feed.selfUrl() + ?: throw IllegalStateException("Saved playlist has no self URL") + + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = PlaylistDetail.create(playlistUrl = savedPlaylistUrl), + popUpTo = PlaylistList.create(), + popUpToInclusive = false, + ) + ) + } + } + + companion object { + const val KEY_LEARNING_UNIT = "result_learning_unit" + const val KEY_PLAYLIST = "result_playlist" + private const val KEY_PENDING_ADD_ITEM_SECTION_INDEX = "pending_add_item_section_index" + private const val KEY_PENDING_ADD_PLAYLIST_SECTION_INDEX = + "pending_add_playlist_section_index" + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/list/PlaylistListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/list/PlaylistListViewModel.kt new file mode 100644 index 000000000..33250e16b --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/list/PlaylistListViewModel.kt @@ -0,0 +1,170 @@ +package world.respect.shared.viewmodel.playlists.mapping.list + +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.DataReadyState +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.db.school.ext.isAdmin +import world.respect.datalayer.school.domain.MakePlaylistOpdsFeedUseCase +import world.respect.datalayer.school.opds.ext.selfUrl +import world.respect.lib.opds.model.OpdsFeed +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.account.username.GetActiveUsernameUseCase +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.home +import world.respect.shared.generated.resources.playlist +import world.respect.shared.navigation.EnterLink +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.PlaylistDetail +import world.respect.shared.navigation.PlaylistEdit +import world.respect.shared.navigation.PlaylistList +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.viewmodel.app.appstate.FabUiState + +enum class PlaylistFilter { + ALL, + MY_PLAYLISTS, +} + +data class PlaylistListUiState( + val playlists: List = emptyList(), + val activeFilter: PlaylistFilter = PlaylistFilter.ALL, + val isTeacherOrAdmin: Boolean = false, + val activeUserOwnerHref: String = "", + val isFabMenuExpanded: Boolean = false, +) { + val showPlaylists: List + get() = when (activeFilter) { + PlaylistFilter.ALL -> playlists + PlaylistFilter.MY_PLAYLISTS -> playlists.filter { feed -> + feed.links.any { link -> + link.rel?.contains(REL_OWNER) == true + && link.href == activeUserOwnerHref + } + } + } + + companion object { + const val REL_OWNER = MakePlaylistOpdsFeedUseCase.REL_OWNER + const val REL_SELF = "self" + } +} + +class PlaylistListViewModel( + savedStateHandle: SavedStateHandle, + private val accountManager: RespectAccountManager, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val schoolDataSource: SchoolDataSource by inject() + private val getActiveUsernameUseCase = GetActiveUsernameUseCase(accountManager) + private val _uiState = MutableStateFlow(PlaylistListUiState()) + + val uiState = _uiState.asStateFlow() + + private val route: PlaylistList = savedStateHandle.toRoute() + + init { + _appUiState.update { + it.copy(title = Res.string.home.asUiText()) + } + + val activeAccount = accountManager.activeAccount + ?: throw IllegalStateException( + "No active account when initializing PlaylistListViewModel" + ) + + viewModelScope.launch { + accountManager.selectedAccountAndPersonFlow.collect { sessionAndPerson -> + val isTeacherOrAdmin = sessionAndPerson?.person?.isAdmin() == true + + val activeUserOwnerHref = sessionAndPerson?.let { + val username = getActiveUsernameUseCase() + "${it.session.account.school.self}user/$username" + } ?: "" + _uiState.update { + it.copy( + isTeacherOrAdmin = isTeacherOrAdmin, + activeUserOwnerHref = activeUserOwnerHref, + ) + } + + _appUiState.update { + it.copy( + title = Res.string.home.asUiText(), + fabState = FabUiState( + visible = isTeacherOrAdmin, + icon = FabUiState.FabIcon.ADD, + text = Res.string.playlist.asUiText(), + onClick = ::onClickCreatePlaylist, + ) + ) + } + } + } + viewModelScope.launch { + schoolDataSource.opdsFeedDataSource.getPlaylistsAsFlow( + schoolUrl = activeAccount.school.self + ).collect { result -> + when (result) { + is DataReadyState -> _uiState.update { it.copy(playlists = result.data) } + else -> {} + } + } + } + } + + fun onClickFilter(filter: PlaylistFilter) { + _uiState.update { it.copy(activeFilter = filter) } + } + + fun onClickPlaylist(feed: OpdsFeed) { + val playlistUrl = feed.selfUrl() + ?: throw IllegalStateException( + "Playlist feed has no self URL: ${feed.metadata.title}" + ) + val isPickMode = route.resultDest != null + if (isPickMode) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + PlaylistDetail.create( + playlistUrl = playlistUrl, + resultDest = route.resultDest, + ) + ) + ) + } else { + _navCommandFlow.tryEmit( + NavCommand.Navigate(PlaylistDetail.create(playlistUrl = playlistUrl)) + ) + } + } + + fun onClickCreatePlaylist() { + _uiState.update { it.copy(isFabMenuExpanded = !it.isFabMenuExpanded) } + } + + fun onClickDismissFabMenu() { + _uiState.update { it.copy(isFabMenuExpanded = false) } + } + + fun onClickAddNew() { + _uiState.update { it.copy(isFabMenuExpanded = false) } + _navCommandFlow.tryEmit(NavCommand.Navigate(PlaylistEdit.create())) + } + + fun onClickAddFromLink() { + _uiState.update { it.copy(isFabMenuExpanded = false) } + _navCommandFlow.tryEmit(NavCommand.Navigate(EnterLink)) + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/share/PlaylistShareViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/share/PlaylistShareViewModel.kt new file mode 100644 index 000000000..1b4098e82 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/share/PlaylistShareViewModel.kt @@ -0,0 +1,135 @@ +package world.respect.shared.viewmodel.playlists.mapping.share + +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.jetbrains.compose.resources.getString +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.DataReadyState +import world.respect.datalayer.SchoolDataSource +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.clipboard.SetClipboardStringUseCase +import world.respect.shared.domain.sharelink.CreatePlaylistShareLinkUseCase +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.share_playlist +import world.respect.shared.navigation.EnterLink +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.PlaylistShare +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.viewmodel.app.appstate.AppBarSearchUiState + +data class PlaylistShareUiState( + val playlistTitle: String = "", + val shareUrl: String = "", + val viewPermissionIndex: Int = VIEW_PERMISSION_DEFAULT_INDEX, + val editPermissionIndex: Int = EDIT_PERMISSION_DEFAULT_INDEX, +) { + companion object { + const val VIEW_PERMISSION_DEFAULT_INDEX = 1 + const val EDIT_PERMISSION_DEFAULT_INDEX = 0 + } +} + +class PlaylistShareViewModel( + savedStateHandle: SavedStateHandle, + private val accountManager: RespectAccountManager, + private val setClipboardStringUseCase: SetClipboardStringUseCase, + private val shareLinkLauncher: LaunchShareLinkUseCase, + private val smsLinkLauncher: LaunchSendSmsUseCase, + private val emailLinkLauncher: LaunchSendEmailUseCase, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val schoolDataSource: SchoolDataSource by inject() + + private val route: PlaylistShare = savedStateHandle.toRoute() + + private val _uiState = MutableStateFlow(PlaylistShareUiState()) + + val uiState = _uiState.asStateFlow() + + init { + _appUiState.update { + it.copy( + title = Res.string.share_playlist.asUiText(), + searchState = AppBarSearchUiState(visible = false), + showBackButton = true, + hideBottomNavigation = true, + userAccountIconVisible = false, + ) + } + + val activeAccount = accountManager.activeAccount + ?: throw IllegalStateException( + "No active account when initializing PlaylistShareViewModel" + ) + val createPlaylistShareLinkUseCase = CreatePlaylistShareLinkUseCase( + schoolUrl = activeAccount.school.self + ) + + val shareUrl = createPlaylistShareLinkUseCase(route.playlistUrl.toString()).toString() + _uiState.update { it.copy(shareUrl = shareUrl) } + + viewModelScope.launch { + schoolDataSource.opdsFeedDataSource.getByUrlAsFlow( + url = route.playlistUrl, + params = DataLoadParams(), + ).collect { result -> + when (result) { + is DataReadyState -> { + _uiState.update { + it.copy(playlistTitle = result.data.metadata.title) + } + } + else -> { /* loading/error handled by app bar loading indicator */ } + } + } + } + } + + fun onViewPermissionChanged(index: Int) { + _uiState.update { it.copy(viewPermissionIndex = index) } + } + + fun onEditPermissionChanged(index: Int) { + _uiState.update { it.copy(editPermissionIndex = index) } + } + + fun onClickCopyLink() { + setClipboardStringUseCase(_uiState.value.shareUrl) + _navCommandFlow.tryEmit(NavCommand.Navigate(EnterLink)) + } + + fun onClickShareLink() { + viewModelScope.launch { + shareLinkLauncher(_uiState.value.shareUrl) + } + } + + fun onClickSendViaSms() { + viewModelScope.launch { + smsLinkLauncher(_uiState.value.shareUrl) + } + } + + fun onClickSendViaEmail() { + viewModelScope.launch { + emailLinkLauncher( + subject = getString(Res.string.share_playlist), + body = _uiState.value.shareUrl, + ) + } + } +} \ No newline at end of file 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..aaf0ba412 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 @@ -8,8 +8,6 @@ import kotlinx.coroutines.flow.update import kotlinx.serialization.json.Json 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.util.ext.asUiText import world.respect.shared.viewmodel.RespectViewModel @@ -40,10 +38,4 @@ class SettingsViewModel( fun onNavigateToLanguage() { // TODO } - - fun onNavigateToMapping() { - _navCommandFlow.tryEmit( - NavCommand.Navigate(CurriculumMappingList) - ) - } } \ No newline at end of file diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/grade1.json b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/grade1.json index e77846479..775aa606c 100644 --- a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/grade1.json +++ b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/grade1.json @@ -1,6 +1,6 @@ { "metadata": { - "title": "Example listing publications" + "title": "Grade 1" }, "links": [ @@ -25,7 +25,11 @@ ] }, "links": [ - {"rel": "self", "href": "lesson001/lesson001.json", "type": "application/opds-publication+json"}, + { + "rel": "self", + "href": "lesson001/lesson001.json", + "type": "application/opds-publication+json" + }, { "rel": "http://opds-spec.org/acquisition/open-access", "href": "http://localhost/opds/case_valid/grade1/lesson001/lesson001.html", @@ -33,7 +37,33 @@ } ], "images": [ - {"href": "lesson001/cover.png", "type": "image/png" } + { "href": "lesson001/cover.png", "type": "image/png" } + ] + }, + + { + "metadata": { + "@type": "http://schema.org/Game", + "title": "Lesson 002", + "author": "Video Lesson", + "identifier": "http://example.app/id/lesson002", + "language": "en", + "modified": "2026-03-11T10:00:00Z" + }, + "links": [ + { + "rel": "self", + "href": "lesson002/lesson002.json", + "type": "application/opds-publication+json" + }, + { + "rel": "http://opds-spec.org/acquisition/open-access", + "href": "http://localhost/opds/case_valid/grade1/lesson002/lesson002.html", + "type": "text/html" + } + ], + "images": [ + { "href": "lesson002/cover.png", "type": "image/png" } ] } ] diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/cover.png b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/cover.png new file mode 100644 index 000000000..f9a625b1c Binary files /dev/null and b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/cover.png differ diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.html b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.html new file mode 100644 index 000000000..d3850707a --- /dev/null +++ b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.html @@ -0,0 +1,17 @@ + + + + + Lesson 2 + + + + +

Lesson 2 Video

+ + + + + \ No newline at end of file diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.json b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.json new file mode 100644 index 000000000..ed6bc3c83 --- /dev/null +++ b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/lesson002.json @@ -0,0 +1,31 @@ +{ + "metadata": { + "@type": "http://schema.org/Game", + "title": "Lesson 002", + "author": "Video Lesson", + "identifier": "https://example.app/id/lesson002", + "language": "en", + "modified": "2026-03-11T10:00:00Z" + }, + "links": [ + { + "rel": "self", + "href": "$TESTBASEURL/grade1/lesson002/lesson002.json", + "type": "application/opds-publication+json" + }, + { + "rel": "http://opds-spec.org/acquisition/open-access", + "href": "lesson002.html", + "type": "text/html" + } + ], + "images": [ + { "href": "cover.png", "type": "image/png" } + ], + "resources": [ + { + "href": "video.mp4", + "type": "video/mp4" + } + ] +} \ No newline at end of file diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/video.mp4 b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/video.mp4 new file mode 100644 index 000000000..694b4038e Binary files /dev/null and b/respect-server/src/main/resources/http/respect-ds/case_valid/grade1/lesson002/video.mp4 differ diff --git a/respect-server/src/main/resources/http/respect-ds/case_valid/index.json b/respect-server/src/main/resources/http/respect-ds/case_valid/index.json index 9e8f35c36..915ec4ccd 100644 --- a/respect-server/src/main/resources/http/respect-ds/case_valid/index.json +++ b/respect-server/src/main/resources/http/respect-ds/case_valid/index.json @@ -1,6 +1,6 @@ { "metadata": { - "title": "Main Menu" + "title": "My App" }, "links": [