diff --git a/Habitica/res/values/strings.xml b/Habitica/res/values/strings.xml index 7ba8677c8..25dabb879 100644 --- a/Habitica/res/values/strings.xml +++ b/Habitica/res/values/strings.xml @@ -276,7 +276,8 @@ • Your Task streaks and Habit counters will not reset\n • Your damage to the Quest boss or found collection items will remain pending until you check out of the Inn You don\'t have any %s - Lvl %1$d %2$s + Lv. %d + Lv. %1$d %2$s Level %1$d %2$s Warrior Rogue @@ -710,6 +711,7 @@ Sometimes starting fresh is the best option, Habitica can help! Latest Check In Total Checkins + %d Checkins Two-Handed It’s time to set your username! Login names are now unique usernames that will be visible beside your display name and used for invitations, chat @mentions, and messaging. @@ -913,6 +915,7 @@ Sign in with Apple Google Send Invites + Send Invite Cancelled Not Recurring Ending on %s @@ -1363,6 +1366,10 @@ List By Invite Here’s a list of Habiticans looking to join a Party + Invited + Invite with @username or email + Send an invite directly to Habiticans you know + Username or email address You diff --git a/Habitica/res/values/styles.xml b/Habitica/res/values/styles.xml index 1705730a1..e341ee6b8 100644 --- a/Habitica/res/values/styles.xml +++ b/Habitica/res/values/styles.xml @@ -515,7 +515,7 @@ true @null @android:style/Animation.Dialog - @color/background_brand + @color/transparent @null diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/api/ApiService.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/api/ApiService.kt index 5be8de488..66c52d8f8 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/api/ApiService.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/api/ApiService.kt @@ -8,6 +8,7 @@ import com.habitrpg.android.habitica.models.TeamPlan import com.habitrpg.android.habitica.models.WorldState import com.habitrpg.android.habitica.models.inventory.Equipment import com.habitrpg.android.habitica.models.inventory.Quest +import com.habitrpg.android.habitica.models.invitations.InviteResponse import com.habitrpg.android.habitica.models.members.Member import com.habitrpg.android.habitica.models.responses.BulkTaskScoringData import com.habitrpg.android.habitica.models.responses.BuyResponse @@ -265,7 +266,7 @@ interface ApiService { suspend fun seenMessages(@Path("gid") groupId: String): HabitResponse @POST("groups/{gid}/invite") - suspend fun inviteToGroup(@Path("gid") groupId: String, @Body inviteData: Map): HabitResponse> + suspend fun inviteToGroup(@Path("gid") groupId: String, @Body inviteData: Map): HabitResponse> @POST("groups/{gid}/reject-invite") suspend fun rejectGroupInvite(@Path("gid") groupId: String): HabitResponse diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt index 7680c74eb..28133bff4 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt @@ -8,6 +8,7 @@ import com.habitrpg.android.habitica.models.TeamPlan import com.habitrpg.android.habitica.models.WorldState import com.habitrpg.android.habitica.models.inventory.Equipment import com.habitrpg.android.habitica.models.inventory.Quest +import com.habitrpg.android.habitica.models.invitations.InviteResponse import com.habitrpg.android.habitica.models.members.Member import com.habitrpg.android.habitica.models.responses.BulkTaskScoringData import com.habitrpg.android.habitica.models.responses.BuyResponse @@ -166,7 +167,7 @@ interface ApiClient { suspend fun seenMessages(groupId: String): Void? - suspend fun inviteToGroup(groupId: String, inviteData: Map): List? + suspend fun inviteToGroup(groupId: String, inviteData: Map): List? suspend fun rejectGroupInvite(groupId: String): Void? diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/SocialRepository.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/SocialRepository.kt index c1c42f39c..9715cad7f 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/SocialRepository.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/SocialRepository.kt @@ -2,6 +2,7 @@ package com.habitrpg.android.habitica.data import com.habitrpg.android.habitica.models.Achievement import com.habitrpg.android.habitica.models.inventory.Quest +import com.habitrpg.android.habitica.models.invitations.InviteResponse import com.habitrpg.android.habitica.models.members.Member import com.habitrpg.android.habitica.models.responses.PostChatMessageResult import com.habitrpg.android.habitica.models.social.ChatMessage @@ -81,7 +82,7 @@ interface SocialRepository : BaseRepository { suspend fun getGroupMembers(id: String): Flow> suspend fun retrievePartyMembers(id: String, includeAllPublicFields: Boolean): List? - suspend fun inviteToGroup(id: String, inviteData: Map): List? + suspend fun inviteToGroup(id: String, inviteData: Map): List? suspend fun retrieveMember(userId: String?, fromHall: Boolean = false): Member? suspend fun retrieveMemberWithUsername(username: String?, fromHall: Boolean): Member? diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt index 99549ffda..05703c3b3 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt @@ -17,6 +17,7 @@ import com.habitrpg.android.habitica.models.TeamPlan import com.habitrpg.android.habitica.models.WorldState import com.habitrpg.android.habitica.models.inventory.Equipment import com.habitrpg.android.habitica.models.inventory.Quest +import com.habitrpg.android.habitica.models.invitations.InviteResponse import com.habitrpg.android.habitica.models.members.Member import com.habitrpg.android.habitica.models.responses.BulkTaskScoringData import com.habitrpg.android.habitica.models.responses.BuyResponse @@ -569,7 +570,7 @@ class ApiClientImpl( return process { apiService.seenMessages(groupId) } } - override suspend fun inviteToGroup(groupId: String, inviteData: Map): List? { + override suspend fun inviteToGroup(groupId: String, inviteData: Map): List? { return process { apiService.inviteToGroup(groupId, inviteData) } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/models/invitations/InviteResponse.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/models/invitations/InviteResponse.kt new file mode 100644 index 000000000..d8d18be2f --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/models/invitations/InviteResponse.kt @@ -0,0 +1,5 @@ +package com.habitrpg.android.habitica.models.invitations + +class InviteResponse { + +} diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/models/members/MemberPreferences.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/models/members/MemberPreferences.kt index d3e5391df..702634dc3 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/models/members/MemberPreferences.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/models/members/MemberPreferences.kt @@ -28,4 +28,5 @@ open class MemberPreferences : } } else null } + var language: String? = null } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartyInviteFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartyInviteFragment.kt index f6dc2e692..da79ee346 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartyInviteFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/social/party/PartyInviteFragment.kt @@ -4,15 +4,66 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.viewModels +import com.habitrpg.android.habitica.R import com.habitrpg.android.habitica.data.SocialRepository import com.habitrpg.android.habitica.data.UserRepository import com.habitrpg.android.habitica.databinding.FragmentComposeBinding -import com.habitrpg.android.habitica.helpers.AppConfigManager +import com.habitrpg.android.habitica.models.invitations.InviteResponse 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.LoadingButtonState +import com.habitrpg.common.habitica.extensions.isValidEmail +import com.habitrpg.common.habitica.helpers.launchCatching import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.UUID import javax.inject.Inject @HiltViewModel @@ -21,13 +72,30 @@ class PartyInviteViewModel @Inject constructor( userViewModel : MainUserViewModel, val socialRepository : SocialRepository ) : BaseViewModel(userRepository, userViewModel) { + val invites = mutableStateListOf("") + + suspend fun sendInvites() : List? { + val inviteMap = mapOf>( + "emails" to mutableListOf(), + "uuids" to mutableListOf(), + "usernames" to mutableListOf() + ) + for (invite in invites) { + if (invite.isValidEmail()) { + inviteMap["emails"]?.add(invite) + } else if (UUID.fromString(invite) != null) { + inviteMap["uuids"]?.add(invite) + } else if (invite.isNotBlank()) { + inviteMap["usernames"]?.add(invite) + } + } + return socialRepository.inviteToGroup("party", inviteMap) + } } @AndroidEntryPoint class PartyInviteFragment : BaseFragment() { - - @Inject - lateinit var configManager : AppConfigManager + val viewModel : PartyInviteViewModel by viewModels() override var binding : FragmentComposeBinding? = null @@ -38,7 +106,117 @@ class PartyInviteFragment : BaseFragment() { return FragmentComposeBinding.inflate(inflater, container, false) } - override fun onViewCreated(view : View, savedInstanceState : Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onCreateView( + inflater : LayoutInflater, + container : ViewGroup?, + savedInstanceState : Bundle? + ) : View? { + val view = super.onCreateView(inflater, container, savedInstanceState) + binding?.composeView?.setContent { + HabiticaTheme { + PartyInviteView(viewModel) + } + } + return view + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PartyInviteView( + viewModel : PartyInviteViewModel +) { + var inviteButtonState : LoadingButtonState by remember { mutableStateOf(LoadingButtonState.CONTENT) } + val scope = rememberCoroutineScope() + val scrollableState = rememberScrollState() + val invites = viewModel.invites + + LazyColumn( + Modifier + .fillMaxWidth() + .padding(14.dp) + .scrollable(scrollableState, Orientation.Vertical)) { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier + .fillMaxWidth() + .padding(top = 22.dp, bottom = 14.dp) + ) { + Text( + stringResource(R.string.invite_with_username_email), + color = HabiticaTheme.colors.textPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + Text( + stringResource(R.string.habiticans_send_invite), + color = HabiticaTheme.colors.textSecondary + ) + } + } + items(invites.indices.toList()) { index -> + val invite = invites[index] + val transition = updateTransition(invites.size - 1 == index, label = "isLast") + val rotation = transition.animateFloat( + label = "isAssigned", + transitionSpec = { spring(Spring.DampingRatioLowBouncy, Spring.StiffnessMediumLow) } + ) { + if (it) 135f else 0f + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier + .fillMaxWidth() + .padding(0.dp, 4.dp) + .background(HabiticaTheme.colors.windowBackground, HabiticaTheme.shapes.medium) + .padding(4.dp, 4.dp) + .animateItemPlacement()) { + Image( + painterResource(R.drawable.ic_close_white_24dp), + null, + colorFilter = ColorFilter.tint(HabiticaTheme.colors.textPrimary), + modifier = Modifier + .rotate(rotation.value) + .size(32.dp) + .padding(3.dp) + ) + TextField( + value = invite, onValueChange = { value -> + if (invites.size - 1 == index && invites[index].isBlank()) { + viewModel.invites.add("") + } + viewModel.invites[index] = value + }, + singleLine = true, + placeholder = { Text(stringResource(R.string.username_or_email)) }, + colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent), + modifier = Modifier + .onFocusChanged { + if (!it.isFocused) { + if (viewModel.invites[index].isBlank() && viewModel.invites.size - 1 != index && viewModel.invites.size > 1) { + viewModel.invites.removeAt(index) + } + } + } + ) + } + } + item { + InviteButton( + state = if (invites.isNotEmpty()) inviteButtonState else LoadingButtonState.DISABLED, + modifier = Modifier.fillMaxWidth(), + onClick = { + inviteButtonState = LoadingButtonState.LOADING + scope.launchCatching({ + inviteButtonState = LoadingButtonState.FAILED + }) { + val responses = viewModel.sendInvites() + if (responses?.isNotEmpty() == true) { + inviteButtonState = LoadingButtonState.SUCCESS + viewModel.invites.clear() + } else { + inviteButtonState = LoadingButtonState.FAILED + } + } + }) + } } } 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 7d4dc2087..aa9c9c487 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 @@ -4,22 +4,38 @@ import android.os.Bundle 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.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.fragment.app.viewModels @@ -28,14 +44,21 @@ import com.habitrpg.android.habitica.R import com.habitrpg.android.habitica.data.SocialRepository import com.habitrpg.android.habitica.data.UserRepository import com.habitrpg.android.habitica.databinding.FragmentComposeBinding +import com.habitrpg.android.habitica.models.invitations.InviteResponse import com.habitrpg.android.habitica.models.members.Member 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.ClassIcon +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.getTranslatedClassName import com.habitrpg.common.habitica.helpers.launchCatching import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.Locale import javax.inject.Inject @HiltViewModel @@ -43,9 +66,8 @@ class PartySeekingViewModel @Inject constructor( userRepository : UserRepository, userViewModel : MainUserViewModel, val socialRepository : SocialRepository -): BaseViewModel(userRepository, userViewModel) { +) : BaseViewModel(userRepository, userViewModel) { val isRefreshing = mutableStateOf(false) - val seekingUsers = mutableStateOf>(emptyList()) init { @@ -59,13 +81,21 @@ class PartySeekingViewModel @Inject constructor( isRefreshing.value = false } } + + suspend fun inviteUser(member : Member) : InviteResponse? { + return socialRepository.inviteToGroup( + "party", mapOf( + "uuids" to listOf(member.id ?: "") + ) + )?.firstOrNull() + } } @AndroidEntryPoint -class PartySeekingFragment: BaseFragment() { - val viewModel: PartySeekingViewModel by viewModels() +class PartySeekingFragment : BaseFragment() { + val viewModel : PartySeekingViewModel by viewModels() - override var binding: FragmentComposeBinding? = null + override var binding : FragmentComposeBinding? = null override fun createBinding( inflater : LayoutInflater, container : ViewGroup? @@ -87,39 +117,183 @@ class PartySeekingFragment: BaseFragment() { return view } - override fun onResume() { - super.onResume() + override fun onStart() { + super.onStart() viewModel.retrieveUsers() } } @Composable -fun PartySeekingListItem(user: Member, - modifier : Modifier = Modifier) { - Column(modifier.fillMaxWidth()) { - Text(user.username ?: "") +fun ClassText( + className : String?, + hasClass : Boolean, + fontSize : TextUnit, + modifier : Modifier = Modifier, + iconSize : Dp? = null +) { + if (!hasClass) return + val classColor = colorResource( + when (className) { + "warrior" -> R.color.text_red + "wizard" -> R.color.text_blue + "rogue" -> R.color.text_brand + "healer" -> R.color.text_yellow + else -> R.color.text_primary + } + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + ClassIcon( + className = className, + hasClass = true, + modifier = Modifier.size(iconSize ?: with(LocalDensity.current) { + fontSize.toDp() + }) + ) + Text( + getTranslatedClassName(LocalContext.current.resources, className), + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + color = classColor + ) } } -@OptIn(ExperimentalMaterialApi::class) @Composable -fun PartySeekingView( - viewModel: PartySeekingViewModel, +fun InviteButton( + state : LoadingButtonState, + onClick : () -> Unit, modifier : Modifier = Modifier ) { - val users: List by viewModel.seekingUsers + 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, + onInvite : suspend (Member) -> InviteResponse? +) { + 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.preferences?.disableClasses == false + ) + } + 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 + ) + } + } + val scope = rememberCoroutineScope() + var inviteState : LoadingButtonState by remember { mutableStateOf(LoadingButtonState.CONTENT) } + InviteButton(state = inviteState, modifier = Modifier.fillMaxWidth(), onClick = { + scope.launchCatching({ + inviteState = LoadingButtonState.FAILED + }) { + inviteState = LoadingButtonState.LOADING + val response = onInvite(user) + if (response != null) { + inviteState = LoadingButtonState.SUCCESS + } else { + inviteState = LoadingButtonState.FAILED + } + } + }) + } +} + +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +fun PartySeekingView( + viewModel : PartySeekingViewModel, + modifier : Modifier = Modifier +) { + val users : List by viewModel.seekingUsers val refreshing by viewModel.isRefreshing val pullRefreshState = rememberPullRefreshState(refreshing, { viewModel.retrieveUsers() }) - LazyColumn(modifier.pullRefresh(pullRefreshState)) { - item { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth().padding(top = 22.dp, bottom = 14.dp)) { - Text(stringResource(R.string.find_more_members), color = HabiticaTheme.colors.textPrimary, fontSize = 16.sp, fontWeight = FontWeight.Medium) - Text(stringResource(R.string.habiticans_looking_party), color = HabiticaTheme.colors.textSecondary) + Box(modifier = modifier.pullRefresh(pullRefreshState)) { + LazyColumn { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier + .fillMaxWidth() + .padding(top = 22.dp, bottom = 14.dp) + ) { + Text( + stringResource(R.string.find_more_members), + color = HabiticaTheme.colors.textPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + Text( + stringResource(R.string.habiticans_looking_party), + color = HabiticaTheme.colors.textSecondary + ) + } + } + items(users) { + PartySeekingListItem(user = it, modifier = Modifier.animateItemPlacement()) { member -> + return@PartySeekingListItem viewModel.inviteUser(member) + } } } - items(users) { - PartySeekingListItem(user = it) - } + PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/theme/HabiticaTheme.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/theme/HabiticaTheme.kt index b3e7ba015..72ca2276a 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/theme/HabiticaTheme.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/theme/HabiticaTheme.kt @@ -143,7 +143,11 @@ object HabiticaTheme { tintedUiMain = Color(context.getThemeColor(R.attr.tintedUiMain)), tintedUiSub = Color(context.getThemeColor(R.attr.tintedUiSub)), tintedUiDetails = Color(context.getThemeColor(R.attr.tintedUiDetails)), - pixelArtBackground = Color(context.getThemeColor(R.attr.colorContentBackground)) + pixelArtBackground = Color(context.getThemeColor(R.attr.colorContentBackground)), + errorBackground = Color(ContextCompat.getColor(context, R.color.background_red)), + errorColor = Color(ContextCompat.getColor(context, R.color.text_red)), + successBackground = Color(ContextCompat.getColor(context, R.color.background_green)), + successColor = Color(ContextCompat.getColor(context, R.color.text_green)) ) } } @@ -160,8 +164,13 @@ class HabiticaColors( val tintedUiMain: Color, val tintedUiSub: Color, val tintedUiDetails: Color, - val pixelArtBackground: Color + val pixelArtBackground: Color, + val errorBackground : Color, + val errorColor : Color, + val successBackground : Color, + val successColor : Color ) { + @Composable fun textPrimaryFor(task: Task?): Color { return colorResource((if (isSystemInDarkTheme()) task?.extraExtraLightTaskColor else task?.extraDarkTaskColor) ?: R.color.text_primary) diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewHolders/GroupMemberViewHolder.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewHolders/GroupMemberViewHolder.kt index af164f368..aa920b647 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewHolders/GroupMemberViewHolder.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewHolders/GroupMemberViewHolder.kt @@ -72,7 +72,7 @@ class GroupMemberViewHolder(itemView: View) : androidx.recyclerview.widget.Recyc binding.displayNameTextview.tier = user.contributor?.level ?: 0 if (user.hasClass) { - binding.sublineTextview.text = itemView.context.getString(R.string.user_level_with_class, user.stats?.lvl, user.stats?.getTranslatedClassName(itemView.context.resources)) + binding.sublineTextview.text = itemView.context.getString(R.string.user_level_with_class, user.stats?.lvl, getTranslatedClassName(itemView.context.resources, user.stats?.habitClass)) } else { binding.sublineTextview.text = itemView.context.getString(R.string.user_level, user.stats?.lvl) } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/AppHeaderView.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/AppHeaderView.kt index 751007b1b..af7a25d75 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/AppHeaderView.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/AppHeaderView.kt @@ -53,7 +53,6 @@ import com.habitrpg.android.habitica.models.user.Profile import com.habitrpg.android.habitica.models.user.Stats import com.habitrpg.android.habitica.models.user.User import com.habitrpg.shared.habitica.models.Avatar -import com.habitrpg.shared.habitica.models.AvatarStats import kotlin.random.Random @Composable @@ -62,9 +61,10 @@ fun UserLevelText(user: Avatar) { stringResource( id = R.string.user_level_with_class, user.stats?.lvl ?: 0, - user.stats?.getTranslatedClassName( - LocalContext.current.resources - ) ?: "" + getTranslatedClassName( + LocalContext.current.resources, + user.stats?.habitClass + ) ) } else { stringResource(id = R.string.user_level, user.stats?.lvl ?: 0) @@ -77,8 +77,8 @@ fun UserLevelText(user: Avatar) { ) } -fun AvatarStats.getTranslatedClassName(resources: Resources): String { - return when (habitClass) { +fun getTranslatedClassName(resources: Resources, className: String?): String { + return when (className) { Stats.HEALER -> resources.getString(R.string.healer) Stats.ROGUE -> resources.getString(R.string.rogue) Stats.WARRIOR -> resources.getString(R.string.warrior) @@ -258,7 +258,7 @@ fun AppHeaderView( ) CurrencyText( "gems", - user.gemCount?.toDouble() ?: 0.0, + user.gemCount.toDouble(), modifier = Modifier.clickable { MainNavigationController.navigate(R.id.gemPurchaseActivity) }, 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 new file mode 100644 index 000000000..d76b582e7 --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/LoadingButton.kt @@ -0,0 +1,85 @@ +package com.habitrpg.android.habitica.ui.views + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ButtonElevation +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.unit.dp +import com.habitrpg.android.habitica.R +import com.habitrpg.android.habitica.ui.theme.HabiticaTheme + + +enum class LoadingButtonState { + CONTENT, + DISABLED, + LOADING, + FAILED, + SUCCESS +} + +@Composable +fun LoadingButton( + state : LoadingButtonState, + onClick : () -> Unit, + modifier : Modifier = Modifier, + elevation : ButtonElevation? = ButtonDefaults.elevation(0.dp), + shape : Shape = MaterialTheme.shapes.medium, + border : BorderStroke? = null, + colors : ButtonColors = ButtonDefaults.buttonColors( + backgroundColor = HabiticaTheme.colors.tintedUiSub, + contentColor = Color.White + ), + contentPadding : PaddingValues = ButtonDefaults.ContentPadding, + successContent : @Composable RowScope.() -> Unit, + content : @Composable RowScope.() -> Unit +) { + val buttonColors = if (state == LoadingButtonState.FAILED) { + ButtonDefaults.buttonColors(backgroundColor = HabiticaTheme.colors.errorBackground) + } else if (state == LoadingButtonState.SUCCESS) { + ButtonDefaults.outlinedButtonColors( + backgroundColor = HabiticaTheme.colors.successColor, + contentColor = HabiticaTheme.colors.successColor + ) + } else colors + Button( + { + if (state == LoadingButtonState.CONTENT || state == LoadingButtonState.FAILED) { + onClick() + } + }, + modifier.requiredHeight(40.dp), + state != LoadingButtonState.DISABLED, + elevation = elevation, + shape = shape, + border = border, + colors = buttonColors, + contentPadding = contentPadding + ) { + when (state) { + LoadingButtonState.LOADING -> CircularProgressIndicator( + color = Color.White, + modifier = Modifier.size(16.dp) + ) + LoadingButtonState.SUCCESS -> successContent() + LoadingButtonState.FAILED -> Image( + painterResource(R.drawable.failed_loading), + stringResource(R.string.failed) + ) + else -> content() + } + } +} diff --git a/common/src/main/java/com/habitrpg/common/habitica/extensions/String-Extensions.kt b/common/src/main/java/com/habitrpg/common/habitica/extensions/String-Extensions.kt index 5bff2abae..c33bffdd4 100644 --- a/common/src/main/java/com/habitrpg/common/habitica/extensions/String-Extensions.kt +++ b/common/src/main/java/com/habitrpg/common/habitica/extensions/String-Extensions.kt @@ -5,6 +5,7 @@ import android.text.Spannable import android.text.SpannableString import android.text.TextUtils import android.text.util.Linkify +import android.util.Patterns import java.util.Locale fun String.fromHtml(): CharSequence { @@ -31,3 +32,6 @@ fun String.removeZeroWidthSpace(): String { fun String.localizedCapitalize(): String { return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } } + + +fun CharSequence?.isValidEmail() = !isNullOrEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches() diff --git a/common/src/main/java/com/habitrpg/common/habitica/views/AvatarView.kt b/common/src/main/java/com/habitrpg/common/habitica/views/AvatarView.kt index 54edd717e..b1330c3e4 100644 --- a/common/src/main/java/com/habitrpg/common/habitica/views/AvatarView.kt +++ b/common/src/main/java/com/habitrpg/common/habitica/views/AvatarView.kt @@ -303,7 +303,7 @@ class AvatarView : FrameLayout { layerMap[LayerType.BACK] = outfit.back } if (outfit.isAvailable(outfit.armor)) { - layerMap[LayerType.ARMOR] = prefs.size + "_" + outfit.armor + layerMap[LayerType.ARMOR] = (prefs.size ?: "broad") + "_" + outfit.armor } if (outfit.isAvailable(outfit.body)) { layerMap[LayerType.BODY] = outfit.body @@ -326,7 +326,7 @@ class AvatarView : FrameLayout { } layerMap[LayerType.SKIN] = "skin_" + prefs.skin + if (prefs.sleep) "_sleep" else "" - layerMap[LayerType.SHIRT] = prefs.size + "_shirt_" + prefs.shirt + layerMap[LayerType.SHIRT] = (prefs.size ?: "broad") + "_shirt_" + prefs.shirt layerMap[LayerType.HEAD_0] = "head_0" if (hair != null) { diff --git a/common/src/main/res/values/colors.xml b/common/src/main/res/values/colors.xml index 8fef01cab..182b918e2 100644 --- a/common/src/main/res/values/colors.xml +++ b/common/src/main/res/values/colors.xml @@ -157,4 +157,5 @@ @color/green_10 @color/blue_10 @color/teal_10 - \ No newline at end of file + @color/gray_500 +