diff --git a/.maestro/flows/001_001_invite_using_invite_code_test.yaml b/.maestro/flows/001_001_invite_using_invite_code_test.yaml new file mode 100644 index 000000000..113c7eee3 --- /dev/null +++ b/.maestro/flows/001_001_invite_using_invite_code_test.yaml @@ -0,0 +1,410 @@ +appId: world.respect.app +onFlowStart: + - clearState: world.respect.app + - runScript: + file: "scripts/school_init.js" + env: + TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} + SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} + DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} + SCHOOL_URL: ${SCHOOL_URL} + SCHOOL_NAME: ${SCHOOL_NAME} + URL_SUBSTITUTION: ${URL_SUBSTITUTION} + NAME: "001_001_invite_using_invite_code_test" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" + +--- + +# A) Admin add new class +- runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: + id: "app_title" + text: "Home" +- tapOn: "Classes" +- assertVisible: + id: "app_title" + text: "Classes" +- tapOn: + id: "floating_action_button" # +Class button +- assertVisible: + id: "app_title" + text: "Add class" +- tapOn: "Save" +- assertVisible: "Required field" +- tapOn: "Class name*" +- inputText: "New Class" +- tapOn: "Description" +- inputText: "New Course" +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "New Class" + +# B) Admin copy invite code to the teacher +- tapOn: "Add Teacher" +- copyTextFrom: + id: "invite_code" + +# C) Teacher sign-up via invite code to the class +- clearState: world.respect.app +- 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: "I have an invite code" +- tapOn: "Invite code" +- pasteText +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "New Class" +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Next" +- assertVisible: "Required field" # Name is mandatory field +- tapOn: "Your name*" +- inputText: "Teacher User" +- tapOn: "Next" +- assertVisible: "Required field" # Gender is mandatory field +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Next" +- assertVisible: "Required field" # DOB is mandatory field +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.futureDate} +- tapOn: "Next" +- assertVisible: "Date of Birth cannot be in the future." +- runFlow: + file: "subflows/erase_text.yaml" + env: + TEXT: "Your date of birth*" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- extendedWaitUntil: + visible: "teacheruser" #username + timeout: 10000 # Timeout in milliseconds +- tapOn: "Next" +# passkeys are not supported on the school domain being used for end to end test so signup flow is bit different +- assertVisible: "Password*" +- tapOn: "Sign-up" +- assertVisible: "Required field" # Password is mandatory field +- tapOn: "Password*" +- inputText: "testt1" +- tapOn: "Sign-up" +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + +# D) Admin approve teacher's request to join the class +- clearState: world.respect.app +- runFlow: "subflows/school_admin_login_flow.yaml" +- tapOn: "Classes" +- assertVisible: + id: "app_title" + text: "Classes" +- tapOn: "New Class" +- assertVisible: "Pending requests.*" +- assertVisible: "Teacher User.*" +- tapOn: "Accept Invite" + +# E) Teacher login to the class and share invite code to Student +- clearState: world.respect.app +- 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" +- tapOn: + id : "password" +- inputText: "testt1" +- tapOn: "Login" +- assertVisible: "Home" +- tapOn: "Classes" +- assertVisible: + id: "app_title" + text: "Classes" +- assertVisible: "New Class" +- tapOn: "New Class" +- assertVisible: "Teacher User.*" +- tapOn: "Add Student" +- copyTextFrom: + id: "invite_code" + +# G) Student sign-up via invite code to the class +- clearState: world.respect.app +- 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: "I have an invite code" +- tapOn: "Invite code" +- pasteText +- tapOn: "Next" +- assertVisible: + id: "app_title" +- tapOn: "I’m a Student" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "Student User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateC} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "studentuser" #username +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "tests1" +- tapOn: "Sign-up" +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + +# H) Teacher approve student's request to join the class +- clearState: world.respect.app +- 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" +- tapOn: + id : "password" +- inputText: "testt1" +- tapOn: "Login" +- assertVisible: "Home" +- tapOn: "Classes" +- assertVisible: + id: "app_title" + text: "Classes" +- tapOn: "New Class" +- assertVisible: "Pending requests.*" +- assertVisible: "Student User.*" +- tapOn: "Accept Invite" +- tapOn: "Add Student" +- copyTextFrom: + id: "invite_code" + +# I) Student login to the class +- clearState: world.respect.app +- 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: "studentuser" +- tapOn: + id : "password" +- inputText: "tests1" +- tapOn: "Login" +- assertVisible: "Home" +- tapOn: "Classes" +- assertVisible: + id: "app_title" + text: "Classes" +- assertVisible: "New Class" +- tapOn: "New Class" +- assertVisible: "Student User.*" + +# G) Parent sign-up their child to the class using invite code +- clearState: world.respect.app +- 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: "I have an invite code" +- tapOn: "Invite code" +- pasteText +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- tapOn: "I’m a parent" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "Parent User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- extendedWaitUntil: + visible: "parentuser" #username + timeout: 10000 +- tapOn: "Next" +# passkeys are not supported on the school domain being used for end to end test so signup flow is bit different +- tapOn: "Password*" +- inputText: "testp1" +- tapOn: "Sign-up" +- assertVisible: + id: "app_title" + text: "Child Profile" +- tapOn: "Child's name*" +- inputText: "Child User" +- tapOn: "Gender*" +- tapOn: "Male" +- tapOn: "Child's date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateC} +# 4-Jan-2026 Maestro cloud is repeatedly missing below without using retryTapIfNoChange +- tapOn: + text: "Done" + +- repeat: + while: + notVisible: "Waiting for approval" + visible: "Done" + commands: + - tapOn: + text: "Done" + retryTapIfNoChange: false + +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + +# H) Teacher approve student's request to join the class +- clearState: world.respect.app +- 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" +- tapOn: + id : "password" +- inputText: "testt1" +- tapOn: "Login" +- assertVisible: "Home" +- tapOn: "People" +- assertVisible: "Parent User" +- assertVisible: "Child User" +- tapOn: "Classes" +- assertVisible: + id: "app_title" + text: "Classes" +- tapOn: "New Class" +- assertVisible: "Pending requests.*" +- assertVisible: "Child User.*" +- tapOn: "Accept Invite" + +# I) Parent login to the class +- clearState: world.respect.app +- 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: "parentuser" +- tapOn: + id : "password" +- inputText: "testp1" +- hideKeyboard +- tapOn: "Login" +- assertVisible: "Home" +- tapOn: "Classes" +- assertVisible: + id: "app_title" + text: "Classes" +- assertVisible: "New Class" +- tapOn: "New Class" +- assertVisible: "Child User" \ No newline at end of file diff --git a/.maestro/flows/001_002_add_user_direct_test.yaml b/.maestro/flows/001_002_add_user_direct_test.yaml index 4de3433a8..ee528c841 100644 --- a/.maestro/flows/001_002_add_user_direct_test.yaml +++ b/.maestro/flows/001_002_add_user_direct_test.yaml @@ -314,6 +314,22 @@ onFlowComplete: when: visible: "Save password for Respect?" file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: "Home" +- tapOn: "People" +- tapOn: "Test User" +- tapOn: "Manage account" +- assertVisible: "testuser" +- tapOn: "Change" +- tapOn: "Old password*" +- inputText: "test234" +- tapOn: "New password*" +- inputText: "t2" +- tapOn: "Save" +- assertVisible: "Password must be at least 6 characters" +- tapOn: "New password*" +- eraseText +- inputText: "test123" +- tapOn: "Save" - assertVisible: "Apps" - tapOn: "People" - tapOn: "ParentA User" @@ -356,6 +372,23 @@ onFlowComplete: 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: "childuser" +- tapOn: + id : "password" +- inputText: "test123" +- hideKeyboard +- tapOn: "Login" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: "Home" - tapOn: "Scan QR code/badge" - assertVisible: id: "app_title" @@ -371,4 +404,4 @@ onFlowComplete: text: "Apps" - assertVisible: "Assignments" - assertVisible: "Classes" -- assertVisible: "People" \ No newline at end of file +- assertVisible: "People" 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/002_browse_lessons_test.yaml b/.maestro/flows/002_browse_lessons_test.yaml index c939f86e0..44c9a7efb 100644 --- a/.maestro/flows/002_browse_lessons_test.yaml +++ b/.maestro/flows/002_browse_lessons_test.yaml @@ -17,8 +17,13 @@ onFlowComplete: file: "scripts/teardown.js" --- - runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: "Home" +- assertVisible: + id: "app_title" + text: "Home" +- tapOn: "Apps" - tapOn: - id: "floating_action_button" + id: "floating_action_button" # +Add App - tapOn: "Add from Link" - tapOn: "Link*" - inputText: "https://respect.world/respect-ds/case_valid/appmanifest.json" @@ -30,14 +35,10 @@ onFlowComplete: - assertVisible: "Add App" # verify App got added to Apps section - tapOn: "Add App" +- tapOn: "Home" - 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" @@ -48,5 +49,202 @@ onFlowComplete: timeout: 1000 - assertVisible: "Hello World Lesson" - tapOn: "Close" +- runFlow: "subflows/admin_add_class.yaml" +- tapOn: "Home" +- tapOn: "Playlists" +- assertVisible: "All" +- assertVisible: "School Playlists" +- assertVisible: "My Playlists" +- assertVisible: "No playlists yet" +- tapOn: "Playlist" +- assertVisible: "Add new" +- assertVisible: "Add from a link" +- tapOn: + text: "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" +- tapOn: "Section title" +- inputText: "Day 1" +- hideKeyboard +- tapOn: + id: "add_item" +- tapOn: "My app" +- tapOn: "Grade 1" +- tapOn: "Lesson 001" +- assertVisible: + id: "app_title" + text: "Add playlist" +- assertVisible: "Lesson 001" +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Playlist - Grade 1" +- assertVisible: "Day 1" +- tapOn: + id: "expand_collapse_icon_" +- assertVisible: "Lesson 001" +- tapOn: + text: "Edit" + index: 1 # Edit button +- assertVisible: + id: "app_title" + text: "Edit playlist" +- tapOn: "Section" +- tapOn: + text: "Section title" + index: 1 +- inputText: "Day 2" +- hideKeyboard +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Playlist - Grade 1" +- tapOn: "Day 1" +- assertVisible: "Lesson 001" +- assertVisible: "Day 2" +- assertVisible: + id: "share_btn" +- assertVisible: + id: "copy_btn" +- assertVisible: + id: "assign_btn" +- assertVisible: + id: "delete_btn" +- tapOn: "Lesson 001" +- assertVisible: "Lesson 001" +- assertVisible: "App name" +- assertVisible: "Open" +- tapOn: "Home" +- tapOn: "Playlists" +- assertVisible: "Playlist - Grade 1" +- assertVisible: "2 sections, 1 items" +- assertVisible: "Created by: Admin" +- tapOn: "My Playlist" +- assertVisible: "Playlist - Grade 1" +- tapOn: "School Playlist" +- assertVisible: "No playlists yet" +- tapOn: "All" +- assertVisible: "Playlist - Grade 1" +- tapOn: "Playlist - Grade 1" +- assertVisible: + id: "app_title" + text: "Playlist - Grade 1" + +# Assigning a playlist to a assignment +- tapOn: + id: "assign_btn" +- assertVisible: + id: "app_title" + text: "Add assignment" +- tapOn: "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: "Lesson 001" +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Homework 1" +- assertVisible: "Lesson 001" +- tapOn: "Home" +- tapOn: "Playlists" +- tapOn: "Playlist - Grade 1" +- assertVisible: + id: "share_btn" +- assertVisible: + id: "app_title" + text: "Share" +- assertVisible: ${output.futureDate}playlist/uid +- tapOn: "Who can view" +- tapOn: "Anyone with the link" +- tapOn: "Who can edit" +- tapOn: "Teacher/admin in my school" +- assertVisible: "Share link" +- assertVisible: "Copy link" +- assertVisible: "Send link via SMS" +- assertVisible: "Send link via email" +- back +- tapOn: + id: "copy_btn" +- assertVisible: "Make a copy" +- assertVisible: "copy the Playlist - Grade 1" +- tapOn: "Copy" +- assertVisible: + id: "app_title" + text: "copy the Playlist - Grade 1" +- assertVisible: "Day 1" +- assertVisible: "Day 2" +# Adding playlist to an assignment +- tapOn: "Assignments" +- tapOn: + id: "floating_action_button" # +Assignment button +- assertVisible: + id: "app_title" + text: "Add assignment" +- tapOn: "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" + text: "Add task to assignment" +- tapOn: "Playlists" +- tapOn: "copy the Playlist - Grade 1" +- assertVisible: + id: "app_title" + text: "copy the Playlist - Grade 1" +- assertVisible: "Select all" +- assertVisible: "Select none" +- tapOn: "Select all" +- assertVisible: + id: "check_box" + checked: true +- tapOn: "Select none" +- assertVisible: + id: "check_box" + checked: false +- tapOn: + id: "check_box" + index: 1 +- tapOn: "Add 1 task to assignment" +- assertVisible: + id: "app_title" + text: "Add assignment" +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: "Homework 2" +- assertVisible: "Lesson 001" +- back +- assertVisible: "Homework 2" 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..c44e543eb 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 @@ -41,7 +41,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" @@ -68,8 +68,6 @@ onFlowComplete: file: "scripts/setDate.js" - inputText: ${output.currentTime} - tapOn: "Lesson/assessment" -- assertVisible: - id: "app_title" - tapOn: "My app" - tapOn: "Grade 1" - tapOn: "Lesson 001" 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 140ce1387..67c4dc066 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/.maestro/flows/subflows/school_admin_login_flow.yaml b/.maestro/flows/subflows/school_admin_login_flow.yaml index bedde5007..37ccbabd4 100644 --- a/.maestro/flows/subflows/school_admin_login_flow.yaml +++ b/.maestro/flows/subflows/school_admin_login_flow.yaml @@ -23,4 +23,4 @@ appId: world.respect.app when: visible: "Save password for Respect?" file: "save_password_prompt_cancel.yaml" -- assertVisible: "Apps" + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7705c811..4af04725c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,6 +68,8 @@ androidx-preference = "1.2.1" androidx-biometric = "1.4.0-alpha02" buildconfig-plugin = "6.0.6" +qr-kit = "3.1.3" + [libraries] argparse4j = { module = "net.sourceforge.argparse4j:argparse4j", version.ref = "argparse4j" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil3" } @@ -171,7 +173,7 @@ acra-http = { module = "ch.acra:acra-http", version.ref = "acra" } acra-core = { module = "ch.acra:acra-core", version.ref = "acra" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } - +qr-kit = { module = "network.chaintech:qr-kit", version.ref = "qr-kit" } diff --git a/respect-app-compose/build.gradle.kts b/respect-app-compose/build.gradle.kts index f41b40f62..ab33a7237 100644 --- a/respect-app-compose/build.gradle.kts +++ b/respect-app-compose/build.gradle.kts @@ -144,6 +144,7 @@ kotlin { implementation(libs.kotlinx.io.core) implementation(libs.androidx.paging.compose) implementation(libs.reorderable) + implementation(libs.qr.kit) implementation(libs.kscan) implementation(libs.qrose) } diff --git a/respect-app-compose/src/androidMain/AndroidManifest.xml b/respect-app-compose/src/androidMain/AndroidManifest.xml index 49ec9ec13..07b64614f 100644 --- a/respect-app-compose/src/androidMain/AndroidManifest.xml +++ b/respect-app-compose/src/androidMain/AndroidManifest.xml @@ -1,14 +1,10 @@ - - + tools:overrideLibrary="org.qrcodeScanner, network.chaintech.cmpimagepickncrop, org.ncgroup.kscan" /> diff --git a/respect-app-compose/src/androidMain/kotlin/world/respect/AbstractAppActivity.kt b/respect-app-compose/src/androidMain/kotlin/world/respect/AbstractAppActivity.kt index fbc12a42f..491bc342e 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AbstractAppActivity.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AbstractAppActivity.kt @@ -105,4 +105,4 @@ abstract class AbstractAppActivity : AppCompatActivity() { Napier.w("Exception handling link", e) } } -} +} \ No newline at end of file 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 d30a17c45..9b6a76e65 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 @@ -103,6 +102,7 @@ import world.respect.shared.domain.account.RespectAccountSchoolScopeLink import world.respect.shared.domain.account.RespectTokenManager import world.respect.shared.domain.account.child.AddChildAccountUseCase import world.respect.shared.domain.account.authenticatepassword.AuthenticatePasswordUseCase +import world.respect.shared.domain.account.authenticatepassword.AuthenticateQrBadgeUseCase import world.respect.shared.domain.account.child.AddChildAccountUseCaseClient import world.respect.shared.domain.account.gettokenanduser.GetTokenAndUserProfileWithCredentialUseCase import world.respect.shared.domain.account.gettokenanduser.GetTokenAndUserProfileWithCredentialUseCaseClient @@ -111,7 +111,6 @@ import world.respect.shared.domain.account.invite.GetInviteInfoUseCase import world.respect.shared.domain.account.invite.GetInviteInfoUseCaseClient import world.respect.shared.domain.account.invite.RedeemInviteUseCase import world.respect.shared.domain.account.invite.RedeemInviteUseCaseClient -import world.respect.shared.domain.navigation.onaccountcreated.NavigateOnAccountCreatedUseCase import world.respect.shared.domain.account.passkey.EncodeUserHandleUseCaseImpl import world.respect.shared.domain.account.passkey.GetPasskeyProviderInfoUseCaseImpl import world.respect.shared.domain.account.passkey.GetActivePersonPasskeysClient @@ -131,12 +130,14 @@ import world.respect.shared.domain.account.validatepassword.ValidatePasswordUseC import world.respect.shared.domain.account.validateqrbadge.ValidateQrCodeUseCase import world.respect.shared.domain.appversioninfo.GetAppVersionInfoUseCase import world.respect.shared.domain.appversioninfo.GetAppVersionInfoUseCaseAndroid +import world.respect.shared.domain.biometric.BiometricAuthUseCase +import world.respect.shared.domain.biometric.BiometricAuthUseCaseAndroidImpl import world.respect.shared.domain.clipboard.SetClipboardStringUseCase import world.respect.shared.domain.clipboard.SetClipboardStringUseCaseAndroid +import world.respect.shared.domain.createclass.CreateClassUseCase import world.respect.shared.domain.createlink.CreateInviteLinkUseCase import world.respect.shared.domain.devmode.GetDevModeEnabledUseCase import world.respect.shared.domain.devmode.SetDevModeEnabledUseCase -import world.respect.shared.domain.school.LaunchCustomTabUseCaseAndroid import world.respect.shared.domain.getdeviceinfo.GetDeviceInfoUseCase import world.respect.shared.domain.getdeviceinfo.GetDeviceInfoUseCaseAndroid import world.respect.shared.domain.getwarnings.GetWarningsUseCase @@ -144,7 +145,13 @@ import world.respect.shared.domain.getwarnings.GetWarningsUseCaseAndroid import world.respect.shared.domain.launchapp.LaunchAppUseCase import world.respect.shared.domain.launchapp.LaunchAppUseCaseAndroid import world.respect.shared.domain.navigation.deeplink.CustomDeepLinkToUrlUseCase +import world.respect.shared.domain.navigation.deeplink.InitDeepLinkUriProviderUseCase +import world.respect.shared.domain.navigation.deeplink.InitDeepLinkUriProviderUseCaseAndroid import world.respect.shared.domain.navigation.deeplink.UrlToCustomDeepLinkUseCase +import world.respect.shared.domain.navigation.deferreddeeplink.GetDeferredDeepLinkUseCase +import world.respect.shared.domain.navigation.deferreddeeplink.GetDeferredDeepLinkUseCaseAndroid +import world.respect.shared.domain.navigation.onaccountcreated.NavigateOnAccountCreatedUseCase +import world.respect.shared.domain.navigation.onappstart.NavigateOnAppStartUseCase import world.respect.shared.domain.onboarding.ShouldShowOnboardingUseCase import world.respect.shared.domain.permissions.CheckSchoolPermissionsUseCase import world.respect.shared.domain.phonenumber.OnClickPhoneNumUseCase @@ -155,8 +162,15 @@ import world.respect.shared.domain.report.formatter.CreateGraphFormatterUseCase import world.respect.shared.domain.report.query.MockRunReportUseCaseClientImpl import world.respect.shared.domain.report.query.RunReportUseCase import world.respect.shared.domain.school.LaunchCustomTabUseCase +import world.respect.shared.domain.school.LaunchCustomTabUseCaseAndroid import world.respect.shared.domain.school.RespectSchoolPath import world.respect.shared.domain.school.SchoolPrimaryKeyGenerator +import world.respect.shared.domain.sendinvite.LaunchSendEmailAndroid +import world.respect.shared.domain.sendinvite.LaunchSendSmsAndroid +import world.respect.shared.domain.sendinvite.LaunchShareLinkAndroid +import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase +import world.respect.shared.domain.sharelink.LaunchSendSmsUseCase +import world.respect.shared.domain.sharelink.LaunchShareLinkUseCase import world.respect.shared.domain.storage.CachePathsProviderAndroid import world.respect.shared.domain.storage.GetAndroidSdCardDirUseCase import world.respect.shared.domain.storage.GetOfflineStorageOptionsUseCaseAndroid @@ -165,6 +179,7 @@ import world.respect.shared.domain.usagereporting.GetUsageReportingEnabledUseCas import world.respect.shared.domain.usagereporting.GetUsageReportingEnabledUseCaseAndroid import world.respect.shared.domain.usagereporting.SetUsageReportingEnabledUseCase import world.respect.shared.domain.usagereporting.SetUsageReportingEnabledUseCaseAndroid +import world.respect.shared.domain.urltonavcommand.ResolveUrlToNavCommandUseCase import world.respect.shared.domain.validateemail.ValidateEmailUseCase import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.app_name @@ -182,19 +197,19 @@ import world.respect.shared.viewmodel.apps.list.AppListViewModel import world.respect.shared.viewmodel.assignment.detail.AssignmentDetailViewModel import world.respect.shared.viewmodel.assignment.edit.AssignmentEditViewModel import world.respect.shared.viewmodel.assignment.list.AssignmentListViewModel -import world.respect.shared.viewmodel.enrollment.list.EnrollmentListViewModel -import world.respect.shared.viewmodel.enrollment.edit.EnrollmentEditViewModel import world.respect.shared.viewmodel.clazz.detail.ClazzDetailViewModel import world.respect.shared.viewmodel.clazz.edit.ClazzEditViewModel import world.respect.shared.viewmodel.clazz.list.ClazzListViewModel +import world.respect.shared.viewmodel.enrollment.edit.EnrollmentEditViewModel +import world.respect.shared.viewmodel.enrollment.list.EnrollmentListViewModel import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel -import world.respect.shared.viewmodel.manageuser.accountlist.AccountListViewModel import world.respect.shared.viewmodel.manageuser.acceptinvite.AcceptInviteViewModel +import world.respect.shared.viewmodel.manageuser.accountlist.AccountListViewModel +import world.respect.shared.viewmodel.manageuser.enterinvitecode.EnterInviteCodeViewModel import world.respect.shared.viewmodel.manageuser.enterpasswordsignup.EnterPasswordSignupViewModel import world.respect.shared.viewmodel.manageuser.getstarted.GetStartedViewModel import world.respect.shared.viewmodel.manageuser.howpasskeywork.HowPasskeyWorksViewModel -import world.respect.shared.viewmodel.manageuser.enterinvitecode.EnterInviteCodeViewModel import world.respect.shared.viewmodel.manageuser.login.LoginViewModel import world.respect.shared.viewmodel.manageuser.otheroption.OtherOptionsViewModel import world.respect.shared.viewmodel.manageuser.otheroptionsignup.OtherOptionsSignupViewModel @@ -206,18 +221,17 @@ import world.respect.shared.viewmodel.onboarding.OnboardingViewModel import world.respect.shared.viewmodel.person.changepassword.ChangePasswordViewModel import world.respect.shared.viewmodel.person.copycode.CopyInviteCodeViewModel import world.respect.shared.viewmodel.person.detail.PersonDetailViewModel -import world.respect.shared.domain.biometric.BiometricAuthUseCase -import world.respect.shared.domain.biometric.BiometricAuthUseCaseAndroidImpl -import world.respect.shared.domain.createclass.CreateClassUseCase -import world.respect.shared.domain.navigation.deferreddeeplink.GetDeferredDeepLinkUseCase -import world.respect.shared.domain.navigation.deeplink.InitDeepLinkUriProviderUseCase -import world.respect.shared.domain.navigation.deeplink.InitDeepLinkUriProviderUseCaseAndroid import world.respect.shared.viewmodel.person.edit.PersonEditViewModel -import world.respect.shared.viewmodel.person.list.PersonListViewModel import world.respect.shared.viewmodel.person.inviteperson.InvitePersonViewModel -import world.respect.shared.viewmodel.person.qrcode.InviteQrViewModel +import world.respect.shared.viewmodel.person.list.PersonListViewModel import world.respect.shared.viewmodel.person.manageaccount.ManageAccountViewModel import world.respect.shared.viewmodel.person.passkeylist.PasskeyListViewModel +import world.respect.shared.viewmodel.person.qrcode.InviteQrViewModel +import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccountSetPasswordViewModel +import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccountSetUserNameViewModel +import world.respect.shared.viewmodel.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.report.ReportViewModel import world.respect.shared.viewmodel.report.detail.ReportDetailViewModel import world.respect.shared.viewmodel.report.edit.ReportEditViewModel @@ -227,26 +241,12 @@ import world.respect.shared.viewmodel.report.indictor.edit.IndicatorEditViewMode import world.respect.shared.viewmodel.report.indictor.list.IndicatorListViewModel import world.respect.shared.viewmodel.report.list.ReportListViewModel import world.respect.shared.viewmodel.report.list.ReportTemplateListViewModel -import world.respect.sharedse.domain.account.authenticatepassword.AuthenticatePasswordUseCaseDbImpl -import java.io.File -import world.respect.shared.viewmodel.settings.SettingsViewModel -import world.respect.shared.viewmodel.curriculum.mapping.list.CurriculumMappingListViewModel -import world.respect.shared.viewmodel.curriculum.mapping.edit.CurriculumMappingEditViewModel -import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccountSetPasswordViewModel -import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccountSetUserNameViewModel +import world.respect.shared.viewmodel.scanqrcode.ScanQRCodeViewModel import world.respect.shared.viewmodel.schooldirectory.edit.SchoolDirectoryEditViewModel import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryListViewModel -import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase -import world.respect.shared.domain.sharelink.LaunchShareLinkUseCase -import world.respect.shared.domain.sharelink.LaunchSendSmsUseCase -import world.respect.shared.domain.sendinvite.LaunchSendSmsAndroid -import world.respect.shared.domain.sendinvite.LaunchSendEmailAndroid -import world.respect.shared.domain.sendinvite.LaunchShareLinkAndroid -import world.respect.shared.domain.urltonavcommand.ResolveUrlToNavCommandUseCase -import world.respect.shared.viewmodel.scanqrcode.ScanQRCodeViewModel -import world.respect.shared.domain.navigation.deferreddeeplink.GetDeferredDeepLinkUseCaseAndroid -import world.respect.shared.domain.navigation.onappstart.NavigateOnAppStartUseCase - +import world.respect.shared.viewmodel.settings.SettingsViewModel +import world.respect.sharedse.domain.account.authenticatepassword.AuthenticatePasswordUseCaseDbImpl +import java.io.File const val SHARED_PREF_SETTINGS_NAME = "respect_settings3_" const val TAG_TMP_DIR = "tmpDir" @@ -270,13 +270,19 @@ val appKoinModule = module { single { XXStringHasherCommonJvm() } + single { LaunchSendSmsAndroid(androidContext()) } + single { LaunchSendEmailAndroid(androidContext()) } + single { + LaunchShareLinkAndroid(androidContext()) + } + single(createdAtStart = true) { GetDeferredDeepLinkUseCaseAndroid( context = androidContext(), @@ -284,9 +290,6 @@ val appKoinModule = module { ) } - single { - LaunchShareLinkAndroid(androidContext()) - } single { XXHashUidNumberMapper(xxStringHasher = get()) } @@ -329,6 +332,7 @@ val appKoinModule = module { appContext = androidContext().applicationContext ) } + viewModelOf(::OnboardingViewModel) viewModelOf(::AppsDetailViewModel) viewModelOf(::AppLauncherViewModel) @@ -359,6 +363,7 @@ val appKoinModule = module { viewModelOf(::PersonListViewModel) viewModelOf(::InvitePersonViewModel) viewModelOf(::CopyInviteCodeViewModel) + viewModelOf(::InviteQrViewModel) viewModelOf(::PersonEditViewModel) viewModelOf(::PersonDetailViewModel) viewModelOf(::ReportDetailViewModel) @@ -371,21 +376,19 @@ val appKoinModule = module { viewModelOf(::IndicatorDetailViewModel) viewModelOf(::SettingsViewModel) viewModelOf(::ScanQRCodeViewModel) - viewModelOf(::CurriculumMappingListViewModel) - viewModelOf(::CurriculumMappingEditViewModel) + viewModelOf(::PlaylistEditViewModel) + viewModelOf(::PlaylistListViewModel) + viewModelOf(::PlaylistShareViewModel) viewModelOf(::CreateAccountSetUserNameViewModel) + viewModelOf(::CreateAccountSetPasswordViewModel) viewModelOf(::ChangePasswordViewModel) viewModelOf(::SchoolDirectoryListViewModel) viewModelOf(::SchoolDirectoryEditViewModel) viewModelOf(::AssignmentListViewModel) viewModelOf(::AssignmentEditViewModel) viewModelOf(::AssignmentDetailViewModel) - viewModelOf(::AssignmentDetailViewModel) viewModelOf(::EnrollmentListViewModel) viewModelOf(::EnrollmentEditViewModel) - viewModelOf(::InviteQrViewModel) - viewModelOf(::CreateAccountSetPasswordViewModel) - single { GetOfflineStorageOptionsUseCaseAndroid( @@ -448,6 +451,7 @@ val appKoinModule = module { okHttpClient = get() ) } + single(named(TAG_TMP_DIR)) { File(androidContext().applicationContext.cacheDir, "tmp").apply { mkdirs() } } @@ -467,6 +471,7 @@ val appKoinModule = module { json = get(), ) } + single { ValidateUsernameUseCase() } @@ -478,6 +483,7 @@ val appKoinModule = module { single { EncodeUserHandleUseCaseImpl() } + single { CreatePublicKeyCredentialRequestOptionsJsonUseCase() } @@ -488,11 +494,13 @@ val appKoinModule = module { createPublicKeyCredentialRequestOptionsJsonUseCase = get() ) } + single { VerifyDomainUseCaseImpl( context = androidApplication() ) } + single { SavePasswordUseCaseAndroidImpl() } @@ -545,6 +553,7 @@ val appKoinModule = module { json = get() ) } + single { XXHasher64FactoryCommonJvm() } @@ -560,9 +569,11 @@ val appKoinModule = module { single { SetClipboardStringUseCaseAndroid(androidContext().applicationContext) } + single { LaunchCustomTabUseCaseAndroid(androidContext().applicationContext) } + single { ShouldShowOnboardingUseCase(settings = get()) } @@ -751,7 +762,6 @@ val appKoinModule = module { ) } - scoped { GetInviteInfoUseCaseClient( schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl, @@ -759,11 +769,13 @@ val appKoinModule = module { httpClient = get(), ) } + scoped { CreateInviteLinkUseCase( schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl, ) } + scoped { UsernameSuggestionUseCaseClient( schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl, @@ -806,6 +818,7 @@ val appKoinModule = module { uidNumberMapper = get(), ) } + scoped { ValidateQrCodeUseCase( schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl @@ -817,7 +830,6 @@ val appKoinModule = module { schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl ) } - } /** @@ -827,7 +839,7 @@ val appKoinModule = module { */ scope { /* Koin doesn't have an onScopeCreated kind of function or event listener. The - * RespectAccount scope is linked ot the SchoolDirectoryEntry scope when + * RespectAccount scope is linked to the SchoolDirectoryEntry scope when * RespectAccountSchoolScopeLink is retrieved. RespectAccountSchoolScopeLink is a root * dependency that all dependencies on RespectAccountScope require. */ @@ -847,7 +859,6 @@ val appKoinModule = module { RespectAccountSchoolScopeLink(accountScopeId.schoolUrl) } - scoped { get().providerFor(id) } @@ -869,12 +880,14 @@ val appKoinModule = module { httpClient = get(), ) } + scoped { RevokePasskeyUseCaseClient( schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl, httpClient = get(), ) } + scoped { EnqueueDrainRemoteWriteQueueUseCaseAndroidImpl( context = androidContext().applicationContext, @@ -1000,15 +1013,17 @@ val appKoinModule = module { scoped { CreateClassUseCase(dataSource = get()) } - } + 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 fb081d7d6..647f4a6a5 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 @@ -1,22 +1,15 @@ package world.respect.app.app +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.runtime.Composable import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.automirrored.filled.LibraryBooks -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.GridView -import androidx.compose.material.icons.filled.ImportContacts -import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -25,10 +18,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -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.automirrored.filled.LibraryBooks +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.ImportContacts +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.compose.rememberNavController @@ -51,14 +52,15 @@ import world.respect.shared.generated.resources.assignments 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.home import world.respect.shared.generated.resources.continue_using_fingerprint_or import world.respect.shared.generated.resources.people import world.respect.shared.navigation.AccountList +import world.respect.shared.navigation.RespectAppLauncher import world.respect.shared.navigation.AssignmentList import world.respect.shared.navigation.ClazzList import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.PersonList -import world.respect.shared.navigation.RespectAppLauncher import world.respect.shared.navigation.RespectComposeNavController import world.respect.shared.resources.StringResourceUiText import world.respect.shared.resources.StringUiText @@ -83,8 +85,8 @@ private val routeNamePrefix = "world.respect.shared.navigation" val APP_TOP_LEVEL_NAV_ITEMS = listOf( TopNavigationItem( destRoute = RespectAppLauncher(), - icon = Icons.Filled.GridView, - label = Res.string.apps, + icon = Icons.Filled.Home, + label = Res.string.home, routeName = "$routeNamePrefix.RespectAppLauncher", ), TopNavigationItem( @@ -120,12 +122,6 @@ val APP_TOP_LEVEL_NAV_ITEMS_FOR_CHILD = listOf( routeName = "$routeNamePrefix.RespectAppLauncher", ), ) - -/** - * @param activityNavCommandFlow a flow that is received from the activity. When a link is opened - * and the app is already running, the Activity's onNewIntent will be invoked. If the app is - * started cold then InitDeepLinkUriProviderUseCase should be used. - */ @OptIn(ExperimentalLayoutApi::class) @Composable fun App( @@ -313,4 +309,4 @@ fun App( } } -} +} \ No newline at end of file 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 59b3b4972..f64f7a53d 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,8 +17,6 @@ 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.learningunit.detail.LearningUnitDetailScreen @@ -48,6 +46,8 @@ 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.share.PlaylistShareScreen import world.respect.app.view.report.detail.ReportDetailScreen import world.respect.app.view.report.edit.ReportEditScreen import world.respect.app.view.report.filteredit.ReportFilterEditScreen @@ -63,6 +63,7 @@ import world.respect.app.view.settings.SettingsScreenForViewModel import world.respect.app.viewmodel.respectViewModel import world.respect.shared.navigation.AccountList import world.respect.shared.navigation.Acknowledgement +import world.respect.shared.navigation.AcceptInvite import world.respect.shared.navigation.AppsDetail import world.respect.shared.navigation.AssignmentDetail import world.respect.shared.navigation.AssignmentEdit @@ -71,16 +72,14 @@ import world.respect.shared.navigation.ChangePassword import world.respect.shared.navigation.ClazzDetail import world.respect.shared.navigation.ClazzEdit import world.respect.shared.navigation.ClazzList -import world.respect.shared.navigation.AcceptInvite import world.respect.shared.navigation.CopyCode import world.respect.shared.navigation.CreateAccount import world.respect.shared.navigation.CreateAccountSetPassword 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 +import world.respect.shared.navigation.EnterInviteCode import world.respect.shared.navigation.EnterPasswordSignup import world.respect.shared.navigation.GetStartedScreen import world.respect.shared.navigation.HowPasskeyWorks @@ -88,7 +87,7 @@ import world.respect.shared.navigation.IndicatorDetail import world.respect.shared.navigation.IndicatorList import world.respect.shared.navigation.IndictorEdit import world.respect.shared.navigation.InvitePerson -import world.respect.shared.navigation.EnterInviteCode +import world.respect.shared.navigation.JoinClazzWithCode import world.respect.shared.navigation.LearningUnitDetail import world.respect.shared.navigation.LearningUnitList import world.respect.shared.navigation.LoginScreen @@ -100,6 +99,8 @@ 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.PlaylistEdit +import world.respect.shared.navigation.PlaylistShare import world.respect.shared.navigation.QrCode import world.respect.shared.navigation.Report import world.respect.shared.navigation.ReportDetail @@ -125,8 +126,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 @@ -144,6 +143,8 @@ import world.respect.shared.viewmodel.manageuser.signup.CreateAccountViewModel import world.respect.shared.viewmodel.manageuser.termsandcondition.TermsAndConditionViewModel import world.respect.shared.viewmodel.manageuser.waitingforapproval.WaitingForApprovalViewModel import world.respect.shared.viewmodel.onboarding.OnboardingViewModel +import world.respect.shared.viewmodel.playlists.mapping.edit.PlaylistEditViewModel +import world.respect.shared.viewmodel.playlists.mapping.share.PlaylistShareViewModel import world.respect.shared.viewmodel.report.detail.ReportDetailViewModel import world.respect.shared.viewmodel.report.edit.ReportEditViewModel import world.respect.shared.viewmodel.report.filteredit.ReportFilterEditViewModel @@ -156,7 +157,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, @@ -166,7 +166,6 @@ fun AppNavHost( onSetAppUiState: (AppUiState) -> Unit, modifier: Modifier, ) { - NavHost( navController = navController, startDestination = Acknowledgement(), @@ -180,14 +179,14 @@ fun AppNavHost( AcknowledgementScreen(viewModel) } - composable{ + composable { val viewModel: OnboardingViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - OnboardingScreen(viewModel) } + composable { val viewModel: LoginViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -308,6 +307,7 @@ fun AppNavHost( ) ReportDetailScreen(navController = navController, viewModel = viewModel) } + composable { val viewModel: ReportEditViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -315,6 +315,7 @@ fun AppNavHost( ) ReportEditScreen(viewModel = viewModel) } + composable { val viewModel: ReportListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -322,6 +323,7 @@ fun AppNavHost( ) ReportListScreen(viewModel = viewModel) } + composable { val viewModel: ReportTemplateListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -329,6 +331,7 @@ fun AppNavHost( ) ReportTemplateListScreen(viewModel = viewModel) } + composable { val viewModel: IndicatorEditViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -336,6 +339,7 @@ fun AppNavHost( ) IndictorEditScreen(viewModel = viewModel) } + composable { val viewModel: ReportFilterEditViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -343,6 +347,7 @@ fun AppNavHost( ) ReportFilterEditScreen(navController = navController, viewModel = viewModel) } + composable { val viewModel: IndicatorListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -350,6 +355,7 @@ fun AppNavHost( ) IndicatorListScreen(viewModel = viewModel) } + composable { val viewModel: IndicatorDetailViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -365,6 +371,7 @@ fun AppNavHost( ) HowPasskeyWorksScreen(viewModel = viewModel) } + composable { val viewModel: AppListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -374,6 +381,7 @@ fun AppNavHost( viewModel = viewModel ) } + composable { val viewModel: EnterLinkViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -521,6 +529,7 @@ fun AppNavHost( ) ) } + composable { PasskeyListScreen( viewModel = respectViewModel( @@ -538,6 +547,7 @@ fun AppNavHost( ) ) } + composable { val viewModel: SettingsViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -547,6 +557,7 @@ fun AppNavHost( viewModel = viewModel ) } + composable { ScanQRCodeScreen( viewModel = respectViewModel( @@ -556,40 +567,39 @@ fun AppNavHost( ) } - composable { - val viewModel: CurriculumMappingListViewModel = respectViewModel( + composable { + val viewModel: PlaylistEditViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - CurriculumMappingListScreenForViewModel( + PlaylistEditScreenForViewModel( viewModel = viewModel ) } - composable { - val viewModel: CurriculumMappingEditViewModel = respectViewModel( + composable { + val viewModel: PlaylistShareViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - CurriculumMappingEditScreenForViewModel( + PlaylistShareScreen( viewModel = viewModel ) } - composable{ + + composable { val viewModel: SchoolDirectoryListViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - SchoolDirectoryListScreen(viewModel) } - composable{ + composable { val viewModel: SchoolDirectoryEditViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, navController = respectNavController ) - SchoolDirectoryEditScreen(viewModel) } @@ -647,7 +657,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 b51d7889d..5e40b804b 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 @@ -3,38 +3,16 @@ package world.respect.app.view.apps.launcher import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.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.LocalSoftwareKeyboardController import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.paging.compose.collectAsLazyPagingItems @@ -44,18 +22,17 @@ import org.jetbrains.compose.resources.stringResource import world.respect.app.app.RespectAsyncImage import world.respect.app.components.respectRememberPager import world.respect.app.components.uiTextStringResource +import world.respect.app.view.playlists.mapping.list.PlaylistListScreen import world.respect.datalayer.DataLoadState import world.respect.datalayer.NoDataLoadedState import world.respect.datalayer.compatibleapps.model.RespectAppManifest import world.respect.datalayer.ext.dataOrNull -import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.empty -import world.respect.shared.generated.resources.empty_list -import world.respect.shared.generated.resources.more_info -import world.respect.shared.generated.resources.remove +import world.respect.shared.generated.resources.* import world.respect.shared.viewmodel.app.appstate.getTitle import world.respect.shared.viewmodel.apps.launcher.AppLauncherUiState import world.respect.shared.viewmodel.apps.launcher.AppLauncherViewModel +import world.respect.shared.viewmodel.playlists.mapping.list.PlaylistListViewModel + @Composable fun AppLauncherScreen( @@ -67,28 +44,68 @@ fun AppLauncherScreen( uiState = uiState, onClickApp = { viewModel.onClickApp(it) }, onClickRemove = { viewModel.onClickRemove(it) }, + onTabSelected = viewModel::onTabSelected, + playlistListViewModel = viewModel.playlistListViewModel, ) } - @Composable fun AppLauncherScreen( uiState: AppLauncherUiState, onClickApp: (DataLoadState) -> Unit, onClickRemove: (DataLoadState) -> Unit, + onTabSelected: (Int) -> Unit, + playlistListViewModel: PlaylistListViewModel, ) { - val pager = respectRememberPager(uiState.apps) - val lazyPagingItems = pager.flow.collectAsLazyPagingItems() + val mainTabs = listOf( + stringResource(Res.string.apps), + stringResource(Res.string.playlists) + ) + + Column( + modifier = Modifier.fillMaxSize() + ) { + TabRow( + selectedTabIndex = uiState.selectedTabIndex, + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + mainTabs.forEachIndexed { index, title -> + Tab( + selected = uiState.selectedTabIndex == index, + onClick = { onTabSelected(index) }, + text = { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + } + ) + } + } - /** - * Maestro end to end tests can be flakey: When running the login sometimes the keyboard stays - * visible even after going from the login screen to the app launcher screen, which then - * hides the bottom navigation buttons. - */ - val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(Unit) { - keyboardController?.hide() + when (uiState.selectedTabIndex) { + 0 -> AppsTabContent( + uiState = uiState, + onClickApp = onClickApp, + onClickRemove = onClickRemove, + ) + 1 -> PlaylistListScreen( + viewModel = playlistListViewModel + ) + } } +} + +@Composable +private fun AppsTabContent( + uiState: AppLauncherUiState, + onClickApp: (DataLoadState) -> Unit, + onClickRemove: (DataLoadState) -> Unit, +) { + val pager = respectRememberPager(uiState.apps) + val lazyPagingItems = pager.flow.collectAsLazyPagingItems() Box( modifier = Modifier.fillMaxSize() @@ -127,7 +144,6 @@ fun AppLauncherScreen( verticalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - items( count = lazyPagingItems.itemCount, key = { index -> @@ -245,4 +261,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..3e3933d2d 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.LibraryAdd import androidx.compose.material3.ListItem import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -46,6 +47,7 @@ import world.respect.datalayer.school.model.Clazz import world.respect.lib.opds.model.findIcons import world.respect.libutil.ext.resolve import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.add_from_playlist import world.respect.shared.generated.resources.assignment_tasks import world.respect.shared.generated.resources.clazz import world.respect.shared.generated.resources.delete @@ -71,6 +73,7 @@ fun AssignmentEditScreen( onClickAddLearningUnit = viewModel::onClickAddLearningUnit, onAssigneeClassSelected = viewModel::onAssigneeClassSelected, onClickRemoveLearningUnit = viewModel::onClickRemoveLearningUnit, + onClickAddFromPlaylists = viewModel::onClickAddFromPlaylists, ) } @@ -83,6 +86,7 @@ fun AssignmentEditScreen( onAssigneeClassSelected: (Clazz) -> Unit, onClickAddLearningUnit: () -> Unit, onClickRemoveLearningUnit: (AssignmentLearningUnitRef) -> Unit, + onClickAddFromPlaylists: () -> Unit, ) { val assignment = uiState.assignment.dataOrNull() val filteredOptions = if(uiState.assigneeText.isNotBlank()) { @@ -115,7 +119,6 @@ fun AssignmentEditScreen( var expanded by remember { mutableStateOf(false) } - //As per https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary#ExposedDropdownMenuBox(kotlin.Boolean,kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1) ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = it } @@ -199,23 +202,25 @@ fun AssignmentEditScreen( text = stringResource(Res.string.assignment_tasks), style = MaterialTheme.typography.titleMedium ) - - ListItem( - modifier = Modifier.fillMaxWidth().clickable { - onClickAddLearningUnit() - }, - leadingContent = { - Icon( - imageVector = Icons.Default.Add, - modifier = Modifier.size(40.dp).padding(8.dp), - contentDescription = "" - ) - }, - headlineContent = { - Text(stringResource(Res.string.lesson_assessment)) - } - ) - + if (uiState.showPlaylistButton) { + ListItem( + modifier = Modifier.fillMaxWidth().clickable { + onClickAddFromPlaylists() + }, + leadingContent = { + Icon( + imageVector = Icons.Default.Add, + modifier = Modifier.size(40.dp).padding(8.dp), + contentDescription = "", + ) + }, + headlineContent = { + Text( + text = stringResource(Res.string.add_from_playlist), + ) + } + ) + } assignment?.learningUnits?.forEach { learningUnit -> val learningUnitInfoFlow = remember( uiState.learningUnitInfoFlow, learningUnit.learningUnitManifestUrl @@ -256,8 +261,5 @@ fun AssignmentEditScreen( }, ) } - - } - } 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/learningunit/detail/LearningUnitDetailScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/detail/LearningUnitDetailScreen.kt index ba44292a5..b4106455b 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/detail/LearningUnitDetailScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/learningunit/detail/LearningUnitDetailScreen.kt @@ -1,55 +1,46 @@ package world.respect.app.view.learningunit.detail +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Android -import androidx.compose.material.icons.filled.NearMe -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.ustadmobile.libcache.PublicationPinState import com.ustadmobile.libuicompose.theme.black import com.ustadmobile.libuicompose.theme.white +import kotlinx.coroutines.flow.Flow import org.jetbrains.compose.resources.stringResource -import world.respect.shared.generated.resources.Res -import world.respect.shared.generated.resources.app_name -import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel -import androidx.compose.ui.graphics.vector.ImageVector -import world.respect.shared.generated.resources.assign -import world.respect.shared.generated.resources.download -import world.respect.shared.generated.resources.open -import world.respect.shared.viewmodel.app.appstate.getTitle -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.ui.layout.ContentScale -import com.ustadmobile.libcache.PublicationPinState import world.respect.app.app.RespectAsyncImage import world.respect.app.components.RespectOfflineItemStatusIcon import world.respect.app.components.RespectQuickActionButton -import world.respect.shared.generated.resources.cancel -import world.respect.shared.generated.resources.downloaded +import world.respect.app.components.defaultItemPadding +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.DataLoadingState +import world.respect.datalayer.ext.dataOrNull +import world.respect.shared.generated.resources.* +import world.respect.shared.viewmodel.app.appstate.getTitle +import world.respect.shared.viewmodel.playlists.mapping.edit.PlaylistSectionUiState +import world.respect.shared.viewmodel.playlists.mapping.model.PlaylistsSectionLink import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailUiState +import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel @Composable fun LearningUnitDetailScreen( @@ -57,44 +48,122 @@ fun LearningUnitDetailScreen( ) { val uiState by viewModel.uiState.collectAsState() - LearningUnitDetailScreen( - uiState = uiState, - onClickOpen = viewModel::onClickOpen, - onClickDownload = viewModel::onClickDownload, - onClickAssign = viewModel::onClickAssign, - ) + if (uiState.showCopyDialog) { + CopyPlaylistDialog( + currentName = uiState.mapping?.title.orEmpty(), + copyDialogName = uiState.copyDialogName, + onNameChanged = viewModel::onCopyDialogNameChanged, + onDismiss = viewModel::onCopyDialogDismiss, + onConfirm = viewModel::onCopyDialogConfirm + ) + } + + if (uiState.mapping != null) { + PlaylistDetailScreen( + uiState = uiState, + onClickLesson = viewModel::onClickLesson, + onClickEdit = viewModel::onClickEdit, + onClickAssign = viewModel::onClickAssign, + onClickAssignSection = viewModel::onClickAssignSection, + onClickShare = viewModel::onClickShare, + onClickCopy = viewModel::onClickCopy, + onClickDelete = viewModel::onClickDelete, + onConfirmSelection = viewModel::onConfirmSelection, + onClickSelectAll = viewModel::onClickSelectAll, + onClickSelectNone = viewModel::onClickSelectNone, + onClickToggleSectionSelection = viewModel::onClickToggleSectionSelection + ) + } else { + SingleLessonDetailScreen( + uiState = uiState, + onClickOpen = viewModel::onClickOpen, + onClickDownload = viewModel::onClickDownload, + onClickAssign = viewModel::onClickAssign, + ) + } } @Composable -fun LearningUnitDetailScreen( +private fun CopyPlaylistDialog( + currentName: String, + copyDialogName: String, + onNameChanged: (String) -> Unit, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(Res.string.make_a_copy), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + OutlinedTextField( + value = copyDialogName, + onValueChange = onNameChanged, + label = { Text(stringResource(Res.string.name)) }, + placeholder = { Text("Copy of $currentName") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(Res.string.cancel), + color = MaterialTheme.colorScheme.primary + ) + } + TextButton(onClick = onConfirm) { + Text( + text = stringResource(Res.string.copy_playlist), + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } +} + +@Composable +private fun SingleLessonDetailScreen( uiState: LearningUnitDetailUiState, onClickOpen: () -> Unit, onClickDownload: () -> Unit, onClickAssign: () -> Unit, ) { - LazyColumn( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - item { ListItem( leadingContent = { val iconUrl = uiState.lessonDetail?.images?.firstOrNull()?.href - iconUrl.also { icon -> - RespectAsyncImage( - uri = icon, - contentDescription = "", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(120.dp) - - ) - } + RespectAsyncImage( + uri = iconUrl, + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier.size(120.dp) + ) }, headlineContent = { Text( @@ -104,8 +173,7 @@ fun LearningUnitDetailScreen( }, supportingContent = { Column( - verticalArrangement = - Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Row( verticalAlignment = Alignment.CenterVertically @@ -127,16 +195,13 @@ fun LearningUnitDetailScreen( Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(Res.string.app_name), - ) + Text(text = stringResource(Res.string.app_name)) } Text( text = uiState.lessonDetail?.metadata?.subtitle ?.getTitle().orEmpty() ) - } } ) @@ -144,9 +209,7 @@ fun LearningUnitDetailScreen( item { Button( - onClick = { - onClickOpen() - }, + onClick = onClickOpen, modifier = Modifier.fillMaxWidth() ) { Text(stringResource(Res.string.open)) @@ -160,15 +223,13 @@ fun LearningUnitDetailScreen( verticalAlignment = Alignment.CenterVertically ) { RespectQuickActionButton( - labelText = when(uiState.pinState.status) { + labelText = when (uiState.pinState.status) { PublicationPinState.Status.IN_PROGRESS -> stringResource(Res.string.cancel) PublicationPinState.Status.READY -> stringResource(Res.string.downloaded) else -> stringResource(Res.string.download) }, iconContent = { - RespectOfflineItemStatusIcon( - state = uiState.pinState, - ) + RespectOfflineItemStatusIcon(state = uiState.pinState) }, onClick = onClickDownload, enabled = uiState.buttonsEnabled, @@ -186,25 +247,437 @@ fun LearningUnitDetailScreen( } @Composable -private fun IconLabel(icon: ImageVector, labelRes: String) { - Column( - horizontalAlignment = Alignment.CenterHorizontally +private fun PlaylistDetailScreen( + uiState: LearningUnitDetailUiState, + onClickLesson: (PlaylistsSectionLink) -> Unit, + onClickEdit: () -> Unit, + onClickAssign: () -> Unit, + onClickAssignSection: (Long) -> Unit, + onClickShare: () -> Unit, + onClickCopy: () -> Unit, + onClickDelete: () -> Unit, + onConfirmSelection: () -> Unit, + onClickSelectAll: () -> Unit, + onClickSelectNone: () -> Unit, + onClickToggleSectionSelection: (Long) -> Unit, +) { + var expandedSections by remember { mutableStateOf(setOf()) } + val mapping = uiState.mapping + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + bottom = if (uiState.isSelectionMode && uiState.selectedLessons.isNotEmpty()) 88.dp else 16.dp + ) + ) { + item("header") { + Card( + modifier = Modifier + .fillMaxWidth() + .defaultItemPadding(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Book, + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + } + + Column(modifier = Modifier.weight(1f)) { + if (!mapping?.description.isNullOrEmpty()) { + Text( + text = mapping.description, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + lineHeight = 18.sp, + ) + } + + if (mapping?.subject != null || mapping?.grade != null || mapping?.language != null) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + mapping.subject?.let { subject -> + Text( + text = subject.name.getTitle(), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + mapping.grade?.let { grade -> + Text( + text = grade, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + mapping.language?.let { language -> + Text( + text = language, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + if (uiState.isSelectionMode && uiState.selectedLessons.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "${uiState.selectedLessons.size} lessons selected", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + } + } + + if (!uiState.isSelectionMode) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ActionButton( + icon = Icons.Default.Share, + label = stringResource(Res.string.share), + onClick = onClickShare, + modifier = Modifier.testTag("share_btn") + ) + ActionButton( + icon = Icons.Default.ContentCopy, + label = stringResource(Res.string.copy_list), + onClick = onClickCopy, + modifier = Modifier.testTag("copy_btn") + ) + ActionButton( + icon = Icons.Default.Task, + label = stringResource(Res.string.assign), + onClick = onClickAssign, + modifier = Modifier.testTag("assign_btn") + ) + ActionButton( + icon = Icons.Default.Delete, + label = stringResource(Res.string.delete), + onClick = onClickDelete, + modifier = Modifier.testTag("delete_btn") + ) + } + } + } + } + } + + if (uiState.isSelectionMode) { + item("selection_controls") { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultItemPadding(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onClickSelectAll, + modifier = Modifier.testTag("select_all_btn"), + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + ) { + Text( + text = stringResource(Res.string.select_all), + fontSize = 14.sp + ) + } + + OutlinedButton( + onClick = onClickSelectNone, + modifier = Modifier.testTag("select_none_btn"), + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + ) { + Text( + text = stringResource(Res.string.select_none), + fontSize = 14.sp + ) + } + } + } + } + + mapping?.sections?.forEachIndexed { sectionIndex, section -> + item(key = "section_${section.uid}") { + val isExpanded = expandedSections.contains(section.uid) + val sectionLessons = section.items + val allSectionLessonsSelected = sectionLessons.all { + uiState.selectedLessons.contains(it) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + expandedSections = if (isExpanded) { + expandedSections - section.uid + } else { + expandedSections + section.uid + } + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (uiState.isSelectionMode) { + Checkbox( + checked = allSectionLessonsSelected, + onCheckedChange = { onClickToggleSectionSelection(section.uid) }, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + + Text( + text = section.title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + + if (!uiState.isSelectionMode) { + IconButton( + onClick = { onClickAssignSection(section.uid) }, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Default.Task, + contentDescription = stringResource(Res.string.assign), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + IconButton( + onClick = { + expandedSections = if (isExpanded) { + expandedSections - section.uid + } else { + expandedSections + section.uid + } + }, + modifier = Modifier.testTag("expand_collapse_icon_") + ) { + Icon( + if (isExpanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + } + } + + if (expandedSections.contains(section.uid)) { + itemsIndexed( + items = section.items, + key = { linkIndex, _ -> "lesson_${section.uid}_${linkIndex}" } + ) { _, link -> + LessonListItem( + link = link, + sectionLinkUiState = uiState.sectionLinkUiState, + onClickLesson = onClickLesson, + isSelectionMode = uiState.isSelectionMode, + isSelected = uiState.selectedLessons.contains(link) + ) + } + } + } + } + + if (uiState.isSelectionMode && uiState.selectedLessons.isNotEmpty()) { + Button( + onClick = onConfirmSelection, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text( + text = stringResource( + Res.string.add_tasks_to_assignment, + uiState.selectedLessons.size + ), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } else if (uiState.showEditButton) { + FloatingActionButton( + onClick = onClickEdit, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Filled.Edit, + contentDescription = stringResource(Res.string.edit), + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(Res.string.edit), + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} + +@Composable +private fun LessonListItem( + link: PlaylistsSectionLink, + sectionLinkUiState: (PlaylistsSectionLink) -> Flow>, + onClickLesson: (PlaylistsSectionLink) -> Unit, + isSelectionMode: Boolean = false, + isSelected: Boolean = false +) { + val stateFlow = remember(link.href) { + sectionLinkUiState(link) + } + val linkUiState by stateFlow.collectAsState(initial = DataLoadingState()) + val linkData = linkUiState.dataOrNull() + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClickLesson(link) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) + if (isSelectionMode) { + Icon( + imageVector = if (isSelected) Icons.Default.CheckBox + else Icons.Default.CheckBoxOutlineBlank, + contentDescription = null, + tint = if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } else { + linkData?.icon?.let { iconUrl -> + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(4.dp)) + ) { + RespectAsyncImage( + uri = iconUrl.toString(), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } ?: run { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Android, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } - Spacer( - modifier = Modifier - .height(4.dp) - ) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = linkData?.title ?: link.title.orEmpty(), + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + if (linkData?.subtitle?.isNotEmpty() == true) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = linkData.subtitle, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ActionButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton( + onClick = onClick, + modifier = modifier.size(40.dp), + enabled = enabled + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Text( - text = labelRes + text = label, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 12.sp ) - } -} +} \ 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..bf99e6f03 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/edit/PlaylistEditScreen.kt @@ -0,0 +1,580 @@ +package world.respect.app.view.playlists.mapping.edit + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +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.ArrowDropDown +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.draw.alpha +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.lib.opds.model.ReadiumSubjectObject +import world.respect.shared.generated.resources.* +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.app.appstate.getTitle +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.PlaylistSectionUiState +import world.respect.shared.viewmodel.playlists.mapping.model.PlaylistsSection +import world.respect.shared.viewmodel.playlists.mapping.model.PlaylistsSectionLink + +@Composable +fun PlaylistEditScreenForViewModel( + viewModel: PlaylistEditViewModel +) { + val uiState by viewModel.uiState.collectAsState() + + PlaylistEditScreen( + uiState = uiState, + sectionLinkUiState = viewModel::sectionLinkUiStateFor, + onTitleChanged = viewModel::onTitleChanged, + onDescriptionChanged = viewModel::onDescriptionChanged, + onSubjectSelected = viewModel::onSubjectSelected, + onGradeSelected = viewModel::onGradeSelected, + onClickAddSection = viewModel::onClickAddSection, + onClickRemoveSection = viewModel::onClickRemoveSection, + onSectionTitleChanged = viewModel::onSectionTitleChanged, + onSectionMoved = viewModel::onSectionMoved, + onClickAddLesson = viewModel::onClickAddLesson, + onClickRemoveLesson = viewModel::onClickRemoveLesson, + onLessonMovedBetweenSections = viewModel::onLessonMovedBetweenSections, + onClickLesson = viewModel::onClickLesson, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaylistEditScreen( + uiState: PlaylistEditUiState = PlaylistEditUiState(), + sectionLinkUiState: (PlaylistsSectionLink) -> Flow>, + onTitleChanged: (String) -> Unit = {}, + onDescriptionChanged: (String) -> Unit = {}, + onSubjectSelected: (ReadiumSubjectObject?) -> Unit = {}, + onGradeSelected: (String?) -> Unit = {}, + onClickAddSection: () -> Unit = {}, + onClickRemoveSection: (Int) -> Unit = {}, + onSectionTitleChanged: (Int, String) -> Unit = { _, _ -> }, + onSectionMoved: (Int, Int) -> Unit = { _, _ -> }, + onClickAddLesson: (Int) -> Unit = {}, + onClickRemoveLesson: (Int, Int) -> Unit = { _, _ -> }, + onLessonMovedBetweenSections: (Int, Int, Int, Int) -> Unit = { _, _, _, _ -> }, + onClickLesson: (PlaylistsSectionLink) -> Unit = {}, +) { + val haptic = LocalHapticFeedback.current + val lazyListState = rememberLazyListState() + var draggingSectionIndex by remember { mutableStateOf(null) } + var isDraggingAnySection by remember { mutableStateOf(false) } + var subjectExpanded by remember { mutableStateOf(false) } + var gradeExpanded by remember { mutableStateOf(false) } + + val reorderableLazyListState = rememberReorderableLazyListState( + lazyListState = lazyListState, + onMove = { from, to -> + val headerItemCount = 5 + val fromIndex = from.index - headerItemCount + val toIndex = to.index - headerItemCount + + if (fromIndex >= 0 && toIndex >= 0) { + var currentItemCount = 0 + var fromSectionIndex = -1 + var fromLessonIndex = -1 + var toSectionIndex = -1 + var toLessonIndex = -1 + + for (sectionIndex in uiState.sections.indices) { + val section = uiState.sections[sectionIndex] + val sectionHeaderIndex = currentItemCount + val lessonStartIndex = currentItemCount + 1 + val lessonEndIndex = lessonStartIndex + section.items.size + + if (fromIndex == sectionHeaderIndex) { + fromSectionIndex = sectionIndex + fromLessonIndex = -1 + } else if (fromIndex in lessonStartIndex until lessonEndIndex) { + fromSectionIndex = sectionIndex + fromLessonIndex = fromIndex - lessonStartIndex + } + + if (toIndex == sectionHeaderIndex) { + toSectionIndex = sectionIndex + toLessonIndex = -1 + } else if (toIndex in lessonStartIndex until lessonEndIndex) { + toSectionIndex = sectionIndex + toLessonIndex = toIndex - lessonStartIndex + } + + currentItemCount = lessonEndIndex + } + + when { + fromLessonIndex >= 0 && toLessonIndex >= 0 -> { + onLessonMovedBetweenSections( + fromSectionIndex, + fromLessonIndex, + toSectionIndex, + toLessonIndex + ) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + fromLessonIndex >= 0 && toLessonIndex == -1 && toSectionIndex >= 0 -> { + onLessonMovedBetweenSections( + fromSectionIndex, + fromLessonIndex, + toSectionIndex, + 0 + ) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + fromLessonIndex == -1 && toLessonIndex == -1 && + fromSectionIndex >= 0 && toSectionIndex >= 0 -> { + onSectionMoved(fromSectionIndex, toSectionIndex) + 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("subject_grade_language_row") { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultItemPadding(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ExposedDropdownMenuBox( + expanded = subjectExpanded, + onExpandedChange = { subjectExpanded = it }, + modifier = Modifier.weight(1f) + ) { + OutlinedTextField( + value = uiState.selectedSubject?.name?.getTitle() ?: "", + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.subject)) }, + trailingIcon = { + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null + ) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + .testTag("subject_dropdown"), + singleLine = true + ) + + ExposedDropdownMenu( + expanded = subjectExpanded, + onDismissRequest = { subjectExpanded = false } + ) { + uiState.availableSubjects.forEach { subject -> + DropdownMenuItem( + text = { Text(subject.name.getTitle()) }, + onClick = { + onSubjectSelected(subject) + subjectExpanded = false + } + ) + } + } + } + + ExposedDropdownMenuBox( + expanded = gradeExpanded, + onExpandedChange = { gradeExpanded = it }, + modifier = Modifier.weight(1f) + ) { + OutlinedTextField( + value = uiState.selectedGrade ?: "", + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(Res.string.grade)) }, + trailingIcon = { + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null + ) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + .testTag("grade_dropdown"), + singleLine = true + ) + + ExposedDropdownMenu( + expanded = gradeExpanded, + onDismissRequest = { gradeExpanded = false } + ) { + uiState.availableGrades.forEach { grade -> + DropdownMenuItem( + text = { Text(grade) }, + onClick = { + onGradeSelected(grade) + gradeExpanded = false + } + ) + } + } + } + } + } + item("sections_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 { + uiState.sections.forEachIndexed { sectionIndex, section -> + item(key = "section_header_${section.uid}") { + ReorderableItem( + state = reorderableLazyListState, + key = "section_header_${section.uid}" + ) { isDragging -> + LaunchedEffect(isDragging) { + if (isDragging) { + draggingSectionIndex = sectionIndex + isDraggingAnySection = true + } else { + draggingSectionIndex = null + isDraggingAnySection = false + } + } + + SectionItem( + section = section, + sectionIndex = sectionIndex, + isDragging = isDragging, + onSectionTitleChanged = onSectionTitleChanged, + onClickRemoveSection = onClickRemoveSection, + onClickAddLesson = onClickAddLesson, + dragModifier = Modifier.draggableHandle( + onDragStarted = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDragStopped = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + ) + ) + } + } + + section.items.forEachIndexed { linkIndex, link -> + item(key = "lesson_${section.uid}_${link.href}_$linkIndex") { + ReorderableItem( + state = reorderableLazyListState, + key = "lesson_${section.uid}_${link.href}_$linkIndex", + enabled = !isDraggingAnySection + ) { isDragging -> + val isParentSectionDragging = draggingSectionIndex == sectionIndex + LessonItem( + link = link, + sectionLinkUiState = sectionLinkUiState, + sectionIndex = sectionIndex, + linkIndex = linkIndex, + onClickRemoveLesson = onClickRemoveLesson, + onClickLesson = onClickLesson, + isDragging = isDragging, + isParentSectionDragging = isParentSectionDragging, + dragModifier = Modifier.draggableHandle( + enabled = !isDraggingAnySection, + onDragStarted = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDragStopped = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + ) + ) + } + } + } + } + } + } +} + +@Composable +private fun SectionItem( + section: PlaylistsSection, + sectionIndex: Int, + isDragging: Boolean, + onSectionTitleChanged: (Int, String) -> Unit, + onClickRemoveSection: (Int) -> Unit, + onClickAddLesson: (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().testTag("add_item"), + enabled = !isDragging + ) { + Icon( + Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(Res.string.lesson)) + } + } + } + } +} + +@Composable +private fun LessonItem( + link: PlaylistsSectionLink, + sectionLinkUiState: (PlaylistsSectionLink) -> Flow>, + sectionIndex: Int, + linkIndex: Int, + onClickRemoveLesson: (Int, Int) -> Unit, + onClickLesson: (PlaylistsSectionLink) -> Unit = {}, + isDragging: Boolean, + isParentSectionDragging: Boolean = false, + dragModifier: Modifier = Modifier +) { + val stateFlow = remember(link.href) { + sectionLinkUiState(link) + } + + val linkUiState by stateFlow.collectAsState(initial = DataLoadingState()) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(start = 48.dp, end = 16.dp, bottom = 8.dp) + .then( + if (isParentSectionDragging) { + Modifier.alpha(0.5f) + } else { + Modifier + } + ) + .clickable( + enabled = !isDragging && !isParentSectionDragging, + onClick = { onClickLesson(link) } + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isDragging) 8.dp else 1.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.DragHandle, + contentDescription = stringResource(Res.string.drag), + modifier = dragModifier.size(20.dp), + tint = if (isDragging) MaterialTheme.colorScheme.primary + else if (isParentSectionDragging) MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.5f + ) + else MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(Modifier.width(12.dp)) + + 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), + color = if (isParentSectionDragging) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.onSurface + } + ) + + Spacer(Modifier.width(16.dp)) + + IconButton( + onClick = { onClickRemoveLesson(sectionIndex, linkIndex) }, + modifier = Modifier.size(24.dp), + enabled = !isDragging && !isParentSectionDragging + ) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(Res.string.remove_lesson), + modifier = Modifier.size(16.dp) + ) + } + } + } +} \ 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..ccf2ff19e --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/list/PlaylistListScreen.kt @@ -0,0 +1,288 @@ +package world.respect.app.view.playlists.mapping.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +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.material.icons.filled.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import world.respect.shared.generated.resources.* +import world.respect.shared.viewmodel.playlists.mapping.list.PlaylistListUiState +import world.respect.shared.viewmodel.playlists.mapping.list.PlaylistListViewModel + +import world.respect.shared.viewmodel.playlists.mapping.model.Playlists + +@Composable +fun PlaylistListScreen( + viewModel: PlaylistListViewModel +) { + val uiState by viewModel.uiState.collectAsState() + + PlaylistListScreenContent( + uiState = uiState, + onFilterSelected = viewModel::onFilterSelected, + onClickMapping = viewModel::onClickMapping, + onClickAddNew = viewModel::onClickAddNew, + onClickAddLink = viewModel::onClickAddLink, + onRemoveMapping = viewModel::removeMapping, + ) +} + +@Composable +fun PlaylistListScreenContent( + uiState: PlaylistListUiState, + onFilterSelected: (Int) -> Unit, + onClickMapping: (Playlists) -> Unit, + onClickAddNew: () -> Unit, + onClickAddLink: () -> Unit, + onRemoveMapping: (Playlists) -> Unit, +) { + var isFabMenuExpanded by remember { mutableStateOf(false) } + + val filterChips = listOf( + stringResource(Res.string.all), + stringResource(Res.string.school_playlists), + stringResource(Res.string.my_playlists) + ) + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + filterChips.forEachIndexed { index, label -> + FilterChip( + selected = uiState.selectedFilterIndex == index, + onClick = { onFilterSelected(index) }, + label = { Text(label) } + ) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + if (uiState.filteredMappings.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(Res.string.no_playlists_yet), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = 8.dp, + bottom = 88.dp + ) + ) { + items( + items = uiState.filteredMappings, + key = { mapping -> mapping.uid } + ) { mapping -> + MappingListItem( + mapping = mapping, + onClickMapping = onClickMapping, + onRemoveMapping = onRemoveMapping, + ) + } + } + } + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (isFabMenuExpanded) { + FloatingActionButton( + onClick = { + onClickAddNew() + isFabMenuExpanded = false + }, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(16.dp), + elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 6.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Filled.Add, + contentDescription = stringResource(Res.string.add_new), + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(Res.string.add_new), + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + } + + FloatingActionButton( + onClick = { + onClickAddLink() + isFabMenuExpanded = false + }, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(16.dp), + elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 6.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Filled.Link, + contentDescription = stringResource(Res.string.add_from_a_link), + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(Res.string.add_from_a_link), + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + + FloatingActionButton( + onClick = { isFabMenuExpanded = !isFabMenuExpanded }, + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Filled.Add, + contentDescription = stringResource(Res.string.add), + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(Res.string.add_playlist), + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} + +@Composable +private fun MappingListItem( + mapping: Playlists, + onClickMapping: (Playlists) -> Unit, + onRemoveMapping: (Playlists) -> Unit, +) { + var menuExpanded by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClickMapping(mapping) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Filled.Book, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = mapping.title, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Filled.Book, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "${mapping.sections.size} section, ${mapping.sections.sumOf { it.items.size }} items", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = "", + ) + } + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem( + text = { + Text(stringResource(Res.string.remove)) + }, + onClick = { + menuExpanded = false + onRemoveMapping(mapping) + } + ) + } + } + } +} \ 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..10cd68e57 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/playlists/mapping/share/PlaylistShareScreen.kt @@ -0,0 +1,234 @@ +package world.respect.app.view.playlists.mapping.share + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.stringResource +import qrgenerator.qrkitpainter.rememberQrKitPainter +import world.respect.app.components.defaultItemPadding +import world.respect.shared.generated.resources.Res +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.teacher_admin_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 PlaylistShareScreen( + viewModel: PlaylistShareViewModel +) { + val uiState by viewModel.uiState.collectAsState() + + PlaylistShareScreen( + uiState = uiState, + onClickShareLink = viewModel::onClickShareLink, + onClickCopyLink = viewModel::onClickCopyLink, + onClickSendViaSms = viewModel::onClickSendViaSms, + onClickSendViaEmail = viewModel::onClickSendViaEmail, + onViewPermissionChanged = viewModel::onViewPermissionChanged, + onEditPermissionChanged = viewModel::onEditPermissionChanged, + ) +} + +@Composable +fun PlaylistShareScreen( + uiState: PlaylistShareUiState, + onClickShareLink: () -> Unit = {}, + onClickCopyLink: () -> Unit = {}, + onClickSendViaSms: () -> Unit = {}, + onClickSendViaEmail: () -> Unit = {}, + onViewPermissionChanged: (String) -> Unit = {}, + onEditPermissionChanged: (String) -> Unit = {}, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + item("qr_code") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val shareUrlStr = uiState.shareUrl?.toString().orEmpty() + + if (shareUrlStr.isNotEmpty()) { + val qrCodePainter = rememberQrKitPainter(data = shareUrlStr) + + Image( + painter = qrCodePainter, + contentDescription = "QR Code for sharing playlist", + modifier = Modifier.size(200.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = shareUrlStr, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + item("permissions") { + Column( + modifier = Modifier + .fillMaxWidth() + .defaultItemPadding() + ) { + PermissionDropdown( + label = stringResource(Res.string.who_can_view), + selectedValue = uiState.viewPermission, + options = listOf( + stringResource(Res.string.anyone_with_the_link), + stringResource(Res.string.teacher_admin_in_my_school) + ), + onValueChanged = onViewPermissionChanged + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PermissionDropdown( + label = stringResource(Res.string.who_can_edit), + selectedValue = uiState.editPermission, + options = listOf( + stringResource(Res.string.teacher_admin_in_my_school) + ), + onValueChanged = onEditPermissionChanged + ) + } + } + + item("share_link") { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClickShareLink() }, + leadingContent = { + Icon( + Icons.Default.Share, + contentDescription = null + ) + }, + headlineContent = { + Text(stringResource(Res.string.share_link)) + } + ) + } + + item("copy_link") { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClickCopyLink() }, + leadingContent = { + Icon( + Icons.Default.ContentCopy, + contentDescription = null + ) + }, + headlineContent = { + Text(stringResource(Res.string.copy_link)) + } + ) + } + + item("send_sms") { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClickSendViaSms() }, + leadingContent = { + Icon( + Icons.Default.Sms, + contentDescription = null + ) + }, + headlineContent = { + Text(stringResource(Res.string.send_link_via_sms)) + } + ) + } + + item("send_email") { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClickSendViaEmail() }, + leadingContent = { + Icon( + Icons.Default.Email, + contentDescription = null + ) + }, + headlineContent = { + Text(stringResource(Res.string.send_link_via_email)) + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PermissionDropdown( + label: String, + selectedValue: String, + options: List, + onValueChanged: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedValue, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = true) + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onValueChanged(option) + expanded = false + } + ) + } + } + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml index 52b9cb015..a10d8f953 100644 --- a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml +++ b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml @@ -89,8 +89,8 @@ Invitation for: Congratulations You have successfully registered the school - I’m a Student - I’m a Parent + I'm a Student + I'm a Parent Accept Terms and conditions Your profile @@ -177,6 +177,7 @@ Teacher or admin login School Directory Let's get started + School name School name Type school name here... I have an invite code @@ -225,7 +226,6 @@ Create passkey Passkey - Add account Logout Profile @@ -317,7 +317,6 @@ Indicator Indicator detail - = Equals != Not Equals @@ -357,7 +356,6 @@ Grade Level Assessment Type (Self/Assignment) - Age Group Region @@ -421,7 +419,6 @@ Expand students list Collapse students list - Add person Select person Edit person @@ -434,7 +431,7 @@ Edit mapping Textbooks Chapter - Lesson + Item Add book cover No sections have been added yet. Click + Section to add one. Map @@ -501,7 +498,7 @@ Date Time - Assignment tasks + Tasks Assignment name Lesson/assessment @@ -521,9 +518,7 @@ Pending teacher Edit enrollment - Regenerate invite code - Copy link Invite settings Invite multiple people Approval required @@ -533,18 +528,13 @@ Share link QR code Code - Copy This code is private. If it is shared with someone, they can enter this code here to join. Class name or school name - - Section name This QR code is private. If it is shared with someone, they can scan to join. - Not set - School server URL Approval not required until: @@ -554,7 +544,6 @@ Invite students directly Students register themselves - Invite via parents Parents register and student uses parents' device @@ -570,4 +559,43 @@ Select Host - + Section name + Playlists + School Playlists + My Playlists + No playlists yet + Create your first playlist to get started + Add playlist + Error loading playlists + Add new + Add from a link + Playlist + Copy + All sections, items + Created by: + Edit playlist + Home + Copy + copy playlist + Make a copy + %1$d sections • %2$d items + Task + Select Playlist Unit + Add task to assignment + Add %1$d tasks to assignment + Select all + Select none + + Who can view + Who can edit + Anyone with the link + Teacher/admin in my school + Share link + Copy link + Send link via SMS + Send link via email + Grade + + Not set + + \ 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 17746057e..732f3772f 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 @@ -7,16 +7,17 @@ import io.ktor.http.Url import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import world.respect.datalayer.school.model.EnrollmentRoleEnum import world.respect.datalayer.school.model.Person -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.domain.account.invite.RespectRedeemInviteRequest 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.playlists.mapping.model.Playlists import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryMode /** @@ -32,20 +33,33 @@ import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryMode 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 JoinClazzWithCode( + val schoolUrlStr: String +) : RespectAppRoute { + + @Transient + val schoolUrl = Url(schoolUrlStr) + + companion object { + fun create(schoolUrl: Url) = JoinClazzWithCode(schoolUrl.toString()) + } } + @Serializable data class EnterInviteCode( val schoolUrlStr: String @@ -57,7 +71,6 @@ data class EnterInviteCode( companion object { fun create(schoolUrl: Url) = EnterInviteCode(schoolUrl.toString()) } - } @Serializable @@ -76,7 +89,6 @@ data class SchoolDirectoryList( mode: SchoolDirectoryMode = SchoolDirectoryMode.MANAGE ) = SchoolDirectoryList(mode.value) } - } @Serializable @@ -93,13 +105,12 @@ data class LoginScreen( companion object { fun create(schoolUrl: Url) = LoginScreen(schoolUrl.toString()) } - } @Serializable data class RespectAppLauncher( val resultDestStr: String? = null, -) : RespectAppRoute, RouteWithResultDest{ +) : RespectAppRoute, RouteWithResultDest { @Transient override val resultDest: ResultDest? = ResultDest.fromStringOrNull(resultDestStr) @@ -123,29 +134,76 @@ data class AssignmentDetail( @Serializable data class AssignmentEdit( - val guid: String?, - private val learningUnitStr: String? = null, -): RespectAppRoute { + val guid: String? = null, + private val learningUnitsJson: String? = null, + private val availablePlaylistsJson: String? = null, +) : RespectAppRoute { @Transient - val learningUnitSelected: LearningUnitSelection? = learningUnitStr?.let { - Json.decodeFromString(LearningUnitSelection.serializer(), it) + val learningUnitSelectedList: List? = learningUnitsJson?.let { jsonStr -> + try { + Json.decodeFromString>(jsonStr) + } catch (e: Exception) { + null + } } - companion object { + @Transient + val availablePlaylists: List? = availablePlaylistsJson?.let { jsonStr -> + try { + Json.decodeFromString( + ListSerializer(Playlists.serializer()), + jsonStr + ) + } catch (e: Exception) { + null + } + } + companion object { fun create( uid: String?, learningUnitSelected: LearningUnitSelection? = null, - ) = AssignmentEdit( - guid = uid, - learningUnitStr = learningUnitSelected?.let { - Json.encodeToString(LearningUnitSelection.serializer(), it) - }, - ) + availablePlaylists: List? = null, + ): AssignmentEdit { + val learningUnits = learningUnitSelected?.let { listOf(it) } + return AssignmentEdit( + guid = uid, + learningUnitsJson = learningUnits?.let { + Json.encodeToString( + ListSerializer(LearningUnitSelection.serializer()), + it + ) + }, + availablePlaylistsJson = availablePlaylists?.let { + Json.encodeToString( + ListSerializer(Playlists.serializer()), + it + ) + } + ) + } + fun createWithMultipleLessons( + uid: String?, + learningUnits: List, + availablePlaylists: List? = null, + ): AssignmentEdit { + return AssignmentEdit( + guid = uid, + learningUnitsJson = Json.encodeToString( + ListSerializer(LearningUnitSelection.serializer()), + learningUnits + ), + availablePlaylistsJson = availablePlaylists?.let { + Json.encodeToString( + ListSerializer(Playlists.serializer()), + it + ) + } + ) + } } - } @Serializable @@ -166,12 +224,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, @@ -179,7 +237,6 @@ data class EnrollmentList( ) } } - } @Serializable @@ -210,7 +267,6 @@ class AddPersonToClazz( } } - @Serializable data class ClazzEdit( val guid: String? @@ -258,7 +314,21 @@ class IndictorEdit(val indicatorId: String?) : RespectAppRoute object RespectAppList : RespectAppRoute @Serializable -object EnterLink : RespectAppRoute +data class EnterLink( + private val resultDestStr: String? = null, +) : RespectAppRoute, RouteWithResultDest { + + @Transient + override val resultDest: ResultDest? = ResultDest.fromStringOrNull(resultDestStr) + + companion object { + fun create( + resultDest: ResultDest? = null, + ) = EnterLink( + resultDestStr = resultDest.encodeToJsonStringOrNull() + ) + } +} @Serializable data class GetStartedScreen( @@ -297,11 +367,9 @@ 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 @@ -335,10 +403,9 @@ class LearningUnitList( resultDestStr = resultDest.encodeToJsonStringOrNull() ) } - } - } + @Serializable class EnterPasswordSignup private constructor( private val schoolUrlStr: String, @@ -346,7 +413,7 @@ class EnterPasswordSignup private constructor( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString(inviteRedeemRequestStr) @Transient @@ -362,7 +429,6 @@ class EnterPasswordSignup private constructor( Json.encodeToString(inviteRequest) ) } - } } @@ -373,7 +439,7 @@ class OtherOptionsSignup private constructor( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString(inviteRedeemRequestStr) @Transient @@ -391,7 +457,6 @@ class OtherOptionsSignup private constructor( respectRedeemInviteRequest, schoolUrl.toString() ) } - } } @@ -430,7 +495,7 @@ class SignupScreen( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString(inviteRedeemRequestStr) @Transient @@ -466,7 +531,7 @@ class TermsAndCondition( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString(inviteRedeemRequestStr) @Transient @@ -498,7 +563,7 @@ class CreateAccount( ) : RespectAppRoute { @Transient - val respectRedeemInviteRequest : RespectRedeemInviteRequest = Json.decodeFromString( + val respectRedeemInviteRequest: RespectRedeemInviteRequest = Json.decodeFromString( inviteRedeemRequestStr ) @@ -534,17 +599,28 @@ class LearningUnitDetail( private val learningUnitManifestUrlStr: String, private val appManifestUrlStr: String, private val refererUrlStr: String? = null, - val expectedIdentifier: String? = null + val expectedIdentifier: String? = null, + private val mappingDataJson: String? = null, + val isSelectionMode: Boolean = false, ) : RespectAppRoute { - @Transient - val learningUnitManifestUrl = Url(learningUnitManifestUrlStr) + val learningUnitManifestUrl: Url + get() = Url(learningUnitManifestUrlStr) - @Transient - val refererUrl = refererUrlStr?.let { Url(it) } + val appManifestUrl: Url + get() = Url(appManifestUrlStr) - @Transient - val appManifestUrl = Url(appManifestUrlStr) + val refererUrl: Url? + get() = refererUrlStr?.let { Url(it) } + + val mappingData: Playlists? + get() = mappingDataJson?.let { + try { + Json.decodeFromString(Playlists.serializer(), it) + } catch (e: Exception) { + null + } + } companion object { @@ -558,10 +634,30 @@ class LearningUnitDetail( appManifestUrlStr = appManifestUrl.toString(), refererUrlStr = refererUrl?.toString(), expectedIdentifier = expectedIdentifier, + mappingDataJson = null, + isSelectionMode = false, ) - } + fun createFromMapping( + mapping: Playlists, + isSelectionMode: Boolean = false + ): LearningUnitDetail { + val mappingJson = try { + Json.encodeToString(Playlists.serializer(), mapping) + } catch (e: Exception) { + null + } + return LearningUnitDetail( + learningUnitManifestUrlStr = "", + appManifestUrlStr = "", + refererUrlStr = null, + expectedIdentifier = null, + mappingDataJson = mappingJson, + isSelectionMode = isSelectionMode, + ) + } + } } @Serializable @@ -579,18 +675,16 @@ 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 + * add student or add teacher on the ClassDetail screen, then the role */ @Serializable data class PersonList( @@ -609,10 +703,12 @@ 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) @@ -639,7 +735,6 @@ data class PersonList( personGuidStr = personGuid, hideInvite = hideInvite, ) - } } @@ -653,7 +748,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 @@ -675,7 +769,6 @@ data class ManageAccount( @Transient val setPersonQrBadgeUrl: Url? = setPersonQrBadgeUrlStr?.let { Url(it) } - companion object { fun create( guid: String, @@ -715,8 +808,6 @@ data class PersonEdit( presetRoleStr = presetRole?.value, ) } - - } @Serializable @@ -763,15 +854,15 @@ data class ScanQRCode( data object CurriculumMappingList : RespectAppRoute @Serializable -data class CurriculumMappingEdit( +data class PlaylistEdit( val textbookUid: Long = 0L, private val mappingDataJson: String? = null ) : RespectAppRoute { @Transient - val mappingData: CurriculumMapping? = mappingDataJson?.let { jsonString -> + val mappingData: Playlists? = mappingDataJson?.let { jsonString -> try { - Json.decodeFromString(CurriculumMapping.serializer(), jsonString) + Json.decodeFromString(Playlists.serializer(), jsonString) } catch (e: Exception) { null } @@ -780,12 +871,12 @@ data class CurriculumMappingEdit( companion object { fun create( uid: Long, - mappingData: CurriculumMapping? = null - ) = CurriculumMappingEdit( + mappingData: Playlists? = null + ) = PlaylistEdit( textbookUid = uid, mappingDataJson = mappingData?.let { mapping -> try { - Json.encodeToString(CurriculumMapping.serializer(), mapping) + Json.encodeToString(Playlists.serializer(), mapping) } catch (e: Exception) { null } @@ -793,10 +884,11 @@ data class CurriculumMappingEdit( ) } } + @Serializable data class CreateAccountSetUsername( val guid: String -): RespectAppRoute +) : RespectAppRoute @Serializable data class CreateAccountSetPassword( @@ -804,12 +896,19 @@ data class CreateAccountSetPassword( val username: String? = null, ) : RespectAppRoute - - @Serializable data class ChangePassword( val guid: String, -): RespectAppRoute +) : RespectAppRoute + +@Serializable +data class PlaylistShare( + val playlistUid: Long +) : RespectAppRoute { + companion object { + fun create(playlistUid: Long) = PlaylistShare(playlistUid) + } +} @Serializable data class InvitePerson( @@ -823,20 +922,19 @@ data class InvitePerson( sealed interface InvitePersonOptions /** - * @property if presetRole is set - then dropdown will not be displayed. + * @property presetRole if set, the role 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( @@ -855,11 +953,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/launcher/AppLauncherViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/launcher/AppLauncherViewModel.kt index 8a27782c0..edf55c102 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 @@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json import org.koin.core.component.KoinScopeComponent import org.koin.core.component.inject import org.koin.core.scope.Scope @@ -28,27 +30,34 @@ import world.respect.libutil.ext.resolve 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.add_task_to_assignment import world.respect.shared.generated.resources.app -import world.respect.shared.generated.resources.apps 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.generated.resources.home import world.respect.shared.navigation.AppsDetail import world.respect.shared.navigation.LearningUnitList import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.Settings import world.respect.shared.navigation.RespectAppLauncher import world.respect.shared.navigation.RespectAppList +import world.respect.shared.navigation.NavResultReturner import world.respect.shared.resources.UiText import world.respect.shared.util.ext.asUiText import world.respect.datalayer.db.school.ext.isAdmin import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.FabUiState +import world.respect.shared.viewmodel.assignment.edit.AssignmentEditViewModel +import world.respect.shared.viewmodel.playlists.mapping.list.PlaylistListViewModel +import world.respect.shared.viewmodel.playlists.mapping.model.Playlists data class AppLauncherUiState( - val apps : IPagingSourceFactory = EmptyPagingSourceFactory(), + val apps: IPagingSourceFactory = EmptyPagingSourceFactory(), val respectAppForSchoolApp: (SchoolApp) -> Flow> = { emptyFlow() }, val canRemove: Boolean = false, - val emptyListDescription: UiText?=null, + val emptyListDescription: UiText? = null, + val selectedTabIndex: Int = 0, + val isSelectionMode: Boolean = false, ) class AppLauncherViewModel( @@ -56,6 +65,9 @@ class AppLauncherViewModel( private val appDataSource: RespectAppDataSource, private val accountManager: RespectAccountManager, private val getDevModeEnabledUseCase: GetDevModeEnabledUseCase, + private val json: Json, + resultReturner: NavResultReturner, + val playlistListViewModel: PlaylistListViewModel, ) : RespectViewModel(savedStateHandle), KoinScopeComponent { override val scope: Scope = accountManager.requireActiveAccountScope() @@ -64,7 +76,7 @@ class AppLauncherViewModel( val uiState = _uiState.asStateFlow() - var errorMessage: String = "" + private var isAdmin: Boolean = false private val route: RespectAppLauncher = savedStateHandle.toRoute() @@ -80,48 +92,71 @@ class AppLauncherViewModel( init { _appUiState.update { it.copy( - title = Res.string.apps.asUiText(), - onClickSettings = ::onClickSettings, - fabState = FabUiState( - icon = FabUiState.FabIcon.ADD, - text = Res.string.app.asUiText(), - onClick = { - _navCommandFlow.tryEmit( - NavCommand.Navigate( - RespectAppList - ) - ) + title = if (route.resultDest != null) { + when (route.resultDest.resultKey) { + AssignmentEditViewModel.KEY_PLAYLIST_SELECTION -> + Res.string.add_task_to_assignment.asUiText() + + else -> + Res.string.home.asUiText() } - ), + } else { + Res.string.home.asUiText() + }, + onClickSettings = ::onClickSettings, hideBottomNavigation = route.resultDest != null, showBackButton = route.resultDest != null, ) } + val isFromAssignmentEdit = + route.resultDest?.resultKey == AssignmentEditViewModel.KEY_PLAYLIST_SELECTION + _uiState.update { prev -> prev.copy( respectAppForSchoolApp = this@AppLauncherViewModel::respectAppForSchoolApp, - apps = pagingSourceHolder + apps = pagingSourceHolder, + selectedTabIndex = if (isFromAssignmentEdit) 1 else 0, + isSelectionMode = isFromAssignmentEdit, ) + } + + playlistListViewModel.setSelectionMode(isFromAssignmentEdit) + + viewModelScope.launch { + playlistListViewModel.navCommandFlow.collect { navCommand -> + _navCommandFlow.tryEmit(navCommand) + } + } + val savedMappings = loadMappingsFromSavedState(savedStateHandle) + playlistListViewModel.setMappings(savedMappings) + viewModelScope.launch { + playlistListViewModel.uiState.collect { state -> + savedStateHandle[KEY_MAPPINGS_LIST] = json.encodeToString( + ListSerializer(Playlists.serializer()), + state.mappings + ) + } } viewModelScope.launch { accountManager.selectedAccountAndPersonFlow.collect { selected -> val isAdmin = selected?.person?.isAdmin() == true val devModeEnabled = getDevModeEnabledUseCase() + + this@AppLauncherViewModel.isAdmin = isAdmin + updateFabState(isAdmin, _uiState.value.selectedTabIndex) + _appUiState.update { it.copy( - fabState = it.fabState.copy( - visible = isAdmin - ), settingsIconVisible = isAdmin && devModeEnabled, ) } _uiState.update { it.copy( canRemove = isAdmin, - emptyListDescription = if(isAdmin) + emptyListDescription = if (isAdmin) Res.string.empty_list_description_admin.asUiText() else Res.string.empty_list_description_non_admin.asUiText() @@ -130,20 +165,56 @@ class AppLauncherViewModel( } } } + fun onTabSelected(index: Int) { + _uiState.update { it.copy(selectedTabIndex = index) } + updateFabState(isAdmin, index) + } + private fun updateFabState(isAdmin: Boolean, tabIndex: Int) { + _appUiState.update { + it.copy( + fabState = when (tabIndex) { + 0 -> FabUiState( + visible = isAdmin, + icon = FabUiState.FabIcon.ADD, + text = Res.string.app.asUiText(), + onClick = { + _navCommandFlow.tryEmit( + NavCommand.Navigate(RespectAppList) + ) + } + ) + 1 -> FabUiState(visible = false) + else -> FabUiState(visible = false) + } + ) + } + } + + private fun loadMappingsFromSavedState(savedStateHandle: SavedStateHandle): List { + val mappingsJson = savedStateHandle.get(KEY_MAPPINGS_LIST) ?: return emptyList() + return try { + json.decodeFromString( + ListSerializer(Playlists.serializer()), + mappingsJson + ) + } catch (e: Exception) { + emptyList() + } + } fun onClickApp(app: DataLoadState) { val url = app.metaInfo.url ?: return val appData = app.dataOrNull() ?: return _navCommandFlow.tryEmit( NavCommand.Navigate( - if(route.resultDest != null) { + if (route.resultDest != null) { LearningUnitList.create( opdsFeedUrl = url.resolve(appData.learningUnits.toString()), appManifestUrl = url, resultDest = route.resultDest, ) - }else { + } else { AppsDetail.create( manifestUrl = url, resultDest = route.resultDest, @@ -152,6 +223,7 @@ class AppLauncherViewModel( ) ) } + fun onClickSettings() { _navCommandFlow.tryEmit( NavCommand.Navigate(Settings) @@ -179,5 +251,8 @@ class AppLauncherViewModel( DataLoadParams() ) } -} + companion object { + 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/apps/list/AppListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt index 9f7e540a5..2c0ae7350 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt @@ -21,6 +21,7 @@ import world.respect.shared.navigation.NavCommand import world.respect.shared.util.ext.asUiText + data class AppListUiState( val appList: List> = emptyList() ) @@ -63,7 +64,7 @@ class AppListViewModel( fun onClickAddLink() { _navCommandFlow.tryEmit( NavCommand.Navigate( - EnterLink + EnterLink.create() ) ) } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/detail/AssignmentDetailViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/detail/AssignmentDetailViewModel.kt index 9af1adac0..11a256576 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/detail/AssignmentDetailViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/detail/AssignmentDetailViewModel.kt @@ -11,10 +11,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json import org.koin.core.component.KoinScopeComponent import org.koin.core.component.inject import org.koin.core.scope.Scope @@ -24,13 +25,8 @@ import world.respect.datalayer.DataLoadingState import world.respect.datalayer.RespectAppDataSource import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.ext.dataOrNull -import world.respect.datalayer.school.PersonDataSource import world.respect.datalayer.school.model.Assignment import world.respect.datalayer.school.model.AssignmentLearningUnitRef -import world.respect.datalayer.school.model.EnrollmentRoleEnum -import world.respect.datalayer.school.model.Person -import world.respect.datalayer.shared.paging.IPagingSourceFactory -import world.respect.datalayer.shared.paging.PagingSourceFactoryHolder import world.respect.lib.opds.model.OpdsPublication import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.ext.whenSubscribed @@ -45,6 +41,8 @@ import world.respect.datalayer.db.school.ext.isAdminOrTeacher import world.respect.datalayer.school.model.Clazz import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.FabUiState +import world.respect.shared.viewmodel.apps.launcher.AppLauncherViewModel +import world.respect.shared.viewmodel.playlists.mapping.model.Playlists data class AssignmentDetailUiState( val assignment: DataLoadState = DataLoadingState(), @@ -58,6 +56,7 @@ class AssignmentDetailViewModel( savedStateHandle: SavedStateHandle, accountManager: RespectAccountManager, private val respectAppDataSource: RespectAppDataSource, + private val json: Json, ) : RespectViewModel(savedStateHandle), KoinScopeComponent { override val scope: Scope = accountManager.requireActiveAccountScope() @@ -132,11 +131,31 @@ class AssignmentDetailViewModel( } fun onClickEdit() { + val availablePlaylists = getAvailablePlaylists() _navCommandFlow.tryEmit( - NavCommand.Navigate(AssignmentEdit.create(uid = route.uid)) + NavCommand.Navigate( + AssignmentEdit.create( + uid = route.uid, + availablePlaylists = availablePlaylists + ) + ) ) } - + private fun getAvailablePlaylists(): List { + val mappingsJson = savedStateHandle.get(AppLauncherViewModel.KEY_MAPPINGS_LIST) + return if (mappingsJson != null) { + try { + json.decodeFromString( + ListSerializer(Playlists.serializer()), + mappingsJson + ) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } fun learningUnitInfoFlowFor(url: Url): Flow> { return respectAppDataSource.opdsDataSource.loadOpdsPublication( url = url, params = DataLoadParams(), null, null 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 e984c618b..d27cf7424 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 @@ -56,6 +56,7 @@ data class AssignmentEditUiState( val classOptions: List = emptyList(), val classError: UiText? = null, val learningUnitInfoFlow: (Url) -> Flow> = { flowOf(DataLoadingState()) }, + val showPlaylistButton: Boolean = true, ) { val fieldsEnabled: Boolean get() = assignment.isReadyAndSettled() @@ -114,6 +115,54 @@ class AssignmentEditViewModel( hideBottomNavigation = true, ) } + viewModelScope.launch { + resultReturner.filteredResultFlowForKey(KEY_LEARNING_UNIT).collect { result -> + val learningUnit = result.result as? LearningUnitSelection ?: return@collect + val assignmentResourceRef = learningUnit.toRef() + + _uiState.update { prev -> + val prevAssignment = prev.assignment.dataOrNull() ?: return@update prev + + prev.copy( + assignment = DataReadyState( + data = prevAssignment.copy( + learningUnits = prevAssignment.learningUnits + assignmentResourceRef + ) + ) + ) + } + } + } + viewModelScope.launch { + resultReturner.filteredResultFlowForKey(KEY_PLAYLIST_SELECTION).collect { result -> + val learningUnitSelections = result.result as? List<*> ?: return@collect + val newLearningUnits = learningUnitSelections.mapNotNull { item -> + (item as? LearningUnitSelection)?.toRef() + } + + if (newLearningUnits.isEmpty()) return@collect + + val assignment = _uiState.value.assignment.dataOrNull() ?: return@collect + val existingUrls = assignment.learningUnits.map { + it.learningUnitManifestUrl + }.toSet() + + val uniqueNewUnits = newLearningUnits.filter { + it.learningUnitManifestUrl !in existingUrls + } + if (uniqueNewUnits.isEmpty()) return@collect + + _uiState.update { prev -> + prev.copy( + assignment = DataReadyState( + assignment.copy( + learningUnits = assignment.learningUnits + uniqueNewUnits + ) + ) + ) + } + } + } launchWithLoadingIndicator { val classes = schoolDataSource.classDataSource.list( @@ -149,7 +198,25 @@ class AssignmentEditViewModel( } } ) + + viewModelScope.launch { + schoolDataSource.assignmentDataSource.findByGuidAsFlow( + route.guid + ).collect { assignmentState -> + if (assignmentState is DataReadyState) { + val currentLearningUnits = _uiState.value.assignment.dataOrNull()?.learningUnits + val newLearningUnits = assignmentState.data.learningUnits + if (currentLearningUnits != newLearningUnits) { + _uiState.update { prev -> + prev.copy(assignment = assignmentState) + } + } + } + } + } }else { + val initialLearningUnits = route.learningUnitSelectedList?.map { it.toRef() } ?: emptyList() + _uiState.update { prev -> prev.copy( assignment = DataReadyState( @@ -158,36 +225,28 @@ class AssignmentEditViewModel( title = "", description = "", classUid = "", - learningUnits = route.learningUnitSelected?.let { - listOf(it.toRef()) - } ?: emptyList() + learningUnits = initialLearningUnits ) ) ) } } - - viewModelScope.launch { - resultReturner.filteredResultFlowForKey(KEY_LEARNING_UNIT).collect { result -> - val learningUnit = result.result as? LearningUnitSelection ?: return@collect - val assignmentResourceRef = learningUnit.toRef() - - _uiState.update { prev -> - val prevAssignment = prev.assignment.dataOrNull() ?: return@update prev - - prev.copy( - assignment = DataReadyState( - data = prevAssignment.copy( - learningUnits = prevAssignment.learningUnits + assignmentResourceRef - ) - ) - ) - } - } - } } } + fun onClickAddFromPlaylists() { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + RespectAppLauncher.create( + resultDest = RouteResultDest( + resultPopUpTo = route, + resultKey = KEY_PLAYLIST_SELECTION, + ) + ) + ) + ) + } + fun learningUnitInfoFlowFor(url: Url): Flow> { return respectAppDataSource.opdsDataSource.loadOpdsPublication( url = url, params = DataLoadParams(), null, null @@ -220,7 +279,10 @@ class AssignmentEditViewModel( } debouncer.launch(DEFAULT_SAVED_STATE_KEY) { - savedStateHandle[DEFAULT_SAVED_STATE_KEY] = json.encodeToString(assignment) + savedStateHandle[DEFAULT_SAVED_STATE_KEY] = json.encodeToString( + Assignment.serializer(), + assignment + ) } } @@ -230,7 +292,6 @@ class AssignmentEditViewModel( } } - fun onClickAddLearningUnit() { _navCommandFlow.tryEmit( NavCommand.Navigate( @@ -244,9 +305,7 @@ class AssignmentEditViewModel( ) } - fun onClickRemoveLearningUnit( - ref: AssignmentLearningUnitRef - ) { + fun onClickRemoveLearningUnit(ref: AssignmentLearningUnitRef) { val assignment = uiState.value.assignment.dataOrNull() ?: return _uiState.update { prev -> @@ -271,7 +330,7 @@ class AssignmentEditViewModel( assignmentVal?.title.isNullOrBlank() }, classError = Res.string.required_field.asUiText().takeIf { - assignmentVal?.classUid.isNullOrEmpty() + assignmentVal?.classUid.isNullOrBlank() } ) } @@ -301,9 +360,7 @@ class AssignmentEditViewModel( } companion object { - const val KEY_LEARNING_UNIT = "result_learning_unit" - + const val KEY_PLAYLIST_SELECTION = "result_playlist_selection" } - } \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/list/AssignmentListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/list/AssignmentListViewModel.kt index 6b5669215..7fd6e2ba9 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/list/AssignmentListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/assignment/list/AssignmentListViewModel.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json import org.koin.core.component.KoinScopeComponent import org.koin.core.component.inject import org.koin.core.scope.Scope @@ -34,6 +36,8 @@ import world.respect.shared.util.ext.asUiText import world.respect.datalayer.db.school.ext.isAdminOrTeacher import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.FabUiState +import world.respect.shared.viewmodel.apps.launcher.AppLauncherViewModel +import world.respect.shared.viewmodel.playlists.mapping.model.Playlists data class AssignmentListUiState( val assignments: IPagingSourceFactory = EmptyPagingSourceFactory(), @@ -44,6 +48,7 @@ class AssignmentListViewModel( savedStateHandle: SavedStateHandle, accountManager: RespectAccountManager, private val respectAppDataSource: RespectAppDataSource, + private val json: Json, ) : RespectViewModel(savedStateHandle), KoinScopeComponent { override val scope: Scope = accountManager.requireActiveAccountScope() @@ -104,14 +109,34 @@ class AssignmentListViewModel( ) } + fun onClickAdd() { + val availablePlaylists = getAvailablePlaylists() _navCommandFlow.tryEmit( NavCommand.Navigate( - AssignmentEdit.create(uid = null) + AssignmentEdit.create( + uid = null, + availablePlaylists = availablePlaylists + ) ) ) } + private fun getAvailablePlaylists(): List { + val mappingsJson = savedStateHandle.get(AppLauncherViewModel.KEY_MAPPINGS_LIST) + return if (mappingsJson != null) { + try { + json.decodeFromString( + ListSerializer(Playlists.serializer()), + mappingsJson + ) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } fun learningUnitInfoFlowFor(url: Url): Flow> { return respectAppDataSource.opdsDataSource.loadOpdsPublication( url = url, params = DataLoadParams(), null, null 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 10d7e6333..000000000 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/edit/CurriculumMappingEditViewModel.kt +++ /dev/null @@ -1,275 +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 world.respect.datalayer.DataLoadParams -import world.respect.datalayer.DataLoadState -import world.respect.datalayer.RespectAppDataSource -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.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, - private val respectAppDataSource: RespectAppDataSource, -) : RespectViewModel(savedStateHandle) { - - 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 respectAppDataSource.opdsDataSource.loadOpdsPublication( - 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/learningunit/LearningUnitSelection.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/LearningUnitSelection.kt index 26b7a035e..b29da5550 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/LearningUnitSelection.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/LearningUnitSelection.kt @@ -14,3 +14,4 @@ data class LearningUnitSelection( val selectedPublication: OpdsPublication, val appManifestUrl: Url, ) + diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/detail/LearningUnitDetailViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/detail/LearningUnitDetailViewModel.kt index cb968c9f7..3abddb704 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/detail/LearningUnitDetailViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/learningunit/detail/LearningUnitDetailViewModel.kt @@ -5,12 +5,17 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.ustadmobile.libcache.PublicationPinState import com.ustadmobile.libcache.UstadCache +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.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import world.respect.shared.navigation.LearningUnitDetail -import world.respect.shared.viewmodel.RespectViewModel +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState import world.respect.datalayer.DataLoadingState @@ -18,15 +23,29 @@ import world.respect.datalayer.DataReadyState import world.respect.datalayer.RespectAppDataSource import world.respect.datalayer.compatibleapps.model.RespectAppManifest import world.respect.datalayer.ext.dataOrNull -import world.respect.lib.opds.model.OpdsPublication +import world.respect.datalayer.ext.map import world.respect.datalayer.respect.model.LEARNING_UNIT_MIME_TYPES +import world.respect.lib.opds.model.OpdsPublication +import world.respect.lib.opds.model.findIcons import world.respect.libutil.ext.resolve import world.respect.shared.domain.launchapp.LaunchAppUseCase import world.respect.shared.navigation.AssignmentEdit +import world.respect.shared.navigation.PlaylistEdit +import world.respect.shared.navigation.LearningUnitDetail import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.NavResult +import world.respect.shared.navigation.NavResultReturner +import world.respect.shared.navigation.PlaylistShare 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.getTitle +import world.respect.shared.viewmodel.apps.launcher.AppLauncherViewModel +import world.respect.shared.viewmodel.assignment.edit.AssignmentEditViewModel +import world.respect.shared.viewmodel.playlists.mapping.edit.PlaylistEditViewModel +import world.respect.shared.viewmodel.playlists.mapping.edit.PlaylistSectionUiState +import world.respect.shared.viewmodel.playlists.mapping.model.Playlists +import world.respect.shared.viewmodel.playlists.mapping.model.PlaylistsSectionLink import world.respect.shared.viewmodel.learningunit.LearningUnitSelection data class LearningUnitDetailUiState( @@ -35,9 +54,20 @@ data class LearningUnitDetailUiState( val pinState: PublicationPinState = PublicationPinState( PublicationPinState.Status.NOT_PINNED, 0, 0 ), + val mapping: Playlists? = null, + val sectionLinkUiState: (PlaylistsSectionLink) -> Flow> = { + emptyFlow() + }, + val showCopyDialog: Boolean = false, + val copyDialogName: String = "", + val isSelectionMode: Boolean = false, + val selectedLessons: Set = emptySet(), ) { val buttonsEnabled: Boolean - get() = lessonDetail != null + get() = lessonDetail != null || mapping != null + + val showEditButton: Boolean + get() = mapping != null && !isSelectionMode } class LearningUnitDetailViewModel( @@ -45,6 +75,8 @@ class LearningUnitDetailViewModel( private val appDataSource: RespectAppDataSource, private val launchAppUseCase: LaunchAppUseCase, private val ustadCache: UstadCache, + private val resultReturner: NavResultReturner, + private val json: Json, ) : RespectViewModel(savedStateHandle) { private val _uiState = MutableStateFlow(LearningUnitDetailUiState()) @@ -54,53 +86,73 @@ class LearningUnitDetailViewModel( private val route: LearningUnitDetail = savedStateHandle.toRoute() init { - viewModelScope.launch { - appDataSource.opdsDataSource.loadOpdsPublication( - url = route.learningUnitManifestUrl, - params = DataLoadParams(), - referrerUrl = route.learningUnitManifestUrl, - expectedPublicationId = route.expectedIdentifier - ).collect { result -> - when (result) { - is DataReadyState -> { - _uiState.update { - it.copy( - lessonDetail = result.data.resolve( - route.learningUnitManifestUrl + val mappingData = route.mappingData + val isSelectionMode = route.isSelectionMode + + if (mappingData != null) { + _uiState.update { + it.copy( + mapping = mappingData, + sectionLinkUiState = this@LearningUnitDetailViewModel::sectionLinkUiStateFor, + isSelectionMode = isSelectionMode + ) + } + _appUiState.update { + it.copy( + title = mappingData.title.asUiText(), + hideBottomNavigation = isSelectionMode, + showBackButton = true, + ) + } + } else { + viewModelScope.launch { + appDataSource.opdsDataSource.loadOpdsPublication( + url = route.learningUnitManifestUrl, + params = DataLoadParams(), + referrerUrl = route.learningUnitManifestUrl, + expectedPublicationId = route.expectedIdentifier + ).collect { result -> + when (result) { + is DataReadyState -> { + _uiState.update { + it.copy( + lessonDetail = result.data.resolve( + route.learningUnitManifestUrl + ) ) - ) - } + } - _appUiState.update { - it.copy( - title = result.data.metadata.title.getTitle().asUiText() - ) + _appUiState.update { + it.copy( + title = result.data.metadata.title.getTitle().asUiText(), + showBackButton = true, + hideBottomNavigation = false + ) + } + } + else -> { } - } - else -> { } } } - } - viewModelScope.launch { - appDataSource.compatibleAppsDataSource.getAppAsFlow( - manifestUrl = route.appManifestUrl, - loadParams = DataLoadParams() - ).collect { app -> - _uiState.update { it.copy(app = app) } + viewModelScope.launch { + appDataSource.compatibleAppsDataSource.getAppAsFlow( + manifestUrl = route.appManifestUrl, + loadParams = DataLoadParams() + ).collect { app -> + _uiState.update { it.copy(app = app) } + } } - } - viewModelScope.launch { - ustadCache.publicationPinState(route.learningUnitManifestUrl).collect { pinState -> - _uiState.update { it.copy(pinState = pinState) } + viewModelScope.launch { + ustadCache.publicationPinState(route.learningUnitManifestUrl).collect { pinState -> + _uiState.update { it.copy(pinState = pinState) } + } } } - } - fun onClickOpen() { val respectApp = _uiState.value.app.dataOrNull() ?: return val launchLink = _uiState.value.lessonDetail?.links?.firstOrNull { link -> @@ -133,27 +185,305 @@ class LearningUnitDetailViewModel( //Do nothing } } - - }catch(t: Throwable) { + } catch(t: Throwable) { t.printStackTrace() } } } + private suspend fun loadLessonPublications( + lessons: List + ): List { + return lessons.mapNotNull { lesson -> + try { + val publicationState = appDataSource.opdsDataSource.loadOpdsPublication( + url = Url(lesson.href), + params = DataLoadParams(), + referrerUrl = null, + expectedPublicationId = null, + ).first { it is DataReadyState } + + val publication = (publicationState as? DataReadyState)?.data + + if (publication != null && lesson.appManifestUrl != null) { + LearningUnitSelection( + learningUnitManifestUrl = Url(lesson.href), + selectedPublication = publication, + appManifestUrl = lesson.appManifestUrl + ) + } else null + } catch (e: Exception) { + e.printStackTrace() + null + } + } + } + + private fun getAvailablePlaylists(): List { + val mappingsJson = savedStateHandle.get(AppLauncherViewModel.KEY_MAPPINGS_LIST) + return if (mappingsJson != null) { + try { + json.decodeFromString( + ListSerializer(Playlists.serializer()), + mappingsJson + ) + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + fun onClickAssign() { - val publicationVal = uiState.value.lessonDetail ?: return + val mapping = uiState.value.mapping + val availablePlaylists = getAvailablePlaylists() + + if (mapping != null) { + val allLessons = mapping.sections.flatMap { section -> + section.items.filter { it.appManifestUrl != null } + } + + if (allLessons.isNotEmpty()) { + viewModelScope.launch { + val learningUnitSelections = loadLessonPublications(allLessons) + + if (learningUnitSelections.isNotEmpty()) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = AssignmentEdit.createWithMultipleLessons( + uid = null, + learningUnits = learningUnitSelections, + availablePlaylists = availablePlaylists + ) + ) + ) + } + } + } else { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = AssignmentEdit.create( + uid = null, + learningUnitSelected = null, + availablePlaylists = availablePlaylists + ) + ) + ) + } + } else { + val publicationVal = uiState.value.lessonDetail ?: return + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = AssignmentEdit.create( + uid = null, + learningUnitSelected = LearningUnitSelection( + learningUnitManifestUrl = route.learningUnitManifestUrl, + selectedPublication = publicationVal, + appManifestUrl = route.appManifestUrl, + ), + availablePlaylists = availablePlaylists + ) + ) + ) + } + } + + fun onClickAssignSection(sectionUid: Long) { + val mapping = _uiState.value.mapping ?: return + val section = mapping.sections.find { it.uid == sectionUid } ?: return + val sectionLessons = section.items.filter { it.appManifestUrl != null } + val availablePlaylists = getAvailablePlaylists() + + if (sectionLessons.isNotEmpty()) { + viewModelScope.launch { + val learningUnitSelections = loadLessonPublications(sectionLessons) + + if (learningUnitSelections.isNotEmpty()) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = AssignmentEdit.createWithMultipleLessons( + uid = null, + learningUnits = learningUnitSelections, + availablePlaylists = availablePlaylists + ) + ) + ) + } + } + } + } + + fun onLessonSelectionToggle(link: PlaylistsSectionLink) { + _uiState.update { prev -> + val selected = if (prev.selectedLessons.contains(link)) { + prev.selectedLessons - link + } else { + prev.selectedLessons + link + } + prev.copy(selectedLessons = selected) + } + } + + fun onClickSelectAll() { + val mapping = _uiState.value.mapping ?: return + val allLessons = mapping.sections.flatMap { it.items }.toSet() + _uiState.update { it.copy(selectedLessons = allLessons) } + } + + fun onClickSelectNone() { + _uiState.update { it.copy(selectedLessons = emptySet()) } + } + + fun onClickToggleSectionSelection(sectionUid: Long) { + val mapping = _uiState.value.mapping ?: return + val section = mapping.sections.find { it.uid == sectionUid } ?: return + val sectionLessons = section.items + + val allSelected = sectionLessons.all { _uiState.value.selectedLessons.contains(it) } + + _uiState.update { prev -> + val newSelection = if (allSelected) { + prev.selectedLessons - sectionLessons.toSet() + } else { + prev.selectedLessons + sectionLessons.toSet() + } + prev.copy(selectedLessons = newSelection) + } + } + + fun onConfirmSelection() { + val selectedLessons = _uiState.value.selectedLessons.toList() + + if (selectedLessons.isEmpty()) return + viewModelScope.launch { + val learningUnitSelections = loadLessonPublications(selectedLessons) + + if (learningUnitSelections.isNotEmpty()) { + resultReturner.sendResult( + NavResult( + key = AssignmentEditViewModel.KEY_PLAYLIST_SELECTION, + result = learningUnitSelections + ) + ) + _navCommandFlow.tryEmit(NavCommand.PopUp()) + } + } + } + + fun onClickLesson(link: PlaylistsSectionLink) { + if (_uiState.value.isSelectionMode) { + onLessonSelectionToggle(link) + } else { + val publicationUrl = Url(link.href) + val appManifestUrl = link.appManifestUrl ?: return + + _navCommandFlow.tryEmit( + NavCommand.Navigate( + LearningUnitDetail.create( + learningUnitManifestUrl = publicationUrl, + appManifestUrl = appManifestUrl, + refererUrl = publicationUrl, + expectedIdentifier = null + ) + ) + ) + } + } + + fun onClickEdit() { + val mapping = _uiState.value.mapping ?: return _navCommandFlow.tryEmit( NavCommand.Navigate( - destination = AssignmentEdit.create( - uid = null, - learningUnitSelected = LearningUnitSelection( - learningUnitManifestUrl = route.learningUnitManifestUrl, - selectedPublication = publicationVal, - appManifestUrl = route.appManifestUrl - ) + PlaylistEdit.create( + uid = mapping.uid, + mappingData = mapping ) ) ) } -} + + fun onClickShare() { + val mapping = _uiState.value.mapping ?: return + _navCommandFlow.tryEmit( + NavCommand.Navigate( + PlaylistShare.create(playlistUid = mapping.uid) + ) + ) + } + + fun onClickCopy() { + val mapping = _uiState.value.mapping ?: return + val defaultName = "Copy of ${mapping.title}" + _uiState.update { + it.copy( + showCopyDialog = true, + copyDialogName = defaultName + ) + } + } + + fun onCopyDialogDismiss() { + _uiState.update { it.copy(showCopyDialog = false, copyDialogName = "") } + } + + fun onCopyDialogNameChanged(name: String) { + _uiState.update { it.copy(copyDialogName = name) } + } + + fun onCopyDialogConfirm() { + val mapping = _uiState.value.mapping ?: return + val newName = _uiState.value.copyDialogName.trim() + + if (newName.isEmpty()) { + return + } + + val copiedMapping = mapping.copy( + uid = System.currentTimeMillis(), + title = newName + ) + + resultReturner.sendResult( + NavResult( + key = PlaylistEditViewModel.KEY_SAVED_MAPPING, + result = copiedMapping + ) + ) + + _uiState.update { + it.copy( + showCopyDialog = false, + copyDialogName = "" + ) + } + } + + fun onClickDelete() { + // TODO: + } + + fun sectionLinkUiStateFor( + link: PlaylistsSectionLink + ): Flow> { + val publicationUrl = Url(link.href) + return appDataSource.opdsDataSource.loadOpdsPublication( + url = publicationUrl, + params = DataLoadParams(), + referrerUrl = null, + expectedPublicationId = null, + ).map { opdsLoadState -> + opdsLoadState.map { publication -> + PlaylistSectionUiState( + icon = publication.findIcons().firstOrNull()?.let { + publicationUrl.resolve(it.href) + }, + title = publication.metadata.title.getTitle(), + subtitle = publication.metadata.subtitle?.getTitle().orEmpty(), + description = publication.metadata.description.orEmpty() + ) + } + } + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt index b032e6d10..8baf7f808 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt @@ -141,4 +141,4 @@ class AcceptInviteViewModel( ) } -} +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/profile/SignupViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/profile/SignupViewModel.kt index fe1017888..d10943ebf 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/profile/SignupViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/profile/SignupViewModel.kt @@ -242,10 +242,9 @@ class SignupViewModel( ) ) ) - } + } } } } } } - 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/playlists/mapping/PlaylistsAdapter.kt similarity index 53% rename from respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/CurriculumMappingAdapter.kt rename to respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/PlaylistsAdapter.kt index d7f5bf874..acf43205e 100644 --- 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/playlists/mapping/PlaylistsAdapter.kt @@ -1,4 +1,4 @@ -package world.respect.shared.viewmodel.curriculum.mapping +package world.respect.shared.viewmodel.playlists.mapping //Add functions that convert CurriculumMapping to OpdsFeed and vice versa. See the adapters in the //database module @@ -6,16 +6,19 @@ package world.respect.shared.viewmodel.curriculum.mapping //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.LangMap 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.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.lib.opds.model.ReadiumMetadata +import world.respect.shared.viewmodel.playlists.mapping.model.Playlists +import world.respect.shared.viewmodel.playlists.mapping.model.PlaylistsSection +import world.respect.shared.viewmodel.playlists.mapping.model.PlaylistsSectionLink -fun CurriculumMapping.toOpds(selfLink: String): OpdsFeed { +fun Playlists.toOpds(selfLink: String): OpdsFeed { return OpdsFeed( metadata = OpdsFeedMetadata( title = this.title, @@ -44,17 +47,46 @@ fun CurriculumMapping.toOpds(selfLink: String): OpdsFeed { ) } -fun OpdsFeed.toCurriculumMapping(): CurriculumMapping { - return CurriculumMapping( +fun Playlists.toOpdsGroup(): OpdsGroup { + return OpdsGroup( + metadata = OpdsFeedMetadata( + title = this.title + ), + publications = this.sections.flatMap { section -> + section.items.map { link -> + OpdsPublication( + metadata = ReadiumMetadata( + title = mapOf("en" to (link.title ?: "")) as LangMap, + ), + links = listOfNotNull( + ReadiumLink( + href = link.href, + rel = listOf("http://opds-spec.org/acquisition"), + ), + link.appManifestUrl?.let { + ReadiumLink( + href = it.toString(), + rel = listOf("http://opds-spec.org/compatible-app"), + ) + } + ) + ) + } + } + ) +} + +fun OpdsFeed.toCurriculumMapping(): Playlists { + return Playlists( uid = System.currentTimeMillis(), title = this.metadata.title, description = this.metadata.description ?: "", sections = this.groups?.map { group -> - CurriculumMappingSection( + PlaylistsSection( uid = System.currentTimeMillis(), title = group.metadata.title, items = group.navigation?.map { navLink -> - CurriculumMappingSectionLink( + PlaylistsSectionLink( uid = System.currentTimeMillis(), href = navLink.href, title = navLink.title ?: "" @@ -63,4 +95,4 @@ fun OpdsFeed.toCurriculumMapping(): CurriculumMapping { ) } ?: emptyList() ) -} +} \ 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..e5ffce8f7 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/edit/PlaylistEditViewModel.kt @@ -0,0 +1,470 @@ +package world.respect.shared.viewmodel.playlists.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.first +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.DataReadyState +import world.respect.datalayer.RespectAppDataSource +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.map +import world.respect.lib.opds.model.findIcons +import world.respect.lib.opds.model.ReadiumSubjectObject +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.create_playlist +import world.respect.shared.generated.resources.edit_playlist +import world.respect.shared.generated.resources.required_field +import world.respect.shared.generated.resources.save +import world.respect.shared.navigation.PlaylistEdit +import world.respect.shared.navigation.LearningUnitDetail +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.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.app.appstate.getTitle +import world.respect.shared.viewmodel.assignment.edit.AssignmentEditViewModel.Companion.KEY_LEARNING_UNIT +import world.respect.shared.viewmodel.playlists.mapping.model.Playlists +import world.respect.shared.viewmodel.playlists.mapping.model.PlaylistsSection +import world.respect.shared.viewmodel.playlists.mapping.model.PlaylistsSectionLink +import world.respect.shared.viewmodel.learningunit.LearningUnitSelection + +data class PlaylistEditUiState( + val mapping: Playlists? = null, + val loading: Boolean = false, + val isNew: Boolean = true, + val titleError: UiText? = null, + val error: UiText? = null, + val pendingLessonSectionIndex: Int? = null, + val availableSubjects: List = emptyList(), + val availableGrades: List = emptyList(), + val availableLanguages: List = emptyList(), + val sectionUiState: (PlaylistsSection) -> 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() + + val selectedSubject: ReadiumSubjectObject? + get() = mapping?.subject + + val selectedGrade: String? + get() = mapping?.grade +} + +data class PlaylistSectionUiState( + val icon: Url? = null, + val title: String = "", + val subtitle: String = "", + val description: String = "", +) + +class PlaylistEditViewModel( + savedStateHandle: SavedStateHandle, + private val resultReturner: NavResultReturner, + private val json: Json, + private val respectAppDataSource: RespectAppDataSource, + private val accountManager: RespectAccountManager, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val schoolDataSource: SchoolDataSource by inject() + + private val route: PlaylistEdit = savedStateHandle.toRoute() + + private val mappingUid = route.textbookUid + private val mappingData = route.mappingData + + private val _uiState = MutableStateFlow( + PlaylistEditUiState( + mapping = mappingData ?: Playlists(uid = mappingUid), + isNew = mappingUid == 0L + ) + ) + + val uiState = _uiState.asStateFlow() + + init { + _appUiState.update { prev -> + prev.copy( + title = if (mappingData == null) { + Res.string.create_playlist.asUiText() + } else { + Res.string.edit_playlist.asUiText() + }, + userAccountIconVisible = false, + actionBarButtonState = ActionBarButtonUiState( + visible = true, + text = Res.string.save.asUiText(), + onClick = ::onClickSave + ), + hideBottomNavigation = true + ) + } + viewModelScope.launch { + loadSubjectsAndGrades() + } + + 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 + PlaylistsSectionLink( + href = selectedLearningUnit.learningUnitManifestUrl.toString(), + title = selectedLearningUnit.selectedPublication.metadata.title.getTitle(), + appManifestUrl = selectedLearningUnit.appManifestUrl + ) + ) + } + ), + pendingLessonSectionIndex = null, + ) + } + } + } + } + + private fun loadSubjectsAndGrades() { + viewModelScope.launch { + try { + val session = accountManager.selectedAccountAndPersonFlow.first() + val catalogUrl = session?.session?.account?.school?.respectExt ?: return@launch + + respectAppDataSource.opdsDataSource.loadOpdsFeed( + url = catalogUrl, + params = DataLoadParams() + ).collect { feedState -> + if (feedState is DataReadyState) { + val allSubjects = mutableListOf() + val allGrades = mutableSetOf() + val allLanguages = mutableSetOf() + + feedState.data.publications?.forEach { publication -> + publication.metadata.subject?.forEach { subject -> + if (subject is ReadiumSubjectObject && subject.code != null) { + allSubjects.add(subject) + } + } + publication.metadata.language?.forEach { language -> + allLanguages.add(language) + } + } + + feedState.data.groups?.forEach { group -> + group.publications?.forEach { publication -> + publication.metadata.subject?.forEach { subject -> + if (subject is ReadiumSubjectObject && subject.code != null) { + allSubjects.add(subject) + } + } + publication.metadata.language?.forEach { language -> + allLanguages.add(language) + } + } + } + + val uniqueSubjects = allSubjects + .distinctBy { subject -> subject.code } + .sortedBy { subject -> subject.name.getTitle() } + + _uiState.update { prev -> + prev.copy( + availableSubjects = uniqueSubjects, + availableGrades = allGrades.toList().sorted(), + availableLanguages = allLanguages.toList().sorted() + ) + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun updateUiStateAndCommit(block: (PlaylistEditUiState) -> PlaylistEditUiState) { + val mappingToCommit = _uiState.updateAndGet(block).mapping ?: return + + savedStateHandle[KEY_MAPPING] = json.encodeToString( + Playlists.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 onSubjectSelected(subject: ReadiumSubjectObject?) { + updateUiStateAndCommit { prev -> + prev.copy( + mapping = prev.mapping?.copy(subject = subject) + ) + } + } + + fun onGradeSelected(grade: String?) { + updateUiStateAndCommit { prev -> + prev.copy( + mapping = prev.mapping?.copy(grade = grade) + ) + } + } + + fun onClickAddSection() { + updateUiStateAndCommit { prev -> + prev.copy( + mapping = prev.mapping?.copy( + sections = prev.mapping.sections + PlaylistsSection(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 onLessonMovedBetweenSections( + fromSectionIndex: Int, + fromLinkIndex: Int, + toSectionIndex: Int, + toLinkIndex: Int + ) { + updateUiStateAndCommit { prev -> + val mapping = prev.mapping + if (mapping == null) { + prev + } else if (fromSectionIndex == toSectionIndex) { + prev.copy( + mapping = mapping.copy( + sections = mapping.sections.updateAtIndex(fromSectionIndex) { section -> + section.copy( + items = section.items.moveItem(from = fromLinkIndex, to = toLinkIndex) + ) + } + ) + ) + } else { + val fromSection = mapping.sections[fromSectionIndex] + val lessonToMove = fromSection.items[fromLinkIndex] + + val updatedFromSection = fromSection.copy( + items = fromSection.items.filterIndexed { index, _ -> index != fromLinkIndex } + ) + + val toSection = mapping.sections[toSectionIndex] + val updatedToSection = toSection.copy( + items = buildList { + addAll(toSection.items.take(toLinkIndex)) + add(lessonToMove) + addAll(toSection.items.drop(toLinkIndex)) + } + ) + + prev.copy( + mapping = mapping.copy( + sections = mapping.sections.mapIndexed { index, section -> + when (index) { + fromSectionIndex -> updatedFromSection + toSectionIndex -> updatedToSection + else -> section + } + } + ) + ) + } + } + } + + 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 } + ) + } + ) + ) + } + } + + fun onClickLesson(link: PlaylistsSectionLink) { + val publicationUrl = Url(link.href) + val appManifestUrl = link.appManifestUrl ?: return + + _navCommandFlow.tryEmit( + NavCommand.Navigate( + LearningUnitDetail.create( + learningUnitManifestUrl = publicationUrl, + appManifestUrl = appManifestUrl, + refererUrl = publicationUrl, + expectedIdentifier = null + ) + ) + ) + } + + fun sectionLinkUiStateFor( + link: PlaylistsSectionLink + ): Flow> { + val publicationUrl = Url(link.href) + return respectAppDataSource.opdsDataSource.loadOpdsPublication( + url = Url(link.href), + params = DataLoadParams(), + referrerUrl = null, + expectedPublicationId = null, + ).map { opdsLoadState -> + opdsLoadState.map { publication -> + PlaylistSectionUiState( + 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 + } + + viewModelScope.launch { + val currentSession = accountManager.selectedAccountAndPersonFlow.first() + + val finalMapping = if (mapping.uid == 0L) { + mapping.copy( + uid = System.currentTimeMillis(), + createdBy = currentSession?.person?.guid, + schoolUrl = currentSession?.session?.account?.school?.self + ) + } else { + mapping + } + + resultReturner.sendResult( + NavResult( + key = KEY_SAVED_MAPPING, + result = finalMapping + ) + ) + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = LearningUnitDetail.createFromMapping(finalMapping), + popUpTo = RespectAppLauncher(), + popUpToInclusive = false + ) + ) + } + } + + 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/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..30504aec8 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/list/PlaylistListViewModel.kt @@ -0,0 +1,145 @@ +package world.respect.shared.viewmodel.playlists.mapping.list + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import io.ktor.http.Url +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.scope.Scope +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.navigation.PlaylistEdit +import world.respect.shared.navigation.EnterLink +import world.respect.shared.navigation.LearningUnitDetail +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.NavResultReturner +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.viewmodel.playlists.mapping.edit.PlaylistEditViewModel +import world.respect.shared.viewmodel.playlists.mapping.model.Playlists +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.error_unexpected_result_type +import world.respect.shared.resources.UiText + +data class PlaylistListUiState( + val mappings: List = emptyList(), + val selectedFilterIndex: Int = 0, + val error: UiText? = null, + val currentUserGuid: String? = null, + val currentSchoolUrl: Url? = null, + val isSelectionMode: Boolean = false, +) { + val filteredMappings: List + get() = when (selectedFilterIndex) { + 0 -> mappings + 1 -> mappings.filter { mapping -> + mapping.isSchoolWide && + mapping.schoolUrl == currentSchoolUrl && + mapping.createdBy != currentUserGuid + } + 2 -> mappings.filter { mapping -> + mapping.createdBy == currentUserGuid + } + else -> mappings + } +} + +class PlaylistListViewModel( + savedStateHandle: SavedStateHandle, + private val resultReturner: NavResultReturner, + private val accountManager: RespectAccountManager, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val _uiState = MutableStateFlow(PlaylistListUiState()) + + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + accountManager.selectedAccountAndPersonFlow.collect { sessionAndPerson -> + _uiState.update { prev -> + prev.copy( + currentUserGuid = sessionAndPerson?.person?.guid, + currentSchoolUrl = sessionAndPerson?.session?.account?.school?.self + ) + } + } + } + + viewModelScope.launch { + resultReturner.filteredResultFlowForKey( + PlaylistEditViewModel.KEY_SAVED_MAPPING + ).collect { result -> + val savedMapping = result.result as? Playlists + if (savedMapping == null) { + _uiState.update { + it.copy(error = Res.string.error_unexpected_result_type.asUiText()) + } + return@collect + } + addOrUpdateMapping(savedMapping) + } + } + } + + fun setMappings(mappings: List) { + _uiState.update { it.copy(mappings = mappings) } + } + + fun setSelectionMode(isSelectionMode: Boolean) { + _uiState.update { it.copy(isSelectionMode = isSelectionMode) } + } + + fun onFilterSelected(index: Int) { + _uiState.update { it.copy(selectedFilterIndex = index) } + } + + fun onClickMapping(mapping: Playlists) { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + LearningUnitDetail.createFromMapping( + mapping = mapping, + isSelectionMode = _uiState.value.isSelectionMode + ) + ) + ) + } + + fun onClickAddNew() { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + PlaylistEdit.create(uid = 0L, mappingData = null) + ) + ) + } + + fun onClickAddLink() { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + EnterLink.create() + ) + ) + } + + fun removeMapping(mapping: Playlists) { + val updated = _uiState.value.mappings.filter { it.uid != mapping.uid } + _uiState.update { it.copy(mappings = updated) } + } + + private fun addOrUpdateMapping(mapping: Playlists) { + val currentMappings = _uiState.value.mappings.toMutableList() + val existingIndex = currentMappings.indexOfFirst { it.uid == mapping.uid } + + if (existingIndex >= 0) { + currentMappings[existingIndex] = mapping + } else { + currentMappings.add(mapping) + } + + _uiState.update { it.copy(mappings = currentMappings) } + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/model/Playlists.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/model/Playlists.kt new file mode 100644 index 000000000..70067812b --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/model/Playlists.kt @@ -0,0 +1,19 @@ +package world.respect.shared.viewmodel.playlists.mapping.model + +import io.ktor.http.Url +import kotlinx.serialization.Serializable +import world.respect.lib.opds.model.ReadiumSubjectObject + +@Serializable +data class Playlists( + val uid: Long = System.currentTimeMillis(), + val title: String = "", + val description: String = "", + val subject: ReadiumSubjectObject? = null, + val grade: String? = null, + val language: String? = null, + val sections: List = emptyList(), + val createdBy: String? = null, + val isSchoolWide: Boolean = false, + val schoolUrl: Url? = null, +) diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/model/PlaylistsSection.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/model/PlaylistsSection.kt new file mode 100644 index 000000000..bd0724a3c --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/model/PlaylistsSection.kt @@ -0,0 +1,10 @@ +package world.respect.shared.viewmodel.playlists.mapping.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PlaylistsSection( + 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/playlists/mapping/model/PlaylistsSectionLink.kt similarity index 55% rename from respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/curriculum/mapping/model/CurriculumMappingSectionLink.kt rename to respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/model/PlaylistsSectionLink.kt index 8003e7ae9..7b1c3c925 100644 --- 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/playlists/mapping/model/PlaylistsSectionLink.kt @@ -1,13 +1,15 @@ -package world.respect.shared.viewmodel.curriculum.mapping.model +package world.respect.shared.viewmodel.playlists.mapping.model +import io.ktor.http.Url import kotlinx.serialization.Serializable /** * @property href Absolute URL to the OPDS publication linked (NOT the Learning Unit ID URL). */ @Serializable -data class CurriculumMappingSectionLink( +data class PlaylistsSectionLink( val uid: Long = System.currentTimeMillis(), val href: String, - val title: String? = "" + val title: String? = "", + val appManifestUrl: Url? = null, ) 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..8f797c235 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/playlists/mapping/share/PlaylistShareViewModel.kt @@ -0,0 +1,101 @@ +package world.respect.shared.viewmodel.playlists.mapping.share + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import io.ktor.http.Url +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.scope.Scope +import world.respect.libutil.ext.appendEndpointSegments +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.navigation.PlaylistShare +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.share + + +data class PlaylistShareUiState( + val playlistUid: Long = 0L, + val shareUrl: Url? = null, + val viewPermission: String = "", + val editPermission: String = "", +) + +class PlaylistShareViewModel( + savedStateHandle: SavedStateHandle, + private val accountManager: RespectAccountManager, +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val _uiState = MutableStateFlow(PlaylistShareUiState()) + + val uiState = _uiState.asStateFlow() + + private val route: PlaylistShare = savedStateHandle.toRoute() + + init { + _appUiState.update { + it.copy( + title = Res.string.share.asUiText(), + hideBottomNavigation = true, + ) + } + + val playlistUid = route.playlistUid + + _uiState.update { + it.copy( + playlistUid = playlistUid, + viewPermission = "", + editPermission = "" + ) + } + + viewModelScope.launch { + accountManager.selectedAccountAndPersonFlow.collect { sessionAndPerson -> + val schoolUrl = sessionAndPerson?.session?.account?.school?.self + + if (schoolUrl != null) { + val shareUrl = buildShareUrl(schoolUrl, playlistUid) + _uiState.update { prev -> + prev.copy(shareUrl = shareUrl) + } + } + } + } + } + + private fun buildShareUrl(schoolUrl: Url, playlistUid: Long): Url { + return schoolUrl.appendEndpointSegments("playlist", playlistUid.toString()) + } + + fun onClickShareLink() { + // TODO: + } + + fun onClickCopyLink() { + // TODO: + } + + fun onClickSendViaSms() { + // TODO: + } + + fun onClickSendViaEmail() { + // TODO: + } + + fun onViewPermissionChanged(permission: String) { + _uiState.update { it.copy(viewPermission = permission) } + } + + fun onEditPermissionChanged(permission: String) { + _uiState.update { it.copy(editPermission = permission) } + } +} \ 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..6843b0968 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,8 @@ 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.navigation.RespectAppLauncher import world.respect.shared.util.ext.asUiText import world.respect.shared.viewmodel.RespectViewModel @@ -43,7 +43,7 @@ class SettingsViewModel( fun onNavigateToMapping() { _navCommandFlow.tryEmit( - NavCommand.Navigate(CurriculumMappingList) + NavCommand.Navigate(RespectAppLauncher()) ) } } \ No newline at end of file