Skip to content

Commit

Permalink
FTUE - Choose a display picture (#5323)
Browse files Browse the repository at this point in the history
* adding tests around the onboarding view model
- cases for the personalisation and display name actions

* adding base choose name fragment with UI

* add click handling for the display name actions

* adding tests around the onboarding view model
- cases for the personalisation and display name actions

* adding barebones profile picture fragment with ability to select a user avatar

* extracting uri filename resolving to a class which can be injected
- includes tests

* updating upstream avatar on profile picture save and continue step
- moves the personalisation state to a dedicated model to allow for back and forth state restoration

* adding test case for skipping profile picture setting

* taking the profile loading into account when rendering the onboarding loading

* extracting method for the handling of the profile picture selection

* adding dedicated camera icon for choosing profile picture

* adding toolbar to back to profile picture page
- this toolbar will fade in with the fragment as it sits at the fragment level, probably worth revisiting once more pages have a toolbar

* changing edit/add picture icon based on if we're already selected an image

* making use of debounced clicks to avoid potential extra clicks

* making the avatar height and camera icon relative percentage based
- also makes the avatar itself clicking, including a foreground ripple

* fixing formatting

* making use of fake session id for user id assertion

* using a real  matrix id syntax for the fake session user id

* removing duplicated dimens

* using self closing imageview tag
  • Loading branch information
ouchadam authored Mar 7, 2022
1 parent 9af2f1c commit 9a02543
Show file tree
Hide file tree
Showing 26 changed files with 803 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
package im.vector.lib.multipicker.utils

import android.database.Cursor
import androidx.core.database.getStringOrNull

fun Cursor.getColumnIndexOrNull(column: String): Int? {
return getColumnIndex(column).takeIf { it != -1 }
}

fun Cursor.readStringColumnOrNull(column: String): String? {
return getColumnIndexOrNull(column)?.let { getStringOrNull(it) }
}
3 changes: 3 additions & 0 deletions library/ui-styles/src/main/res/values/dimens.xml
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,7 @@

<item name="ftue_auth_carousel_item_spacing" format="float" type="dimen">0.01</item>
<item name="ftue_auth_carousel_item_image_height" format="float" type="dimen">0.35</item>

<item name="ftue_auth_profile_picture_height" format="float" type="dimen">0.15</item>
<item name="ftue_auth_profile_picture_icon_height" format="float" type="dimen">0.05</item>
</resources>
6 changes: 6 additions & 0 deletions vector/src/main/java/im/vector/app/core/di/FragmentModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import im.vector.app.features.matrixto.MatrixToUserFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthAccountCreatedFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthCaptchaFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseDisplayNameFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseProfilePictureFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthGenericTextInputFormFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthLoginFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment
Expand Down Expand Up @@ -485,6 +486,11 @@ interface FragmentModule {
@FragmentKey(FtueAuthChooseDisplayNameFragment::class)
fun bindFtueAuthChooseDisplayNameFragment(fragment: FtueAuthChooseDisplayNameFragment): Fragment

@Binds
@IntoMap
@FragmentKey(FtueAuthChooseProfilePictureFragment::class)
fun bindFtueAuthChooseProfilePictureFragment(fragment: FtueAuthChooseProfilePictureFragment): Fragment

@Binds
@IntoMap
@FragmentKey(UserListFragment::class)
Expand Down
12 changes: 12 additions & 0 deletions vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package im.vector.app.features.home

import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.net.Uri
import android.widget.ImageView
import androidx.annotation.AnyThread
import androidx.annotation.ColorInt
Expand Down Expand Up @@ -48,6 +49,7 @@ import org.matrix.android.sdk.api.auth.login.LoginProfileInfo
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.util.MatrixItem
import java.io.File
import javax.inject.Inject

/**
Expand Down Expand Up @@ -100,6 +102,16 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
DrawableImageViewTarget(imageView))
}

@UiThread
fun render(matrixItem: MatrixItem, localUri: Uri?, imageView: ImageView) {
val placeholder = getPlaceholderDrawable(matrixItem)
GlideApp.with(imageView)
.load(localUri?.let { File(localUri.path!!) })
.apply(RequestOptions.circleCropTransform())
.placeholder(placeholder)
.into(imageView)
}

@UiThread
fun render(mappedContact: MappedContact, imageView: ImageView) {
// Create a Fake MatrixItem, for the placeholder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package im.vector.app.features.onboarding

import android.net.Uri
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.login.LoginConfig
import im.vector.app.features.login.ServerType
Expand Down Expand Up @@ -76,4 +77,7 @@ sealed class OnboardingAction : VectorViewModelAction {

data class UpdateDisplayName(val displayName: String) : OnboardingAction()
object UpdateDisplayNameSkipped : OnboardingAction()
data class ProfilePictureSelected(val uri: Uri) : OnboardingAction()
object SaveSelectedProfilePicture : OnboardingAction()
object UpdateProfilePictureSkipped : OnboardingAction()
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,6 @@ sealed class OnboardingViewEvents : VectorViewEvents {
object OnPersonalizeProfile : OnboardingViewEvents()
object OnDisplayNameUpdated : OnboardingViewEvents()
object OnDisplayNameSkipped : OnboardingViewEvents()
object OnPersonalizationComplete : OnboardingViewEvents()
object OnBack : OnboardingViewEvents()
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixIdFailure
import org.matrix.android.sdk.api.session.Session
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.CancellationException

/**
Expand All @@ -80,6 +81,7 @@ class OnboardingViewModel @AssistedInject constructor(
private val homeServerHistoryService: HomeServerHistoryService,
private val vectorFeatures: VectorFeatures,
private val analyticsTracker: AnalyticsTracker,
private val uriFilenameResolver: UriFilenameResolver,
private val vectorOverrides: VectorOverrides
) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) {

Expand Down Expand Up @@ -157,6 +159,9 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.PostViewEvent -> _viewEvents.post(action.viewEvent)
is OnboardingAction.UpdateDisplayName -> updateDisplayName(action.displayName)
OnboardingAction.UpdateDisplayNameSkipped -> _viewEvents.post(OnboardingViewEvents.OnDisplayNameSkipped)
OnboardingAction.UpdateProfilePictureSkipped -> _viewEvents.post(OnboardingViewEvents.OnPersonalizationComplete)
is OnboardingAction.ProfilePictureSelected -> handleProfilePictureSelected(action)
OnboardingAction.SaveSelectedProfilePicture -> updateProfilePicture()
}.exhaustive
}

Expand Down Expand Up @@ -899,14 +904,59 @@ class OnboardingViewModel @AssistedInject constructor(
val activeSession = activeSessionHolder.getActiveSession()
try {
activeSession.setDisplayName(activeSession.myUserId, displayName)
setState { copy(asyncDisplayName = Success(Unit)) }
setState {
copy(
asyncDisplayName = Success(Unit),
personalizationState = personalizationState.copy(displayName = displayName)
)
}
_viewEvents.post(OnboardingViewEvents.OnDisplayNameUpdated)
} catch (error: Throwable) {
setState { copy(asyncDisplayName = Fail(error)) }
_viewEvents.post(OnboardingViewEvents.Failure(error))
}
}
}

private fun handleProfilePictureSelected(action: OnboardingAction.ProfilePictureSelected) {
setState {
copy(personalizationState = personalizationState.copy(selectedPictureUri = action.uri))
}
}

private fun updateProfilePicture() {
withState { state ->
when (val pictureUri = state.personalizationState.selectedPictureUri) {
null -> _viewEvents.post(OnboardingViewEvents.Failure(NullPointerException("picture uri is missing from state")))
else -> {
setState { copy(asyncProfilePicture = Loading()) }
viewModelScope.launch {
val activeSession = activeSessionHolder.getActiveSession()
try {
activeSession.updateAvatar(
activeSession.myUserId,
pictureUri,
uriFilenameResolver.getFilenameFromUri(pictureUri) ?: UUID.randomUUID().toString()
)
setState {
copy(
asyncProfilePicture = Success(Unit),
)
}
onProfilePictureSaved()
} catch (error: Throwable) {
setState { copy(asyncProfilePicture = Fail(error)) }
_viewEvents.post(OnboardingViewEvents.Failure(error))
}
}
}
}
}
}

private fun onProfilePictureSaved() {
_viewEvents.post(OnboardingViewEvents.OnPersonalizationComplete)
}
}

private fun LoginMode.supportsSignModeScreen(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

package im.vector.app.features.onboarding

import android.net.Uri
import android.os.Parcelable
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
Expand All @@ -25,6 +27,7 @@ import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.ServerType
import im.vector.app.features.login.SignMode
import kotlinx.parcelize.Parcelize

data class OnboardingViewState(
val asyncLoginAction: Async<Unit> = Uninitialized,
Expand All @@ -33,6 +36,7 @@ data class OnboardingViewState(
val asyncResetMailConfirmed: Async<Unit> = Uninitialized,
val asyncRegistration: Async<Unit> = Uninitialized,
val asyncDisplayName: Async<Unit> = Uninitialized,
val asyncProfilePicture: Async<Unit> = Uninitialized,

@PersistState
val onboardingFlow: OnboardingFlow? = null,
Expand Down Expand Up @@ -65,6 +69,9 @@ data class OnboardingViewState(
val loginModeSupportedTypes: List<String> = emptyList(),
val knownCustomHomeServersUrls: List<String> = emptyList(),
val isForceLoginFallbackEnabled: Boolean = false,

@PersistState
val personalizationState: PersonalizationState = PersonalizationState()
) : MavericksState {

fun isLoading(): Boolean {
Expand All @@ -73,7 +80,8 @@ data class OnboardingViewState(
asyncResetPassword is Loading ||
asyncResetMailConfirmed is Loading ||
asyncRegistration is Loading ||
asyncDisplayName is Loading
asyncDisplayName is Loading ||
asyncProfilePicture is Loading
}

fun isAuthTaskCompleted(): Boolean {
Expand All @@ -86,3 +94,9 @@ enum class OnboardingFlow {
SignUp,
SignInSignUp
}

@Parcelize
data class PersonalizationState(
val displayName: String? = null,
val selectedPictureUri: Uri? = null
) : Parcelable
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.app.features.onboarding

import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import im.vector.lib.multipicker.utils.readStringColumnOrNull
import javax.inject.Inject

class UriFilenameResolver @Inject constructor(private val context: Context) {

fun getFilenameFromUri(uri: Uri): String? {
val fallback = uri.path?.substringAfterLast('/')
return when (uri.scheme) {
"content" -> readResolvedDisplayName(uri) ?: fallback
else -> fallback
}
}

private fun readResolvedDisplayName(uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.takeIf { cursor.moveToFirst() }
?.readStringColumnOrNull(OpenableColumns.DISPLAY_NAME)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import im.vector.app.core.platform.SimpleTextWatcher
import im.vector.app.databinding.FragmentFtueDisplayNameBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import javax.inject.Inject

class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuthFragment<FragmentFtueDisplayNameBinding>() {
Expand All @@ -41,7 +42,6 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth
}

private fun setupViews() {
views.displayNameSubmit.isEnabled = views.displayNameInput.hasContentEmpty()
views.displayNameInput.editText?.addTextChangedListener(object : SimpleTextWatcher() {
override fun afterTextChanged(s: Editable) {
val newContent = s.toString()
Expand All @@ -58,10 +58,7 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth
}
}

views.displayNameSubmit.debouncedClicks {
updateDisplayName()
}

views.displayNameSubmit.debouncedClicks { updateDisplayName() }
views.displayNameSkip.debouncedClicks { viewModel.handle(OnboardingAction.UpdateDisplayNameSkipped) }
}

Expand All @@ -70,6 +67,11 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth
viewModel.handle(OnboardingAction.UpdateDisplayName(newDisplayName))
}

override fun updateWithState(state: OnboardingViewState) {
views.displayNameInput.editText?.setText(state.personalizationState.displayName)
views.displayNameSubmit.isEnabled = views.displayNameInput.hasContentEmpty()
}

override fun resetViewModel() {
// Nothing to do
}
Expand Down
Loading

0 comments on commit 9a02543

Please sign in to comment.