Initial party seeking version

This commit is contained in:
Phillip Thelen 2023-03-13 18:18:26 +01:00
parent 70a2d400e7
commit cdc105bec7
17 changed files with 516 additions and 48 deletions

View file

@ -276,7 +276,8 @@
• Your Task streaks and Habit counters will not reset\n • Your Task streaks and Habit counters will not reset\n
&#8226; Your damage to the Quest boss or found collection items will remain pending until you check out of the Inn</string> &#8226; Your damage to the Quest boss or found collection items will remain pending until you check out of the Inn</string>
<string name="empty_items">You don\'t have any %s</string> <string name="empty_items">You don\'t have any %s</string>
<string name="user_level_with_class">Lvl %1$d %2$s</string> <string name="level_abbreviated">Lv. %d</string>
<string name="user_level_with_class">Lv. %1$d %2$s</string>
<string name="user_level_with_class_unabbreviated">Level %1$d %2$s</string> <string name="user_level_with_class_unabbreviated">Level %1$d %2$s</string>
<string name="warrior">Warrior</string> <string name="warrior">Warrior</string>
<string name="rogue">Rogue</string> <string name="rogue">Rogue</string>
@ -710,6 +711,7 @@
<string name="month_reminder_text">Sometimes starting fresh is the best option, Habitica can help!</string> <string name="month_reminder_text">Sometimes starting fresh is the best option, Habitica can help!</string>
<string name="last_login">Latest Check In</string> <string name="last_login">Latest Check In</string>
<string name="total_checkins">Total Checkins</string> <string name="total_checkins">Total Checkins</string>
<string name="x_checkins">%d Checkins</string>
<string name="two_handed">Two-Handed</string> <string name="two_handed">Two-Handed</string>
<string name="usernamePromptTitle">Its time to set your username!</string> <string name="usernamePromptTitle">Its time to set your username!</string>
<string name="usernamePromptBody">Login names are now unique usernames that will be visible beside your display name and used for invitations, chat @mentions, and messaging.</string> <string name="usernamePromptBody">Login names are now unique usernames that will be visible beside your display name and used for invitations, chat @mentions, and messaging.</string>
@ -913,6 +915,7 @@
<string name="apple_sign_in">Sign in with Apple</string> <string name="apple_sign_in">Sign in with Apple</string>
<string name="google">Google</string> <string name="google">Google</string>
<string name="send_invites">Send Invites</string> <string name="send_invites">Send Invites</string>
<string name="send_invite">Send Invite</string>
<string name="cancelled">Cancelled</string> <string name="cancelled">Cancelled</string>
<string name="not_recurring">Not Recurring</string> <string name="not_recurring">Not Recurring</string>
<string name="ending_on">Ending on %s</string> <string name="ending_on">Ending on %s</string>
@ -1363,6 +1366,10 @@
<string name="list">List</string> <string name="list">List</string>
<string name="by_invite">By Invite</string> <string name="by_invite">By Invite</string>
<string name="habiticans_looking_party">Heres a list of Habiticans looking to join a Party</string> <string name="habiticans_looking_party">Heres a list of Habiticans looking to join a Party</string>
<string name="invited">Invited</string>
<string name="invite_with_username_email">Invite with @username or email</string>
<string name="habiticans_send_invite">Send an invite directly to Habiticans you know</string>
<string name="username_or_email">Username or email address</string>
<plurals name="you_x_others"> <plurals name="you_x_others">
<item quantity="zero">You</item> <item quantity="zero">You</item>

View file

@ -515,7 +515,7 @@
<item name="android:windowIsFloating">true</item> <item name="android:windowIsFloating">true</item>
<item name="android:windowContentOverlay">@null</item> <item name="android:windowContentOverlay">@null</item>
<item name="android:windowAnimationStyle">@android:style/Animation.Dialog</item> <item name="android:windowAnimationStyle">@android:style/Animation.Dialog</item>
<item name="android:windowBackground">@color/background_brand</item> <item name="android:windowBackground">@color/transparent</item>
<item name="android:colorBackgroundCacheHint">@null</item> <item name="android:colorBackgroundCacheHint">@null</item>
</style> </style>

View file

@ -8,6 +8,7 @@ import com.habitrpg.android.habitica.models.TeamPlan
import com.habitrpg.android.habitica.models.WorldState import com.habitrpg.android.habitica.models.WorldState
import com.habitrpg.android.habitica.models.inventory.Equipment import com.habitrpg.android.habitica.models.inventory.Equipment
import com.habitrpg.android.habitica.models.inventory.Quest 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.members.Member
import com.habitrpg.android.habitica.models.responses.BulkTaskScoringData import com.habitrpg.android.habitica.models.responses.BulkTaskScoringData
import com.habitrpg.android.habitica.models.responses.BuyResponse import com.habitrpg.android.habitica.models.responses.BuyResponse
@ -265,7 +266,7 @@ interface ApiService {
suspend fun seenMessages(@Path("gid") groupId: String): HabitResponse<Void> suspend fun seenMessages(@Path("gid") groupId: String): HabitResponse<Void>
@POST("groups/{gid}/invite") @POST("groups/{gid}/invite")
suspend fun inviteToGroup(@Path("gid") groupId: String, @Body inviteData: Map<String, Any>): HabitResponse<List<Void>> suspend fun inviteToGroup(@Path("gid") groupId: String, @Body inviteData: Map<String, Any>): HabitResponse<List<InviteResponse>>
@POST("groups/{gid}/reject-invite") @POST("groups/{gid}/reject-invite")
suspend fun rejectGroupInvite(@Path("gid") groupId: String): HabitResponse<Void> suspend fun rejectGroupInvite(@Path("gid") groupId: String): HabitResponse<Void>

View file

@ -8,6 +8,7 @@ import com.habitrpg.android.habitica.models.TeamPlan
import com.habitrpg.android.habitica.models.WorldState import com.habitrpg.android.habitica.models.WorldState
import com.habitrpg.android.habitica.models.inventory.Equipment import com.habitrpg.android.habitica.models.inventory.Equipment
import com.habitrpg.android.habitica.models.inventory.Quest 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.members.Member
import com.habitrpg.android.habitica.models.responses.BulkTaskScoringData import com.habitrpg.android.habitica.models.responses.BulkTaskScoringData
import com.habitrpg.android.habitica.models.responses.BuyResponse import com.habitrpg.android.habitica.models.responses.BuyResponse
@ -166,7 +167,7 @@ interface ApiClient {
suspend fun seenMessages(groupId: String): Void? suspend fun seenMessages(groupId: String): Void?
suspend fun inviteToGroup(groupId: String, inviteData: Map<String, Any>): List<Void>? suspend fun inviteToGroup(groupId: String, inviteData: Map<String, Any>): List<InviteResponse>?
suspend fun rejectGroupInvite(groupId: String): Void? suspend fun rejectGroupInvite(groupId: String): Void?

View file

@ -2,6 +2,7 @@ package com.habitrpg.android.habitica.data
import com.habitrpg.android.habitica.models.Achievement import com.habitrpg.android.habitica.models.Achievement
import com.habitrpg.android.habitica.models.inventory.Quest 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.members.Member
import com.habitrpg.android.habitica.models.responses.PostChatMessageResult import com.habitrpg.android.habitica.models.responses.PostChatMessageResult
import com.habitrpg.android.habitica.models.social.ChatMessage import com.habitrpg.android.habitica.models.social.ChatMessage
@ -81,7 +82,7 @@ interface SocialRepository : BaseRepository {
suspend fun getGroupMembers(id: String): Flow<List<Member>> suspend fun getGroupMembers(id: String): Flow<List<Member>>
suspend fun retrievePartyMembers(id: String, includeAllPublicFields: Boolean): List<Member>? suspend fun retrievePartyMembers(id: String, includeAllPublicFields: Boolean): List<Member>?
suspend fun inviteToGroup(id: String, inviteData: Map<String, Any>): List<Void>? suspend fun inviteToGroup(id: String, inviteData: Map<String, Any>): List<InviteResponse>?
suspend fun retrieveMember(userId: String?, fromHall: Boolean = false): Member? suspend fun retrieveMember(userId: String?, fromHall: Boolean = false): Member?
suspend fun retrieveMemberWithUsername(username: String?, fromHall: Boolean): Member? suspend fun retrieveMemberWithUsername(username: String?, fromHall: Boolean): Member?

View file

@ -17,6 +17,7 @@ import com.habitrpg.android.habitica.models.TeamPlan
import com.habitrpg.android.habitica.models.WorldState import com.habitrpg.android.habitica.models.WorldState
import com.habitrpg.android.habitica.models.inventory.Equipment import com.habitrpg.android.habitica.models.inventory.Equipment
import com.habitrpg.android.habitica.models.inventory.Quest 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.members.Member
import com.habitrpg.android.habitica.models.responses.BulkTaskScoringData import com.habitrpg.android.habitica.models.responses.BulkTaskScoringData
import com.habitrpg.android.habitica.models.responses.BuyResponse import com.habitrpg.android.habitica.models.responses.BuyResponse
@ -569,7 +570,7 @@ class ApiClientImpl(
return process { apiService.seenMessages(groupId) } return process { apiService.seenMessages(groupId) }
} }
override suspend fun inviteToGroup(groupId: String, inviteData: Map<String, Any>): List<Void>? { override suspend fun inviteToGroup(groupId: String, inviteData: Map<String, Any>): List<InviteResponse>? {
return process { apiService.inviteToGroup(groupId, inviteData) } return process { apiService.inviteToGroup(groupId, inviteData) }
} }

View file

@ -0,0 +1,5 @@
package com.habitrpg.android.habitica.models.invitations
class InviteResponse {
}

View file

@ -28,4 +28,5 @@ open class MemberPreferences :
} }
} else null } else null
} }
var language: String? = null
} }

View file

@ -4,15 +4,66 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup 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.SocialRepository
import com.habitrpg.android.habitica.data.UserRepository import com.habitrpg.android.habitica.data.UserRepository
import com.habitrpg.android.habitica.databinding.FragmentComposeBinding 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.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.BaseViewModel
import com.habitrpg.android.habitica.ui.viewmodels.MainUserViewModel 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.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -21,13 +72,30 @@ class PartyInviteViewModel @Inject constructor(
userViewModel : MainUserViewModel, userViewModel : MainUserViewModel,
val socialRepository : SocialRepository val socialRepository : SocialRepository
) : BaseViewModel(userRepository, userViewModel) { ) : BaseViewModel(userRepository, userViewModel) {
val invites = mutableStateListOf("")
suspend fun sendInvites() : List<InviteResponse>? {
val inviteMap = mapOf<String, MutableList<String>>(
"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 @AndroidEntryPoint
class PartyInviteFragment : BaseFragment<FragmentComposeBinding>() { class PartyInviteFragment : BaseFragment<FragmentComposeBinding>() {
val viewModel : PartyInviteViewModel by viewModels()
@Inject
lateinit var configManager : AppConfigManager
override var binding : FragmentComposeBinding? = null override var binding : FragmentComposeBinding? = null
@ -38,7 +106,117 @@ class PartyInviteFragment : BaseFragment<FragmentComposeBinding>() {
return FragmentComposeBinding.inflate(inflater, container, false) return FragmentComposeBinding.inflate(inflater, container, false)
} }
override fun onViewCreated(view : View, savedInstanceState : Bundle?) { override fun onCreateView(
super.onViewCreated(view, savedInstanceState) 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
}
}
})
}
} }
} }

View file

@ -4,22 +4,38 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.fragment.app.viewModels 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.SocialRepository
import com.habitrpg.android.habitica.data.UserRepository import com.habitrpg.android.habitica.data.UserRepository
import com.habitrpg.android.habitica.databinding.FragmentComposeBinding 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.models.members.Member
import com.habitrpg.android.habitica.ui.fragments.BaseFragment import com.habitrpg.android.habitica.ui.fragments.BaseFragment
import com.habitrpg.android.habitica.ui.theme.HabiticaTheme import com.habitrpg.android.habitica.ui.theme.HabiticaTheme
import com.habitrpg.android.habitica.ui.viewmodels.BaseViewModel import com.habitrpg.android.habitica.ui.viewmodels.BaseViewModel
import com.habitrpg.android.habitica.ui.viewmodels.MainUserViewModel 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 com.habitrpg.common.habitica.helpers.launchCatching
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -43,9 +66,8 @@ class PartySeekingViewModel @Inject constructor(
userRepository : UserRepository, userRepository : UserRepository,
userViewModel : MainUserViewModel, userViewModel : MainUserViewModel,
val socialRepository : SocialRepository val socialRepository : SocialRepository
): BaseViewModel(userRepository, userViewModel) { ) : BaseViewModel(userRepository, userViewModel) {
val isRefreshing = mutableStateOf(false) val isRefreshing = mutableStateOf(false)
val seekingUsers = mutableStateOf<List<Member>>(emptyList()) val seekingUsers = mutableStateOf<List<Member>>(emptyList())
init { init {
@ -59,13 +81,21 @@ class PartySeekingViewModel @Inject constructor(
isRefreshing.value = false isRefreshing.value = false
} }
} }
suspend fun inviteUser(member : Member) : InviteResponse? {
return socialRepository.inviteToGroup(
"party", mapOf(
"uuids" to listOf(member.id ?: "")
)
)?.firstOrNull()
}
} }
@AndroidEntryPoint @AndroidEntryPoint
class PartySeekingFragment: BaseFragment<FragmentComposeBinding>() { class PartySeekingFragment : BaseFragment<FragmentComposeBinding>() {
val viewModel: PartySeekingViewModel by viewModels() val viewModel : PartySeekingViewModel by viewModels()
override var binding: FragmentComposeBinding? = null override var binding : FragmentComposeBinding? = null
override fun createBinding( override fun createBinding(
inflater : LayoutInflater, inflater : LayoutInflater,
container : ViewGroup? container : ViewGroup?
@ -87,39 +117,183 @@ class PartySeekingFragment: BaseFragment<FragmentComposeBinding>() {
return view return view
} }
override fun onResume() { override fun onStart() {
super.onResume() super.onStart()
viewModel.retrieveUsers() viewModel.retrieveUsers()
} }
} }
@Composable @Composable
fun PartySeekingListItem(user: Member, fun ClassText(
modifier : Modifier = Modifier) { className : String?,
Column(modifier.fillMaxWidth()) { hasClass : Boolean,
Text(user.username ?: "") 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 @Composable
fun PartySeekingView( fun InviteButton(
viewModel: PartySeekingViewModel, state : LoadingButtonState,
onClick : () -> Unit,
modifier : Modifier = Modifier modifier : Modifier = Modifier
) { ) {
val users: List<Member> 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<Member> by viewModel.seekingUsers
val refreshing by viewModel.isRefreshing val refreshing by viewModel.isRefreshing
val pullRefreshState = rememberPullRefreshState(refreshing, { viewModel.retrieveUsers() }) val pullRefreshState = rememberPullRefreshState(refreshing, { viewModel.retrieveUsers() })
LazyColumn(modifier.pullRefresh(pullRefreshState)) { Box(modifier = modifier.pullRefresh(pullRefreshState)) {
item { LazyColumn {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth().padding(top = 22.dp, bottom = 14.dp)) { item {
Text(stringResource(R.string.find_more_members), color = HabiticaTheme.colors.textPrimary, fontSize = 16.sp, fontWeight = FontWeight.Medium) Column(
Text(stringResource(R.string.habiticans_looking_party), color = HabiticaTheme.colors.textSecondary) 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) { PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
PartySeekingListItem(user = it)
}
} }
} }

View file

@ -143,7 +143,11 @@ object HabiticaTheme {
tintedUiMain = Color(context.getThemeColor(R.attr.tintedUiMain)), tintedUiMain = Color(context.getThemeColor(R.attr.tintedUiMain)),
tintedUiSub = Color(context.getThemeColor(R.attr.tintedUiSub)), tintedUiSub = Color(context.getThemeColor(R.attr.tintedUiSub)),
tintedUiDetails = Color(context.getThemeColor(R.attr.tintedUiDetails)), 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 tintedUiMain: Color,
val tintedUiSub: Color, val tintedUiSub: Color,
val tintedUiDetails: Color, val tintedUiDetails: Color,
val pixelArtBackground: Color val pixelArtBackground: Color,
val errorBackground : Color,
val errorColor : Color,
val successBackground : Color,
val successColor : Color
) { ) {
@Composable @Composable
fun textPrimaryFor(task: Task?): Color { fun textPrimaryFor(task: Task?): Color {
return colorResource((if (isSystemInDarkTheme()) task?.extraExtraLightTaskColor else task?.extraDarkTaskColor) ?: R.color.text_primary) return colorResource((if (isSystemInDarkTheme()) task?.extraExtraLightTaskColor else task?.extraDarkTaskColor) ?: R.color.text_primary)

View file

@ -72,7 +72,7 @@ class GroupMemberViewHolder(itemView: View) : androidx.recyclerview.widget.Recyc
binding.displayNameTextview.tier = user.contributor?.level ?: 0 binding.displayNameTextview.tier = user.contributor?.level ?: 0
if (user.hasClass) { 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 { } else {
binding.sublineTextview.text = itemView.context.getString(R.string.user_level, user.stats?.lvl) binding.sublineTextview.text = itemView.context.getString(R.string.user_level, user.stats?.lvl)
} }

View file

@ -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.Stats
import com.habitrpg.android.habitica.models.user.User import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.shared.habitica.models.Avatar import com.habitrpg.shared.habitica.models.Avatar
import com.habitrpg.shared.habitica.models.AvatarStats
import kotlin.random.Random import kotlin.random.Random
@Composable @Composable
@ -62,9 +61,10 @@ fun UserLevelText(user: Avatar) {
stringResource( stringResource(
id = R.string.user_level_with_class, id = R.string.user_level_with_class,
user.stats?.lvl ?: 0, user.stats?.lvl ?: 0,
user.stats?.getTranslatedClassName( getTranslatedClassName(
LocalContext.current.resources LocalContext.current.resources,
) ?: "" user.stats?.habitClass
)
) )
} else { } else {
stringResource(id = R.string.user_level, user.stats?.lvl ?: 0) stringResource(id = R.string.user_level, user.stats?.lvl ?: 0)
@ -77,8 +77,8 @@ fun UserLevelText(user: Avatar) {
) )
} }
fun AvatarStats.getTranslatedClassName(resources: Resources): String { fun getTranslatedClassName(resources: Resources, className: String?): String {
return when (habitClass) { return when (className) {
Stats.HEALER -> resources.getString(R.string.healer) Stats.HEALER -> resources.getString(R.string.healer)
Stats.ROGUE -> resources.getString(R.string.rogue) Stats.ROGUE -> resources.getString(R.string.rogue)
Stats.WARRIOR -> resources.getString(R.string.warrior) Stats.WARRIOR -> resources.getString(R.string.warrior)
@ -258,7 +258,7 @@ fun AppHeaderView(
) )
CurrencyText( CurrencyText(
"gems", "gems",
user.gemCount?.toDouble() ?: 0.0, user.gemCount.toDouble(),
modifier = Modifier.clickable { modifier = Modifier.clickable {
MainNavigationController.navigate(R.id.gemPurchaseActivity) MainNavigationController.navigate(R.id.gemPurchaseActivity)
}, },

View file

@ -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()
}
}
}

View file

@ -5,6 +5,7 @@ import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.TextUtils import android.text.TextUtils
import android.text.util.Linkify import android.text.util.Linkify
import android.util.Patterns
import java.util.Locale import java.util.Locale
fun String.fromHtml(): CharSequence { fun String.fromHtml(): CharSequence {
@ -31,3 +32,6 @@ fun String.removeZeroWidthSpace(): String {
fun String.localizedCapitalize(): String { fun String.localizedCapitalize(): String {
return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
} }
fun CharSequence?.isValidEmail() = !isNullOrEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches()

View file

@ -303,7 +303,7 @@ class AvatarView : FrameLayout {
layerMap[LayerType.BACK] = outfit.back layerMap[LayerType.BACK] = outfit.back
} }
if (outfit.isAvailable(outfit.armor)) { 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)) { if (outfit.isAvailable(outfit.body)) {
layerMap[LayerType.BODY] = 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.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" layerMap[LayerType.HEAD_0] = "head_0"
if (hair != null) { if (hair != null) {

View file

@ -157,4 +157,5 @@
<color name="text_green">@color/green_10</color> <color name="text_green">@color/green_10</color>
<color name="text_blue">@color/blue_10</color> <color name="text_blue">@color/blue_10</color>
<color name="text_teal">@color/teal_10</color> <color name="text_teal">@color/teal_10</color>
</resources> <color name="divider_color">@color/gray_500</color>
</resources>