diff --git a/respect-app-compose/src/androidMain/kotlin/world/respect/images/RespectImage.android.kt b/respect-app-compose/src/androidMain/kotlin/world/respect/images/RespectImage.android.kt
index c6e9f68e0..c64eab98d 100644
--- a/respect-app-compose/src/androidMain/kotlin/world/respect/images/RespectImage.android.kt
+++ b/respect-app-compose/src/androidMain/kotlin/world/respect/images/RespectImage.android.kt
@@ -12,7 +12,7 @@ private val nameMap = mapOf(
RespectImage.WORKS_OFFLINE to R.drawable.works_offline,
RespectImage.DATA_REPORTING to R.drawable.data_reporting,
RespectImage.ASSIGNMENTS to R.drawable.assignments
- )
+)
@Composable
actual fun respectImagePainter(image: RespectImage): Painter {
diff --git a/respect-app-compose/src/androidMain/res/drawable/enter_invite_code.webp b/respect-app-compose/src/androidMain/res/drawable/enter_invite_code.webp
new file mode 100644
index 000000000..7757080c4
Binary files /dev/null and b/respect-app-compose/src/androidMain/res/drawable/enter_invite_code.webp differ
diff --git a/respect-app-compose/src/androidMain/res/drawable/open_respect_app.webp b/respect-app-compose/src/androidMain/res/drawable/open_respect_app.webp
new file mode 100644
index 000000000..71ac8b9e3
Binary files /dev/null and b/respect-app-compose/src/androidMain/res/drawable/open_respect_app.webp differ
diff --git a/respect-app-compose/src/androidMain/res/drawable/review_and_complete_setup.webp b/respect-app-compose/src/androidMain/res/drawable/review_and_complete_setup.webp
new file mode 100644
index 000000000..8bf4a2e8e
Binary files /dev/null and b/respect-app-compose/src/androidMain/res/drawable/review_and_complete_setup.webp differ
diff --git a/respect-app-compose/src/androidMain/res/drawable/search_school.webp b/respect-app-compose/src/androidMain/res/drawable/search_school.webp
new file mode 100644
index 000000000..82e4484ab
Binary files /dev/null and b/respect-app-compose/src/androidMain/res/drawable/search_school.webp differ
diff --git a/respect-app-compose/src/androidMain/res/drawable/share_invite_code.webp b/respect-app-compose/src/androidMain/res/drawable/share_invite_code.webp
new file mode 100644
index 000000000..bd9f5f637
Binary files /dev/null and b/respect-app-compose/src/androidMain/res/drawable/share_invite_code.webp differ
diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt
index 1abf71dfd..b97d03000 100644
--- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt
+++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
@@ -15,6 +16,7 @@ import androidx.compose.material.RadioButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share
@@ -86,6 +88,9 @@ fun InvitePersonScreen(
onApprovalRequiredChanged = viewModel::onApprovalEnabledChanged,
onRoleChange = viewModel::onRoleChange,
onClickResetCode = viewModel::onClickResetCode,
+ showSlider = viewModel::showSlider,
+ hideSlider = viewModel::hideSlider,
+ setSliderPage = viewModel::setSliderPage,
onSetClassInviteMode = viewModel::onSetClassInviteMode,
)
}
@@ -102,6 +107,9 @@ fun InvitePersonScreen(
onApprovalRequiredChanged: (Boolean) -> Unit,
onRoleChange: (PersonRoleEnum) -> Unit,
onClickResetCode: () -> Unit,
+ showSlider: () -> Unit,
+ hideSlider: () -> Unit,
+ setSliderPage: (Int) -> Unit,
onSetClassInviteMode: (ClassInviteModeEnum) -> Unit = { },
) {
val invite = uiState.invite.dataOrNull()
@@ -112,6 +120,13 @@ fun InvitePersonScreen(
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
+ if (uiState.showSlider){
+ InviteSliderBottomSheet(
+ uiState = uiState,
+ onPageChange = { setSliderPage(it) },
+ onDismiss = hideSlider
+ )
+ }
if(uiState.showRoleSelection) {
val selectedRole = uiState.selectedRole ?: uiState.roleOptions.firstOrNull()
@@ -166,21 +181,36 @@ fun InvitePersonScreen(
invite?.also {
Row(
- modifier = Modifier.align(Alignment.CenterHorizontally).defaultItemPadding()
- .clickable(enabled = fieldsEnabled) {
- onClickInviteCode()
- },
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .defaultItemPadding(),
+ verticalAlignment = Alignment.CenterVertically
) {
- Text(
- text = stringResource(Res.string.invite_code_label) + ": ",
- textAlign = TextAlign.Center,
- )
+ Row(
+ modifier = Modifier
+ .clickable(enabled = fieldsEnabled) {
+ onClickInviteCode()
+ }
+ ) {
+ Text(
+ text = stringResource(Res.string.invite_code_label) + ": ",
+ textAlign = TextAlign.Center,
+ )
+
+ Text(
+ text = it.code,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.testTag("invite_code"),
+ )
+ }
- //Separated out for easier automated testing.
- Text(
- text = it.code,
- textAlign = TextAlign.Center,
- modifier = Modifier.testTag("invite_code"),
+ Icon(
+ imageVector = Icons.Default.Info,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .size(18.dp)
+ .clickable { showSlider() }
)
}
}
diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InviteSliderBottomSheet.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InviteSliderBottomSheet.kt
new file mode 100644
index 000000000..41761c753
--- /dev/null
+++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InviteSliderBottomSheet.kt
@@ -0,0 +1,178 @@
+package world.respect.app.view.person.inviteperson
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+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.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.DrawableResource
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.stringResource
+import world.respect.shared.generated.resources.Res
+import world.respect.shared.generated.resources.enter_invite_code
+import world.respect.shared.generated.resources.invite_how_title
+import world.respect.shared.generated.resources.invite_step_confirm_desc
+import world.respect.shared.generated.resources.invite_step_enter_code_desc
+import world.respect.shared.generated.resources.invite_step_enter_code_title
+import world.respect.shared.generated.resources.invite_step_open_app_desc
+import world.respect.shared.generated.resources.invite_step_open_app_title
+import world.respect.shared.generated.resources.invite_step_review_title
+import world.respect.shared.generated.resources.invite_step_search_school_desc
+import world.respect.shared.generated.resources.invite_step_search_school_title
+import world.respect.shared.generated.resources.invite_step_share_desc
+import world.respect.shared.generated.resources.invite_step_share_title
+import world.respect.shared.generated.resources.open_respect_app
+import world.respect.shared.generated.resources.review_and_complete_setup
+import world.respect.shared.generated.resources.search_school
+import world.respect.shared.generated.resources.share_invite_code
+import world.respect.shared.viewmodel.person.inviteperson.InvitePersonUiState
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun InviteSliderBottomSheet(
+ uiState: InvitePersonUiState,
+ onPageChange: (Int) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sliderPages = listOf(
+ InviteSliderPageUi(
+ sliderImage = Res.drawable.share_invite_code,
+ title = stringResource(Res.string.invite_step_share_title),
+ description = stringResource(
+ Res.string.invite_step_share_desc,
+ uiState.inviteCode?:""
+ )
+ ),
+ InviteSliderPageUi(
+ sliderImage = Res.drawable.open_respect_app,
+ title = stringResource(Res.string.invite_step_open_app_title),
+ description = stringResource(Res.string.invite_step_open_app_desc)
+ ),
+ InviteSliderPageUi(
+ sliderImage = Res.drawable.search_school,
+ title = stringResource(Res.string.invite_step_search_school_title),
+ description = stringResource(
+ Res.string.invite_step_search_school_desc,
+ uiState.schoolName?:""
+ )
+ ),
+ InviteSliderPageUi(
+ sliderImage = Res.drawable.review_and_complete_setup,
+ title = stringResource(Res.string.invite_step_review_title),
+ description = stringResource(Res.string.invite_step_confirm_desc)
+ ),
+ InviteSliderPageUi(
+ sliderImage = Res.drawable.enter_invite_code,
+ title = stringResource(Res.string.invite_step_enter_code_title),
+ description = stringResource(
+ Res.string.invite_step_enter_code_desc
+ )
+ )
+ )
+ val pagerState = rememberPagerState(
+ initialPage = uiState.currentSliderPage
+ ) { sliderPages.size }
+
+ LaunchedEffect(pagerState.currentPage) {
+ onPageChange(pagerState.currentPage)
+ }
+ val sheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true
+ )
+ if (uiState.showSlider) {
+ ModalBottomSheet(onDismissRequest = onDismiss,
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(390.dp)
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.weight(1f)
+ ) { page ->
+ val item = sliderPages[page]
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(Res.string.invite_how_title),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ fontWeight = FontWeight.Bold
+ )
+ Image(
+ painter = painterResource(item.sliderImage),
+ contentDescription = null,
+ modifier = Modifier.size(200.dp).padding(8.dp)
+ .align(Alignment.CenterHorizontally)
+ )
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ Text(
+ text = item.title,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Start,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text(item.description)
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ repeat(sliderPages.size) { index ->
+ val selected = uiState.currentSliderPage == index
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 4.dp)
+ .size(if (selected) 10.dp else 6.dp)
+ .clip(CircleShape)
+ .background(
+ if (selected) Color.Black else Color.LightGray
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+data class InviteSliderPageUi(
+ val sliderImage: DrawableResource,
+ val title: String,
+ val description: String
+)
\ No newline at end of file
diff --git a/respect-lib-shared/src/commonMain/composeResources/drawable/enter_invite_code.webp b/respect-lib-shared/src/commonMain/composeResources/drawable/enter_invite_code.webp
new file mode 100644
index 000000000..a7b119336
Binary files /dev/null and b/respect-lib-shared/src/commonMain/composeResources/drawable/enter_invite_code.webp differ
diff --git a/respect-lib-shared/src/commonMain/composeResources/drawable/open_respect_app.webp b/respect-lib-shared/src/commonMain/composeResources/drawable/open_respect_app.webp
new file mode 100644
index 000000000..fb43d32c9
Binary files /dev/null and b/respect-lib-shared/src/commonMain/composeResources/drawable/open_respect_app.webp differ
diff --git a/respect-lib-shared/src/commonMain/composeResources/drawable/review_and_complete_setup.webp b/respect-lib-shared/src/commonMain/composeResources/drawable/review_and_complete_setup.webp
new file mode 100644
index 000000000..e62e05f51
Binary files /dev/null and b/respect-lib-shared/src/commonMain/composeResources/drawable/review_and_complete_setup.webp differ
diff --git a/respect-lib-shared/src/commonMain/composeResources/drawable/search_school.webp b/respect-lib-shared/src/commonMain/composeResources/drawable/search_school.webp
new file mode 100644
index 000000000..1f09bd485
Binary files /dev/null and b/respect-lib-shared/src/commonMain/composeResources/drawable/search_school.webp differ
diff --git a/respect-lib-shared/src/commonMain/composeResources/drawable/share_invite_code.webp b/respect-lib-shared/src/commonMain/composeResources/drawable/share_invite_code.webp
new file mode 100644
index 000000000..ca1113ada
Binary files /dev/null and b/respect-lib-shared/src/commonMain/composeResources/drawable/share_invite_code.webp differ
diff --git a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml
index 52b9cb015..4f29a1314 100644
--- a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml
+++ b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml
@@ -524,6 +524,23 @@
Regenerate invite code
Copy link
+ How to use an invite code
+
+ Share the Invite Code
+ Give the invite code (%1$s) to the person you want to join the class. They can enter this code in the Respect app.
+
+ Open the Respect App
+ Download and open the Respect app on your device. Tap Get Started to begin the joining process.
+
+ Search for Your School
+ Type School name (e.g. %1$s) in the search bar. Select %1$s from the results list.
+
+ Enter the Invite Code
+ Tap on "I have an Invite Code". Enter the code shared by your teacher.
+
+ Confirm the class details shown on the screen. Create your profile and wait for teacher approval to join.
+ Review and Complete Setup
+
Invite settings
Invite multiple people
Approval required
diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt
index e386d7d54..5b4dc2bac 100644
--- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt
+++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt
@@ -4,9 +4,6 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import io.ktor.http.Url
import kotlinx.coroutines.delay
-import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase
-import world.respect.shared.domain.sharelink.LaunchShareLinkUseCase
-import world.respect.shared.domain.sharelink.LaunchSendSmsUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
@@ -33,12 +30,16 @@ import world.respect.datalayer.school.model.EnrollmentRoleEnum
import world.respect.datalayer.school.model.Invite2
import world.respect.datalayer.school.model.NewUserInvite
import world.respect.datalayer.school.model.PersonRoleEnum
+import world.respect.lib.opds.model.toStringMap
import world.respect.libutil.ext.CHAR_POOL_NUMBERS
import world.respect.libutil.ext.randomString
import world.respect.libutil.util.time.systemTimeInMillis
import world.respect.shared.domain.account.RespectAccountManager
import world.respect.shared.domain.clipboard.SetClipboardStringUseCase
import world.respect.shared.domain.createlink.CreateInviteLinkUseCase
+import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase
+import world.respect.shared.domain.sharelink.LaunchSendSmsUseCase
+import world.respect.shared.domain.sharelink.LaunchShareLinkUseCase
import world.respect.shared.generated.resources.Res
import world.respect.shared.generated.resources.invitation
import world.respect.shared.generated.resources.invite_person
@@ -55,9 +56,11 @@ import kotlin.time.Duration.Companion.minutes
* the time runs out, even though the invite itself hasn't changed.
*/
data class InvitePersonUiState(
+ val currentSliderPage: Int = 0,
val inviteOptions: InvitePerson.NewUserInviteOptions = InvitePerson.NewUserInviteOptions(null),
val invite: DataLoadState = DataLoadingState(),
val approvalRequired: Boolean = true,
+ val showSlider: Boolean = false,
val inviteUrl: Url? = null,
val selectedRole: PersonRoleEnum? = null,
val className: String? = null,
@@ -123,11 +126,11 @@ class InvitePersonViewModel(
val writableRoles = getWritableRolesListUseCase(currentPersonRole)
val selectedRole = writableRoles.firstOrNull() ?: PersonRoleEnum.STUDENT
-
_uiState.update {
it.copy(
roleOptions = writableRoles,
- selectedRole = selectedRole
+ selectedRole = selectedRole,
+ schoolName = accountManager.activeAccount?.school?.name?.toStringMap()?.values?.firstOrNull()
)
}
@@ -140,13 +143,17 @@ class InvitePersonViewModel(
uid = inviteUid,
loadParams = DataLoadParams()
).collectLatest { invite ->
+ val code = invite.dataOrNull()?.code.orEmpty()
+ val schoolName = accountManager.activeAccount?.school?.name?.
+ toStringMap()?.values?.firstOrNull()?:""
+
_uiState.update { prev ->
prev.copy(
invite = invite,
approvalRequired = invite.dataOrNull()?.isApprovalRequiredNow() ?: true,
inviteUrl = invite.dataOrNull()?.let {
createInviteLinkUseCase(it.code)
- },
+ }
)
}
@@ -245,7 +252,17 @@ class InvitePersonViewModel(
}
}
}
+ fun setSliderPage(page: Int) {
+ _uiState.update { it.copy(currentSliderPage = page) }
+ }
+ fun showSlider() {
+ _uiState.update { it.copy(showSlider = true) }
+ }
+
+ fun hideSlider() {
+ _uiState.update { it.copy(showSlider = false) }
+ }
fun onClickResetCode() {
_uiState.value.invite.dataOrNull()?.also { currentInvite ->
launchUpdateInvite(