diff --git a/Habitica/res/values/strings.xml b/Habitica/res/values/strings.xml index 574378e09..92f524b3c 100644 --- a/Habitica/res/values/strings.xml +++ b/Habitica/res/values/strings.xml @@ -1383,6 +1383,8 @@ New gear is released during the seasonal Galas. Until then, theres %d pieces of gear in the Enchanted Armoire to find! New gear is released during the seasonal Galas. The Enchanted Armoire gets new stock every month too! Unlock %s gear and skills + Rescind Invite + Rescinded You diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartySeekingFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartySeekingFragment.kt index 4b66f8dc8..44f3fc5ca 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartySeekingFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartySeekingFragment.kt @@ -5,18 +5,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.foundation.ExperimentalFoundationApi -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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.Divider +import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Text import androidx.compose.material.pullrefresh.pullRefresh @@ -28,7 +25,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -55,18 +52,19 @@ import com.habitrpg.android.habitica.ui.fragments.BaseFragment import com.habitrpg.android.habitica.ui.theme.HabiticaTheme import com.habitrpg.android.habitica.ui.viewmodels.BaseViewModel import com.habitrpg.android.habitica.ui.viewmodels.MainUserViewModel -import com.habitrpg.android.habitica.ui.views.ClassText -import com.habitrpg.android.habitica.ui.views.ComposableAvatarView import com.habitrpg.android.habitica.ui.views.LoadingButton import com.habitrpg.android.habitica.ui.views.LoadingButtonState import com.habitrpg.android.habitica.ui.views.progress.HabiticaCircularProgressView import com.habitrpg.android.habitica.ui.views.progress.HabiticaPullRefreshIndicator +import com.habitrpg.android.habitica.ui.views.social.PartySeekingListItem import com.habitrpg.common.habitica.helpers.launchCatching import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import java.util.Locale import javax.inject.Inject +import kotlin.time.DurationUnit +import kotlin.time.toDuration @HiltViewModel class PartySeekingViewModel @Inject constructor( @@ -96,6 +94,10 @@ class PartySeekingViewModel @Inject constructor( ) )?.firstOrNull() } + + suspend fun rescindInvite(member : Member) : Member? { + return socialRepository.removeMemberFromGroup("party", member.id)?.firstOrNull() + } } @AndroidEntryPoint @@ -129,93 +131,24 @@ class PartySeekingFragment : BaseFragment() { fun InviteButton( state : LoadingButtonState, onClick : () -> Unit, - modifier : Modifier = Modifier -) { - LoadingButton(state = state, onClick = onClick, modifier = modifier, successContent = { - Text(stringResource(R.string.invited)) - }) { - Text(stringResource(R.string.send_invite)) - } -} - -@Composable -fun PartySeekingListItem( - user : Member, modifier : Modifier = Modifier, - inviteState : LoadingButtonState = LoadingButtonState.LOADING, - onInvite : (Member) -> Unit + isAlreadyInvited: Boolean = false, ) { - Column( - modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 4.dp) - .background(HabiticaTheme.colors.windowBackground, HabiticaTheme.shapes.large) - .padding(14.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.Top - ) { - ComposableAvatarView(user, Modifier.size(94.dp, 98.dp)) - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.fillMaxWidth() - ) { - Text( - user.displayName, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = HabiticaTheme.colors.textPrimary - ) - Text( - user.formattedUsername ?: "", - fontSize = 14.sp, - color = HabiticaTheme.colors.textTertiary - ) - Divider( - color = colorResource(R.color.divider_color), - thickness = 1.dp, - modifier = Modifier.padding(vertical = 4.dp) - ) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - stringResource(R.string.level_abbreviated, user.stats?.lvl ?: 0), - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - color = HabiticaTheme.colors.textPrimary - ) - ClassText( - user.stats?.habitClass, - fontSize = 14.sp, - iconSize = 18.dp, - hasClass = user.hasClass - ) - } - Text( - stringResource(R.string.x_checkins, user.loginIncentives), - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - color = HabiticaTheme.colors.textPrimary - ) - Text( - Locale(user.preferences?.language ?: "en").getDisplayName(Locale.getDefault()), - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - color = HabiticaTheme.colors.textPrimary - ) - } + LoadingButton(state = state, onClick = onClick, colors = ButtonDefaults.buttonColors( + backgroundColor = if (isAlreadyInvited) HabiticaTheme.colors.errorBackground else HabiticaTheme.colors.tintedUiSub, + contentColor = Color.White, + ), modifier = modifier, successContent = { + if (isAlreadyInvited) { + Text(stringResource(R.string.invited)) + } else { + Text(stringResource(R.string.rescinded)) + } + }) { + if (isAlreadyInvited) { + Text(stringResource(R.string.rescind_invite)) + } else { + Text(stringResource(R.string.send_invite)) } - InviteButton( - state = inviteState, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - onClick = { - onInvite(user) - }) } } @@ -230,6 +163,7 @@ fun PartySeekingView( val pullRefreshState = rememberPullRefreshState(refreshing, { pageData.refresh() }) val scope = rememberCoroutineScope() + val successfulInvites = remember { mutableListOf() } val inviteStates = remember { mutableMapOf() } Box( @@ -276,17 +210,25 @@ fun PartySeekingView( PartySeekingListItem( user = it, inviteState = inviteStates[it.id] ?: LoadingButtonState.CONTENT, + isInvited = successfulInvites.contains(it.id), modifier = Modifier.animateItemPlacement() ) { member -> scope.launchCatching({ inviteStates[member.id] = LoadingButtonState.FAILED }) { inviteStates[member.id] = LoadingButtonState.LOADING - val response = viewModel.inviteUser(member) - inviteStates[member.id] = if (response != null) { - LoadingButtonState.SUCCESS + val response = if (successfulInvites.contains(member.id)) viewModel.inviteUser(member) else viewModel.inviteUser(member) + if (response != null) { + if (successfulInvites.contains(member.id)) { + successfulInvites.remove(member.id) + } else { + successfulInvites.add(member.id) + } + inviteStates[member.id] = LoadingButtonState.SUCCESS + delay(4.toDuration(DurationUnit.SECONDS)) + inviteStates[member.id] = LoadingButtonState.CONTENT } else { - LoadingButtonState.FAILED + inviteStates[member.id] = LoadingButtonState.FAILED } } } @@ -304,7 +246,6 @@ fun PartySeekingView( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - HabiticaCircularProgressView() } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/LoadingButton.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/LoadingButton.kt index 026b6fabf..6a7cdb249 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/LoadingButton.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/LoadingButton.kt @@ -31,6 +31,7 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonElevation import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -44,8 +45,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.habitrpg.android.habitica.R import com.habitrpg.android.habitica.ui.theme.HabiticaTheme import kotlinx.coroutines.delay @@ -125,53 +129,55 @@ fun LoadingButton( colors = buttonColors, contentPadding = PaddingValues(0.dp) ) { - AnimatedContent( - targetState = state, - transitionSpec = { - val isInitialShowingContent = - initialState == LoadingButtonState.CONTENT || initialState == LoadingButtonState.DISABLED || (initialState == LoadingButtonState.SUCCESS && successContent == null) - val isTargetShowingContent = - targetState == LoadingButtonState.CONTENT || targetState == LoadingButtonState.DISABLED || (targetState == LoadingButtonState.SUCCESS && successContent == null) - if (targetState == LoadingButtonState.FAILED) { - fadeIn( - animationSpec = tween(220, delayMillis = 90) - ) + - slideInHorizontally( - animationSpec = spring( - dampingRatio = 0.2f, - stiffness = StiffnessMediumLow, - ) - ) with - fadeOut(animationSpec = tween(90)) - } else if (isInitialShowingContent && isTargetShowingContent) { - fadeIn() with fadeOut() - } else { - fadeIn(animationSpec = tween(220, delayMillis = 90)) + - scaleIn( - initialScale = 0.92f, - animationSpec = tween(220, delayMillis = 90, FastOutSlowInEasing) - ) with - fadeOut(animationSpec = tween(90)) - } - }, - contentAlignment = Alignment.Center, - modifier = Modifier.padding(contentPadding) - ) { state -> - when (state) { - LoadingButtonState.LOADING -> - CircularProgressIndicator( - color = contentColor.value, - modifier = Modifier.size(16.dp) - ) + ProvideTextStyle(value = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.SemiBold)) { + AnimatedContent( + targetState = state, + transitionSpec = { + val isInitialShowingContent = + initialState == LoadingButtonState.CONTENT || initialState == LoadingButtonState.DISABLED || (initialState == LoadingButtonState.SUCCESS && successContent == null) + val isTargetShowingContent = + targetState == LoadingButtonState.CONTENT || targetState == LoadingButtonState.DISABLED || (targetState == LoadingButtonState.SUCCESS && successContent == null) + if (targetState == LoadingButtonState.FAILED) { + fadeIn( + animationSpec = tween(220, delayMillis = 90) + ) + + slideInHorizontally( + animationSpec = spring( + dampingRatio = 0.2f, + stiffness = StiffnessMediumLow, + ) + ) with + fadeOut(animationSpec = tween(90)) + } else if (isInitialShowingContent && isTargetShowingContent) { + fadeIn() with fadeOut() + } else { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + scaleIn( + initialScale = 0.92f, + animationSpec = tween(220, delayMillis = 90, FastOutSlowInEasing) + ) with + fadeOut(animationSpec = tween(90)) + } + }, + contentAlignment = Alignment.Center, + modifier = Modifier.padding(contentPadding) + ) { state -> + when (state) { + LoadingButtonState.LOADING -> + CircularProgressIndicator( + color = contentColor.value, + modifier = Modifier.size(16.dp) + ) - LoadingButtonState.SUCCESS -> successContent?.let { it() } ?: content() - LoadingButtonState.FAILED -> - failedContent?.let { it() } ?: Image( - painterResource(R.drawable.failed_loading), - stringResource(R.string.failed), - Modifier.padding(horizontal = 8.dp) - ) - else -> content() + LoadingButtonState.SUCCESS -> successContent?.let { it() } ?: content() + LoadingButtonState.FAILED -> + failedContent?.let { it() } ?: Image( + painterResource(R.drawable.failed_loading), + stringResource(R.string.failed), + Modifier.padding(horizontal = 8.dp) + ) + else -> content() + } } } } @@ -208,7 +214,10 @@ private fun Preview() { }, content = { Text("Do something") }, modifier = Modifier.fillMaxWidth()) - LoadingButton(LoadingButtonState.LOADING, {}, content = { + LoadingButton(LoadingButtonState.LOADING, {}, colors = ButtonDefaults.buttonColors( + backgroundColor = HabiticaTheme.colors.successBackground, + contentColor = Color.White + ), content = { Text("Do something") }) LoadingButton(LoadingButtonState.LOADING, {}, content = { diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/UsernameLabel.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/UsernameLabel.kt index 153e26711..e49cd80f6 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/UsernameLabel.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/UsernameLabel.kt @@ -9,6 +9,21 @@ import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.habitrpg.common.habitica.R import com.habitrpg.common.habitica.models.PlayerTier @@ -45,7 +60,7 @@ class UsernameLabel @JvmOverloads constructor( tierIconView.visibility = View.GONE } else { tierIconView.visibility = View.VISIBLE - tierIconView.setImageBitmap(com.habitrpg.android.habitica.ui.views.HabiticaIconsHelper.imageOfContributorBadge(value.toFloat(), isNPC)) + tierIconView.setImageBitmap(HabiticaIconsHelper.imageOfContributorBadge(value.toFloat(), isNPC)) } } @@ -68,3 +83,24 @@ class UsernameLabel @JvmOverloads constructor( addView(tierIconView, iconViewParams) } } + + +@Composable +fun ComposableUsernameLabel( + username: String, + tier: Int, + modifier : Modifier = Modifier, + isNPC: Boolean = false +) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + ProvideTextStyle(value = TextStyle(fontWeight = FontWeight.SemiBold)) { + Text(username, color = if (isNPC) colorResource(id = R.color.contributor_npc) else Color(PlayerTier.getColorForTier(LocalContext.current, tier))) + if (tier > 0) { + Image( + bitmap = HabiticaIconsHelper.imageOfContributorBadge(tier.toFloat(), isNPC).asImageBitmap(), + contentDescription = null + ) + } + } + } +} diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/social/PartySeekingListItem.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/social/PartySeekingListItem.kt new file mode 100644 index 000000000..6f130a113 --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/social/PartySeekingListItem.kt @@ -0,0 +1,187 @@ +package com.habitrpg.android.habitica.ui.views.social + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Divider +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.habitrpg.android.habitica.R +import com.habitrpg.android.habitica.models.auth.LocalAuthentication +import com.habitrpg.android.habitica.models.members.Member +import com.habitrpg.android.habitica.models.members.MemberFlags +import com.habitrpg.android.habitica.models.members.MemberPreferences +import com.habitrpg.android.habitica.models.user.Authentication +import com.habitrpg.android.habitica.models.user.ContributorInfo +import com.habitrpg.android.habitica.models.user.Profile +import com.habitrpg.android.habitica.models.user.Stats +import com.habitrpg.android.habitica.ui.fragments.social.party.InviteButton +import com.habitrpg.android.habitica.ui.theme.HabiticaTheme +import com.habitrpg.android.habitica.ui.views.ClassText +import com.habitrpg.android.habitica.ui.views.ComposableAvatarView +import com.habitrpg.android.habitica.ui.views.ComposableUsernameLabel +import com.habitrpg.android.habitica.ui.views.LoadingButtonState +import java.util.Locale +import kotlin.random.Random + +@Composable +fun PartySeekingListItem( + user : Member, + modifier : Modifier = Modifier, + inviteState : LoadingButtonState = LoadingButtonState.LOADING, + isInvited: Boolean = false, + onInvite : (Member) -> Unit +) { + Column( + modifier + .fillMaxWidth() + .padding(horizontal = 14.dp) + .padding(bottom = 4.dp) + .background(HabiticaTheme.colors.windowBackground, HabiticaTheme.shapes.large) + .padding(14.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.Top + ) { + ComposableAvatarView(user, + Modifier + .size(94.dp, 98.dp) + .padding(top = 4.dp)) + Column( + verticalArrangement = Arrangement.Top, + modifier = Modifier.fillMaxWidth() + ) { + ProvideTextStyle(value = TextStyle(fontSize = 14.sp)) { + ComposableUsernameLabel( + user.displayName, + user.contributor?.level ?: 0 + ) + } + Text( + user.formattedUsername ?: "", + fontSize = 14.sp, + color = HabiticaTheme.colors.textTertiary + ) + Divider( + color = colorResource(R.color.divider_color), + thickness = 1.dp, + modifier = Modifier.padding(vertical = 6.dp) + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource(R.string.level_abbreviated, user.stats?.lvl ?: 0), + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = HabiticaTheme.colors.textPrimary + ) + ClassText( + user.stats?.habitClass, + fontSize = 14.sp, + iconSize = 18.dp, + hasClass = user.hasClass + ) + } + Text( + stringResource(R.string.x_checkins, user.loginIncentives), + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = HabiticaTheme.colors.textPrimary + ) + Text( + Locale(user.preferences?.language ?: "en").getDisplayName(Locale.getDefault()), + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = HabiticaTheme.colors.textPrimary + ) + } + } + InviteButton( + state = inviteState, + isAlreadyInvited = isInvited, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + onClick = { + onInvite(user) + }) + } +} + +private class MemberProvider: PreviewParameterProvider { + + private fun generateMember() : Member { + val member = Member() + member.profile = Profile() + member.profile?.name = "User" + member.authentication = Authentication() + member.authentication?.localAuthentication = LocalAuthentication() + member.authentication?.localAuthentication?.username = "username" + member.preferences = MemberPreferences() + member.preferences?.disableClasses = false + member.flags = MemberFlags() + member.flags?.classSelected = true + member.stats = Stats() + member.stats?.hp = Random.nextDouble(from = 0.0, until = 50.0) + member.stats?.maxHealth = 50 + member.stats?.toNextLevel = Random.nextInt(from = 0, until = 10000) + member.stats?.exp = + Random.nextDouble(until = (member.stats?.toNextLevel ?: 0).toDouble()) + member.stats?.maxMP = Random.nextInt(from = 0, until = 10000) + member.stats?.mp = Random.nextDouble(until = (member.stats?.maxMP ?: 0).toDouble()) + member.stats?.lvl = Random.nextInt(from = 0, until = 9999) + return member + } + + override val values : Sequence + get() { + val list = mutableListOf() + val earlyMember = generateMember() + earlyMember.stats?.lvl = 5 + list.add(earlyMember) + val needsClass = generateMember() + needsClass.stats?.lvl = 24 + needsClass.stats?.habitClass = "healer" + needsClass.flags?.classSelected = false + needsClass.contributor = ContributorInfo() + needsClass.contributor?.level = 4 + list.add(needsClass) + val classDisabled = generateMember() + classDisabled.stats?.lvl = 24 + classDisabled.stats?.habitClass = "rogue" + classDisabled.preferences?.disableClasses = true + list.add(classDisabled) + val subscriber = generateMember() + subscriber.stats?.habitClass = "warrior" + list.add(subscriber) + val onlyHourglasses = generateMember() + onlyHourglasses.stats?.habitClass = "wizard" + list.add(onlyHourglasses) + return list.asSequence() + } +} + +@Preview +@Composable +private fun Preview(@PreviewParameter(MemberProvider::class) data: Member) { + PartySeekingListItem(user = data, onInvite = {}) +} diff --git a/common/src/main/res/values-night/colors.xml b/common/src/main/res/values-night/colors.xml index 3a0c54faa..81e86ef21 100644 --- a/common/src/main/res/values-night/colors.xml +++ b/common/src/main/res/values-night/colors.xml @@ -19,4 +19,5 @@ @color/blue_100 @color/teal_100 #40502B94 + @color/gray_100 diff --git a/version.properties b/version.properties index 0cbb8ab3d..c5da90060 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ NAME=4.2 -CODE=5951 \ No newline at end of file +CODE=5961 \ No newline at end of file