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(