diff --git a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.avatarpicker.AvatarPickerTest.avatarPickerListLoaded.png b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.avatarpicker.AvatarPickerTest.avatarPickerListLoaded.png index b099f6483..178ab5656 100644 Binary files a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.avatarpicker.AvatarPickerTest.avatarPickerListLoaded.png and b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.avatarpicker.AvatarPickerTest.avatarPickerListLoaded.png differ diff --git a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.avatarpicker.AvatarPickerTest.avatarPickerListLoadedDark.png b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.avatarpicker.AvatarPickerTest.avatarPickerListLoadedDark.png index 49ef869b7..8a4986969 100644 Binary files a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.avatarpicker.AvatarPickerTest.avatarPickerListLoadedDark.png and b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.avatarpicker.AvatarPickerTest.avatarPickerListLoadedDark.png differ diff --git a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.components.ProfileCardTest.profileCardDarkMode.png b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.components.ProfileCardTest.profileCardDarkMode.png index 912300b31..e824da06f 100644 Binary files a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.components.ProfileCardTest.profileCardDarkMode.png and b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.components.ProfileCardTest.profileCardDarkMode.png differ diff --git a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.components.ProfileCardTest.profileCardLightMode.png b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.components.ProfileCardTest.profileCardLightMode.png index 604a41608..c02bce983 100644 Binary files a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.components.ProfileCardTest.profileCardLightMode.png and b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.components.ProfileCardTest.profileCardLightMode.png differ diff --git a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageAuthorizing.png b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageAuthorizing.png index 12629a769..1476b6f27 100644 Binary files a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageAuthorizing.png and b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageAuthorizing.png differ diff --git a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageDark.png b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageDark.png index c2ac29e70..d9b6adb05 100644 Binary files a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageDark.png and b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageDark.png differ diff --git a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageLight.png b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageLight.png index a3a4fd5e2..5f0f0637e 100644 Binary files a/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageLight.png and b/gravatar-quickeditor/screenshotTests/roborazzi/com.gravatar.quickeditor.ui.oauth.OAuthPageTest.oAuthPageLight.png differ diff --git a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/avatarpicker/AvatarPicker.kt b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/avatarpicker/AvatarPicker.kt index e517554bc..9b92677e8 100644 --- a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/avatarpicker/AvatarPicker.kt +++ b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/avatarpicker/AvatarPicker.kt @@ -214,6 +214,7 @@ internal fun AvatarPicker(uiState: AvatarPickerUiState, onEvent: (AvatarPickerEv } ProfileCard( profile = uiState.profile, + email = uiState.email, avatarCacheBuster = uiState.avatarCacheBuster.toString(), modifier = Modifier.padding(horizontal = 16.dp), ) diff --git a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/components/ProfileCard.kt b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/components/ProfileCard.kt index 33ecaa50e..fe5c17c93 100644 --- a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/components/ProfileCard.kt +++ b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/components/ProfileCard.kt @@ -21,20 +21,21 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.gravatar.AvatarQueryOptions +import com.gravatar.DefaultAvatarOption import com.gravatar.ImageRating -import com.gravatar.extensions.avatarUrl import com.gravatar.extensions.defaultProfile import com.gravatar.restapi.models.Profile +import com.gravatar.types.Email import com.gravatar.ui.GravatarTheme import com.gravatar.ui.components.ComponentState import com.gravatar.ui.components.ProfileSummary import com.gravatar.ui.components.atomic.Avatar import com.gravatar.ui.components.atomic.ViewProfileButton -import com.gravatar.ui.components.transform @Composable internal fun ProfileCard( profile: ComponentState?, + email: Email, modifier: Modifier = Modifier, avatarCacheBuster: String? = null, ) { @@ -49,16 +50,15 @@ internal fun ProfileCard( avatar = { val sizePx = with(LocalDensity.current) { 72.dp.roundToPx() } Avatar( - state = profile.transform { - avatarUrl( - AvatarQueryOptions { - preferredSize = sizePx - rating = ImageRating.X - }, - ).url(avatarCacheBuster).toString() + email = email, + avatarQueryOptions = AvatarQueryOptions { + preferredSize = sizePx + rating = ImageRating.X + defaultAvatarOption = DefaultAvatarOption.Status404 }, size = 72.dp, modifier = Modifier.clip(CircleShape), + cacheBuster = avatarCacheBuster, ) }, viewProfile = { state -> @@ -106,6 +106,7 @@ private fun ProfileCardPreview() { profile = ComponentState.Loaded( defaultProfile(hash = "dfadf", "John Travolta"), ), + email = Email("john.adams@test.com"), modifier = Modifier.padding(20.dp), ) } diff --git a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthPage.kt b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthPage.kt index aa1492d8e..58cfe2c82 100644 --- a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthPage.kt +++ b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthPage.kt @@ -172,7 +172,9 @@ internal fun OauthPage( uiState.profile?.let { ProfileCard( profile = it, + email = email, modifier = Modifier.padding(top = 16.dp), + avatarCacheBuster = uiState.avatarCacheBuster, ) } val sectionModifier = Modifier.padding(top = 24.dp, bottom = 10.dp) diff --git a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthUiState.kt b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthUiState.kt index ec567daac..d14bcb9fa 100644 --- a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthUiState.kt +++ b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthUiState.kt @@ -6,6 +6,7 @@ import com.gravatar.ui.components.ComponentState internal data class OAuthUiState( val status: OAuthStatus = OAuthStatus.LoginRequired, val profile: ComponentState? = null, + val avatarCacheBuster: String? = null, ) internal sealed class OAuthStatus { diff --git a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthViewModel.kt b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthViewModel.kt index 0b72ebeda..1053f4854 100644 --- a/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthViewModel.kt +++ b/gravatar-quickeditor/src/main/java/com/gravatar/quickeditor/ui/oauth/OAuthViewModel.kt @@ -9,6 +9,8 @@ import androidx.lifecycle.viewmodel.CreationExtras import com.gravatar.quickeditor.QuickEditorContainer import com.gravatar.quickeditor.data.storage.ProfileStorage import com.gravatar.quickeditor.data.storage.TokenStorage +import com.gravatar.quickeditor.ui.time.Clock +import com.gravatar.quickeditor.ui.time.SystemClock import com.gravatar.services.ErrorType import com.gravatar.services.GravatarResult import com.gravatar.services.ProfileService @@ -28,8 +30,9 @@ internal class OAuthViewModel( private val tokenStorage: TokenStorage, private val profileStorage: ProfileStorage, private val profileService: ProfileService, + clock: Clock, ) : ViewModel() { - private val _uiState = MutableStateFlow(OAuthUiState()) + private val _uiState = MutableStateFlow(OAuthUiState(avatarCacheBuster = clock.getTimeMillis().toString())) val uiState: StateFlow = _uiState.asStateFlow() private val _actions = Channel(Channel.BUFFERED) @@ -127,6 +130,7 @@ internal class OAuthViewModelFactory( tokenStorage = QuickEditorContainer.getInstance().tokenStorage, profileStorage = QuickEditorContainer.getInstance().profileStorage, profileService = QuickEditorContainer.getInstance().profileService, + clock = SystemClock(), ) as T } } diff --git a/gravatar-quickeditor/src/test/java/com/gravatar/quickeditor/ui/components/ProfileCardTest.kt b/gravatar-quickeditor/src/test/java/com/gravatar/quickeditor/ui/components/ProfileCardTest.kt index 999f8a092..527737da7 100644 --- a/gravatar-quickeditor/src/test/java/com/gravatar/quickeditor/ui/components/ProfileCardTest.kt +++ b/gravatar-quickeditor/src/test/java/com/gravatar/quickeditor/ui/components/ProfileCardTest.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.gravatar.extensions.defaultProfile import com.gravatar.quickeditor.ui.gravatarScreenshotTest +import com.gravatar.types.Email import com.gravatar.ui.components.ComponentState import com.gravatar.uitestutils.RoborazziTest import org.junit.Test @@ -22,6 +23,7 @@ class ProfileCardTest : RoborazziTest() { gravatarScreenshotTest { ProfileCard( profile = ComponentState.Loaded(profile), + email = Email("email"), modifier = Modifier.padding(20.dp), ) } @@ -32,6 +34,7 @@ class ProfileCardTest : RoborazziTest() { fun profileCardDarkMode() = gravatarScreenshotTest { ProfileCard( profile = ComponentState.Loaded(profile), + email = Email("email"), modifier = Modifier.padding(20.dp), ) } diff --git a/gravatar-quickeditor/src/test/java/com/gravatar/quickeditor/ui/oauth/OAuthViewModelTest.kt b/gravatar-quickeditor/src/test/java/com/gravatar/quickeditor/ui/oauth/OAuthViewModelTest.kt index 78cd6378a..bdb53b95d 100644 --- a/gravatar-quickeditor/src/test/java/com/gravatar/quickeditor/ui/oauth/OAuthViewModelTest.kt +++ b/gravatar-quickeditor/src/test/java/com/gravatar/quickeditor/ui/oauth/OAuthViewModelTest.kt @@ -5,6 +5,7 @@ import app.cash.turbine.test import com.gravatar.quickeditor.data.storage.ProfileStorage import com.gravatar.quickeditor.data.storage.TokenStorage import com.gravatar.quickeditor.ui.CoroutineTestRule +import com.gravatar.quickeditor.ui.time.Clock import com.gravatar.restapi.models.Profile import com.gravatar.services.ErrorType import com.gravatar.services.GravatarResult @@ -29,6 +30,7 @@ class OAuthViewModelTest { private val tokenStorage = mockk() private val profileService = mockk() private val profileStorage = mockk() + private val clock = mockk() private val savedStateHandle = SavedStateHandle() private lateinit var viewModel: OAuthViewModel @@ -42,6 +44,7 @@ class OAuthViewModelTest { coEvery { profileService.retrieveCatching(email) } returns GravatarResult.Success(mockk()) coEvery { profileStorage.getLoginIntroShown(any()) } returns false coEvery { profileStorage.setLoginIntroShown(any()) } returns Unit + coEvery { clock.getTimeMillis() } returns 0 viewModel = createViewModel() } @@ -239,6 +242,6 @@ class OAuthViewModelTest { } private fun createViewModel(): OAuthViewModel { - return OAuthViewModel(savedStateHandle, email, tokenStorage, profileStorage, profileService) + return OAuthViewModel(savedStateHandle, email, tokenStorage, profileStorage, profileService, clock) } } diff --git a/gravatar-ui/api/gravatar-ui.api b/gravatar-ui/api/gravatar-ui.api index 37329b88d..52e4a7c73 100644 --- a/gravatar-ui/api/gravatar-ui.api +++ b/gravatar-ui/api/gravatar-ui.api @@ -147,6 +147,7 @@ public final class com/gravatar/ui/components/atomic/AboutMeKt { public final class com/gravatar/ui/components/atomic/AvatarKt { public static final fun Avatar-EUb7tLY (Lcom/gravatar/restapi/models/Profile;FLandroidx/compose/ui/Modifier;Lcom/gravatar/AvatarQueryOptions;ZLandroidx/compose/runtime/Composer;II)V + public static final fun Avatar-EUb7tLY (Lcom/gravatar/types/Email;FLandroidx/compose/ui/Modifier;Lcom/gravatar/AvatarQueryOptions;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V public static final fun Avatar-EUb7tLY (Lcom/gravatar/ui/components/ComponentState;FLandroidx/compose/ui/Modifier;Lcom/gravatar/AvatarQueryOptions;ZLandroidx/compose/runtime/Composer;II)V public static final fun Avatar-uFdPcIQ (Lcom/gravatar/ui/components/ComponentState;FLandroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } diff --git a/gravatar-ui/src/main/java/com/gravatar/ui/components/atomic/Avatar.kt b/gravatar-ui/src/main/java/com/gravatar/ui/components/atomic/Avatar.kt index 0e9f505fc..f4200f31b 100644 --- a/gravatar-ui/src/main/java/com/gravatar/ui/components/atomic/Avatar.kt +++ b/gravatar-ui/src/main/java/com/gravatar/ui/components/atomic/Avatar.kt @@ -4,16 +4,23 @@ import android.content.res.Configuration import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.gravatar.AvatarQueryOptions +import com.gravatar.AvatarUrl import com.gravatar.extensions.avatarUrl import com.gravatar.extensions.defaultProfile import com.gravatar.restapi.models.Profile +import com.gravatar.types.Email import com.gravatar.ui.R import com.gravatar.ui.components.ComponentState import com.gravatar.ui.components.LoadingToLoadedProfileStatePreview @@ -144,11 +151,70 @@ private fun EmptyAvatar(size: Dp, modifier: Modifier = Modifier) { private fun Avatar(model: Any?, size: Dp, modifier: Modifier) { AsyncImage( model = model, - contentDescription = "User profile image", + contentDescription = stringResource(R.string.gravatar_ui_avatar_content_description), modifier = modifier.size(size), ) } +private enum class AvatarState { + None, + Loading, + Loaded, + Placeholder, +} + +/** + * Atomic Avatar composable that displays a user's avatar that is generated from the user's email address. + * A skeleton overlay will be shown while loading the image. + * + * @param email The user's email address + * @param size The size of the avatar + * @param modifier Composable modifier + * @param avatarQueryOptions Options to customize the avatar query + * @param cacheBuster Random string value to force a cache bust + */ +@Composable +public fun Avatar( + email: Email, + size: Dp, + modifier: Modifier = Modifier, + avatarQueryOptions: AvatarQueryOptions? = null, + cacheBuster: String? = null, +) { + var state by remember { mutableStateOf(AvatarState.None) } + val sizePx = with(LocalDensity.current) { size.roundToPx() } + Box( + modifier = modifier.size(size), + ) { + AsyncImage( + model = AvatarUrl( + hash = email.hash(), + avatarQueryOptions = AvatarQueryOptions { + preferredSize = sizePx + rating = avatarQueryOptions?.rating + forceDefaultAvatar = avatarQueryOptions?.forceDefaultAvatar + defaultAvatarOption = avatarQueryOptions?.defaultAvatarOption + }, + ).url(cacheBuster).toString(), + contentDescription = stringResource(R.string.gravatar_ui_avatar_content_description), + onLoading = { + state = AvatarState.Loading + }, + onError = { + state = AvatarState.Placeholder + }, + onSuccess = { + state = AvatarState.Loaded + }, + ) + when (state) { + AvatarState.Loading -> SkeletonAvatar(size = size) + AvatarState.Placeholder -> EmptyAvatar(size = size) + else -> Unit + } + } +} + @Preview @Composable private fun AvatarPreview() { diff --git a/gravatar-ui/src/main/res/values/strings.xml b/gravatar-ui/src/main/res/values/strings.xml index 600e1bc5d..edd361c08 100644 --- a/gravatar-ui/src/main/res/values/strings.xml +++ b/gravatar-ui/src/main/res/values/strings.xml @@ -6,4 +6,5 @@ Add your location, pronouns, etc Claim profile Location + User profile image