allow rescinding invites from party screen

This commit is contained in:
Phillip Thelen 2023-05-04 14:51:14 +02:00
parent 79d9d744b4
commit 30773b13b9
12 changed files with 193 additions and 66 deletions

View file

@ -202,6 +202,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"/>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/invites_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>

View file

@ -1385,6 +1385,7 @@
<string name="unlock_gear_and_skills">Unlock %s gear and skills</string>
<string name="rescind_invite">Rescind Invite</string>
<string name="rescinded">Rescinded</string>
<string name="pending_invite">Pending Invite</string>
<plurals name="you_x_others">
<item quantity="zero">You</item>

View file

@ -286,6 +286,10 @@ interface ApiService {
@POST("groups/{gid}/quests/invite/{questKey}")
suspend fun inviteToQuest(@Path("gid") groupId: String, @Path("questKey") questKey: String): HabitResponse<Quest>
@GET("groups/{gid}/invites")
suspend fun getGroupInvites(@Path("gid") groupId: String,
@Query("includeAllPublicFields") includeAllPublicFields: Boolean?): HabitResponse<List<Member>>
@POST("groups/{gid}/quests/abort")
suspend fun abortQuest(@Path("gid") groupId: String): HabitResponse<Quest>

View file

@ -278,4 +278,5 @@ interface ApiClient {
suspend fun getHallMember(userId: String): Member?
suspend fun markTaskNeedsWork(taskID: String, userID: String): Task?
suspend fun retrievePartySeekingUsers(page: Int) : List<Member>?
suspend fun getGroupInvites(groupId: String, includeAllPublicFields: Boolean?): List<Member>?
}

View file

@ -123,4 +123,5 @@ interface SocialRepository : BaseRepository {
fun getMember(userID: String?): Flow<Member?>
suspend fun updateMember(memberID: String, key: String, value: Any?): Member?
suspend fun retrievePartySeekingUsers(page: Int = 0): List<Member>?
suspend fun retrievegroupInvites(id: String, includeAllPublicFields: Boolean): List<Member>?
}

View file

@ -578,6 +578,10 @@ class ApiClientImpl(
return process { apiService.rejectGroupInvite(groupId) }
}
override suspend fun getGroupInvites(groupId: String, includeAllPublicFields: Boolean?): List<Member>? {
return process { apiService.getGroupInvites(groupId, includeAllPublicFields) }
}
override suspend fun acceptQuest(groupId: String): Void? {
return process { apiService.acceptQuest(groupId) }
}

View file

@ -275,6 +275,9 @@ class SocialRepositoryImpl(
}
}
override suspend fun retrievegroupInvites(id: String, includeAllPublicFields: Boolean) = apiClient.getGroupInvites(id, includeAllPublicFields)
override suspend fun retrieveMemberWithUsername(username: String?, fromHall: Boolean): Member? {
return retrieveMember(username, fromHall)
}

View file

@ -213,7 +213,7 @@ class UserRepositoryImpl(
override suspend fun sendPasswordResetEmail(email: String) = apiClient.sendPasswordResetEmail(email)
override suspend fun updateLoginName(newLoginName: String, password: String?): User? {
if (password != null && password.isNotEmpty()) {
if (!password.isNullOrEmpty()) {
apiClient.updateLoginName(newLoginName.trim(), password.trim())
} else {
apiClient.updateUsername(newLoginName.trim())

View file

@ -7,6 +7,7 @@ import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.widget.AppCompatEditText
import androidx.compose.foundation.layout.Column
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
@ -30,10 +31,13 @@ import com.habitrpg.android.habitica.ui.activities.MainActivity
import com.habitrpg.android.habitica.ui.fragments.BaseFragment
import com.habitrpg.android.habitica.ui.fragments.inventory.items.ItemDialogFragment
import com.habitrpg.android.habitica.ui.helpers.dismissKeyboard
import com.habitrpg.android.habitica.ui.theme.HabiticaTheme
import com.habitrpg.android.habitica.ui.viewHolders.GroupMemberViewHolder
import com.habitrpg.android.habitica.ui.viewmodels.PartyViewModel
import com.habitrpg.android.habitica.ui.views.HabiticaSnackbar
import com.habitrpg.android.habitica.ui.views.LoadingButtonState
import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaAlertDialog
import com.habitrpg.android.habitica.ui.views.social.PartySeekingListItem
import com.habitrpg.common.habitica.extensions.DataBindingUtils
import com.habitrpg.common.habitica.extensions.dpToPx
import com.habitrpg.common.habitica.extensions.loadImage
@ -55,7 +59,10 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
override var binding: FragmentPartyDetailBinding? = null
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPartyDetailBinding {
override fun createBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentPartyDetailBinding {
return FragmentPartyDetailBinding.inflate(inflater, container, false)
}
@ -110,6 +117,28 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
userRepository.retrieveUser(false, true)
}
}
binding?.invitesWrapper?.setContent {
HabiticaTheme {
val invitedMembers = viewModel?.pendingInvites
Column {
for (invitedMember in (invitedMembers ?: emptyList())) {
val state = viewModel?.pendingInviteStates?.getOrDefault(
invitedMember.id,
LoadingButtonState.CONTENT
) ?: LoadingButtonState.CONTENT
PartySeekingListItem(
user = invitedMember,
inviteState = state,
isInvited = state != LoadingButtonState.SUCCESS,
showHeader = true,
showExtendedInfo = false,
onInvite = {
viewModel?.rescindInvite(invitedMember)
})
}
}
}
}
viewModel?.getGroupData()?.observe(viewLifecycleOwner) { updateParty(it) }
viewModel?.user?.observe(viewLifecycleOwner) { updateUser(it) }
@ -138,7 +167,8 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
binding?.questImageWrapper?.visibility = View.VISIBLE
lifecycleScope.launch(Dispatchers.Main) {
delay(500)
val content = inventoryRepository.getQuestContent(party.quest?.key ?: "").firstOrNull()
val content =
inventoryRepository.getQuestContent(party.quest?.key ?: "").firstOrNull()
if (content != null) {
updateQuestContent(content)
}
@ -239,17 +269,36 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
}
binding?.questImageWrapper?.alpha = 1.0f
binding?.questProgressView?.alpha = 1.0f
context?.let { binding?.questParticipationView?.setTextColor(ContextCompat.getColor(it, R.color.text_quad)) }
context?.let {
binding?.questParticipationView?.setTextColor(
ContextCompat.getColor(
it,
R.color.text_quad
)
)
}
if (viewModel?.isQuestActive == true) {
binding?.questProgressView?.visibility = View.VISIBLE
binding?.questProgressView?.setData(questContent, viewModel?.getGroupData()?.value?.quest?.progress)
binding?.questProgressView?.setData(
questContent,
viewModel?.getGroupData()?.value?.quest?.progress
)
val questParticipants = viewModel?.getGroupData()?.value?.quest?.members
if (questParticipants?.find { it.key == viewModel?.userViewModel?.userID } != null) {
binding?.questParticipationView?.text = context?.getString(R.string.number_participants, questParticipants.size)
binding?.questParticipationView?.text =
context?.getString(R.string.number_participants, questParticipants.size)
} else {
binding?.questParticipationView?.text = context?.getString(R.string.not_participating)
context?.let { binding?.questParticipationView?.setTextColor(ContextCompat.getColor(it, R.color.red_10)) }
binding?.questParticipationView?.text =
context?.getString(R.string.not_participating)
context?.let {
binding?.questParticipationView?.setTextColor(
ContextCompat.getColor(
it,
R.color.red_10
)
)
}
binding?.questImageWrapper?.alpha = 0.5f
binding?.questProgressView?.alpha = 0.5f
}
@ -257,7 +306,8 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
binding?.questProgressView?.visibility = View.GONE
val members = viewModel?.getGroupData()?.value?.quest?.members
val responded = members?.filter { it.isParticipating != null }
binding?.questParticipationView?.text = context?.getString(R.string.number_responded, responded?.size, members?.size)
binding?.questParticipationView?.text =
context?.getString(R.string.number_responded, responded?.size, members?.size)
}
}
@ -294,7 +344,8 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
val factory = LayoutInflater.from(context)
val newMessageView = factory.inflate(R.layout.profile_new_message_dialog, null)
val emojiEditText = newMessageView.findViewById<AppCompatEditText>(R.id.edit_new_message_text)
val emojiEditText =
newMessageView.findViewById<AppCompatEditText>(R.id.edit_new_message_text)
val newMessageTitle = newMessageView.findViewById<TextView>(R.id.new_message_title)
newMessageTitle.text = String.format(getString(R.string.profile_send_message_to), username)
@ -306,13 +357,17 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
(activity as? MainActivity)?.snackbarContainer?.let { it1 ->
HabiticaSnackbar.showSnackbar(
it1,
String.format(getString(R.string.profile_message_sent_to), username), HabiticaSnackbar.SnackbarDisplayType.NORMAL
String.format(getString(R.string.profile_message_sent_to), username),
HabiticaSnackbar.SnackbarDisplayType.NORMAL
)
}
}
activity?.dismissKeyboard()
}
addMessageDialog?.addButton(android.R.string.cancel, false) { _, _ -> activity?.dismissKeyboard() }
addMessageDialog?.addButton(
android.R.string.cancel,
false
) { _, _ -> activity?.dismissKeyboard() }
addMessageDialog?.setAdditionalContentView(newMessageView)
addMessageDialog?.show()
}
@ -325,7 +380,8 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
(activity as? MainActivity)?.snackbarContainer?.let { it1 ->
HabiticaSnackbar.showSnackbar(
it1,
String.format(getString(R.string.transferred_ownership), displayName), HabiticaSnackbar.SnackbarDisplayType.NORMAL
String.format(getString(R.string.transferred_ownership), displayName),
HabiticaSnackbar.SnackbarDisplayType.NORMAL
)
}
}
@ -333,7 +389,12 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
}
dialog?.addButton(android.R.string.cancel, false) { _, _ -> activity?.dismissKeyboard() }
dialog?.setTitle(context?.getString(R.string.transfer_ownership_confirm))
dialog?.setMessage(context?.getString(R.string.transfer_ownership_confirm_message, displayName))
dialog?.setMessage(
context?.getString(
R.string.transfer_ownership_confirm_message,
displayName
)
)
dialog?.show()
}
@ -345,7 +406,8 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
(activity as? MainActivity)?.snackbarContainer?.let { it1 ->
HabiticaSnackbar.showSnackbar(
it1,
String.format(getString(R.string.removed_member), displayName), HabiticaSnackbar.SnackbarDisplayType.NORMAL
String.format(getString(R.string.removed_member), displayName),
HabiticaSnackbar.SnackbarDisplayType.NORMAL
)
}
}
@ -370,7 +432,8 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
lifecycleScope.launchCatching {
userRepository.getUser().collect {
it?.challenges?.forEach { membership ->
val challenge = challengeRepository.getChallenge(membership.challengeID).firstOrNull()
val challenge =
challengeRepository.getChallenge(membership.challengeID).firstOrNull()
if (challenge != null && challenge.groupId == viewModel?.groupID) {
groupChallenges.add(challenge)
}
@ -396,7 +459,11 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
MainNavigationController.navigate(R.id.noPartyFragment)
}
}
alert.addButton(R.string.leave_challenges_delete_tasks, false, isDestructive = true) { _, _ ->
alert.addButton(
R.string.leave_challenges_delete_tasks,
false,
isDestructive = true
) { _, _ ->
viewModel?.leaveGroup(groupChallenges, false) {
parentFragmentManager.popBackStack()
MainNavigationController.navigate(R.id.noPartyFragment)
@ -408,7 +475,11 @@ class PartyDetailFragment : BaseFragment<FragmentPartyDetailBinding>() {
val alert = HabiticaAlertDialog(context)
alert.setTitle(R.string.leave_party_confirmation)
alert.setMessage(R.string.rejoin_party)
alert.addButton(R.string.leave, isPrimary = true, isDestructive = true) { _, _ ->
alert.addButton(
R.string.leave,
isPrimary = true,
isDestructive = true
) { _, _ ->
viewModel?.leaveGroup(groupChallenges, false) {
parentFragmentManager.popBackStack()
MainNavigationController.navigate(R.id.noPartyFragment)

View file

@ -210,7 +210,7 @@ fun PartySeekingView(
user = it,
inviteState =viewModel.inviteStates[it.id] ?: LoadingButtonState.CONTENT,
isInvited = viewModel.successfulInvites.contains(it.id),
modifier = Modifier.animateItemPlacement()
modifier = Modifier.animateItemPlacement().padding(horizontal = 14.dp)
) { member ->
scope.launchCatching({
viewModel.inviteStates[member.id] = LoadingButtonState.FAILED

View file

@ -1,6 +1,8 @@
package com.habitrpg.android.habitica.ui.viewmodels
import android.os.Bundle
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
@ -14,12 +16,14 @@ import com.habitrpg.android.habitica.models.members.Member
import com.habitrpg.android.habitica.models.social.Challenge
import com.habitrpg.android.habitica.models.social.ChatMessage
import com.habitrpg.android.habitica.models.social.Group
import com.habitrpg.android.habitica.ui.views.LoadingButtonState
import com.habitrpg.common.habitica.helpers.ExceptionHandler
import com.habitrpg.common.habitica.helpers.launchCatching
import com.habitrpg.common.habitica.models.notifications.NewChatMessageData
import dagger.hilt.android.lifecycle.HiltViewModel
import io.realm.kotlin.toFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
@ -29,8 +33,10 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
enum class GroupViewType(internal val order : String) {
enum class GroupViewType(internal val order: String) {
PARTY("party"),
GUILD("guild"),
TAVERN("tavern")
@ -39,17 +45,17 @@ enum class GroupViewType(internal val order : String) {
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
open class GroupViewModel @Inject constructor(
userRepository : UserRepository,
userViewModel : MainUserViewModel,
val challengeRepository : ChallengeRepository,
val socialRepository : SocialRepository,
val notificationsManager : NotificationsManager
userRepository: UserRepository,
userViewModel: MainUserViewModel,
val challengeRepository: ChallengeRepository,
val socialRepository: SocialRepository,
val notificationsManager: NotificationsManager
) : BaseViewModel(userRepository, userViewModel) {
protected val groupIDState = MutableStateFlow<String?>(null)
val groupIDFlow : Flow<String?> = groupIDState
val groupIDFlow: Flow<String?> = groupIDState
var groupViewType : GroupViewType? = null
var groupViewType: GroupViewType? = null
private val groupFlow = groupIDFlow
.filterNotNull()
@ -67,21 +73,21 @@ open class GroupViewModel @Inject constructor(
.map { it != null }
private val isMemberData = isMemberFlow.asLiveData()
private val _chatMessages : MutableLiveData<List<ChatMessage>> by lazy {
private val _chatMessages: MutableLiveData<List<ChatMessage>> by lazy {
MutableLiveData<List<ChatMessage>>(listOf())
}
val chatmessages : LiveData<List<ChatMessage>> by lazy {
val chatmessages: LiveData<List<ChatMessage>> by lazy {
_chatMessages
}
var gotNewMessages : Boolean = false
var gotNewMessages: Boolean = false
override fun onCleared() {
socialRepository.close()
super.onCleared()
}
fun setGroupID(groupID : String) {
fun setGroupID(groupID: String) {
if (groupID == groupIDState.value) return
groupIDState.value = groupID
@ -95,22 +101,25 @@ open class GroupViewModel @Inject constructor(
}
}
val groupID : String?
val groupID: String?
get() = groupIDState.value
val isMember : Boolean
val isMember: Boolean
get() = isMemberData.value ?: false
val leaderID : String?
val leaderID: String?
get() = group.value?.leaderID
val isLeader : Boolean
val isLeader: Boolean
get() = user.value?.id == leaderID
val isPublicGuild : Boolean
val isPublicGuild: Boolean
get() = group.value?.privacy == "public"
fun getGroupData() : LiveData<Group?> = group
fun getLeaderData() : LiveData<Member?> = leader
fun getIsMemberData() : LiveData<Boolean> = isMemberData
val pendingInvites = mutableStateListOf<Member>()
val pendingInviteStates = mutableStateMapOf<String, LoadingButtonState>()
fun retrieveGroup(function : (() -> Unit)?) {
fun getGroupData(): LiveData<Group?> = group
fun getLeaderData(): LiveData<Member?> = leader
fun getIsMemberData(): LiveData<Boolean> = isMemberData
fun retrieveGroup(function: (() -> Unit)?) {
if (groupID?.isNotEmpty() == true) {
viewModelScope.launch(
ExceptionHandler.coroutine {
@ -122,19 +131,23 @@ open class GroupViewModel @Inject constructor(
val group = socialRepository.retrieveGroup(groupID ?: "")
if (groupViewType == GroupViewType.PARTY) {
socialRepository.retrievePartyMembers(group?.id ?: "", true)
val invites =
socialRepository.retrievegroupInvites(group?.id ?: "", true) ?: emptyList()
pendingInvites.clear()
pendingInvites.addAll(invites)
}
function?.invoke()
}
}
}
fun inviteToGroup(inviteData : HashMap<String, Any>) {
fun inviteToGroup(inviteData: HashMap<String, Any>) {
viewModelScope.launchCatching {
socialRepository.inviteToGroup(group.value?.id ?: "", inviteData)
}
}
fun updateOrCreateGroup(bundle : Bundle?) {
fun updateOrCreateGroup(bundle: Bundle?) {
viewModelScope.launch(ExceptionHandler.coroutine()) {
if (group.value == null) {
socialRepository.createGroup(
@ -157,9 +170,9 @@ open class GroupViewModel @Inject constructor(
}
fun leaveGroup(
groupChallenges : List<Challenge>,
keepChallenges : Boolean = true,
function : (() -> Unit)? = null
groupChallenges: List<Challenge>,
keepChallenges: Boolean = true,
function: (() -> Unit)? = null
) {
if (!keepChallenges) {
viewModelScope.launchCatching {
@ -175,14 +188,14 @@ open class GroupViewModel @Inject constructor(
}
}
fun joinGroup(id : String? = null, function : (() -> Unit)? = null) {
fun joinGroup(id: String? = null, function: (() -> Unit)? = null) {
viewModelScope.launchCatching {
socialRepository.joinGroup(id ?: groupID)
function?.invoke()
}
}
fun rejectGroupInvite(id : String? = null) {
fun rejectGroupInvite(id: String? = null) {
groupID?.let {
viewModelScope.launchCatching {
socialRepository.rejectGroupInvite(id ?: it)
@ -200,7 +213,7 @@ open class GroupViewModel @Inject constructor(
}
}
fun likeMessage(message : ChatMessage) {
fun likeMessage(message: ChatMessage) {
viewModelScope.launchCatching {
val message = socialRepository.likeMessage(message)
val index = _chatMessages.value?.indexOfFirst { it.id == message?.id }
@ -216,7 +229,7 @@ open class GroupViewModel @Inject constructor(
}
}
fun deleteMessage(chatMessage : ChatMessage) {
fun deleteMessage(chatMessage: ChatMessage) {
val oldIndex = _chatMessages.value?.indexOf(chatMessage) ?: return
val list = _chatMessages.value?.toMutableList()
list?.remove(chatMessage)
@ -232,7 +245,7 @@ open class GroupViewModel @Inject constructor(
}
}
fun postGroupChat(chatText : String, onComplete : () -> Unit, onError : () -> Unit) {
fun postGroupChat(chatText: String, onComplete: () -> Unit, onError: () -> Unit) {
groupID?.let { groupID ->
viewModelScope.launch(
ExceptionHandler.coroutine {
@ -251,7 +264,7 @@ open class GroupViewModel @Inject constructor(
}
}
fun retrieveGroupChat(onComplete : () -> Unit) {
fun retrieveGroupChat(onComplete: () -> Unit) {
var groupID = groupID
if (groupViewType == GroupViewType.PARTY) {
groupID = "party"
@ -267,7 +280,7 @@ open class GroupViewModel @Inject constructor(
}
}
fun updateGroup(bundle : Bundle?) {
fun updateGroup(bundle: Bundle?) {
viewModelScope.launch(ExceptionHandler.coroutine()) {
socialRepository.updateGroup(
group.value,
@ -278,4 +291,16 @@ open class GroupViewModel @Inject constructor(
)
}
}
fun rescindInvite(invitedMember: Member) {
pendingInviteStates[invitedMember.id] = LoadingButtonState.LOADING
viewModelScope.launchCatching({
pendingInviteStates[invitedMember.id] = LoadingButtonState.FAILED
}) {
socialRepository.removeMemberFromGroup(groupID ?: "", invitedMember.id)
pendingInviteStates[invitedMember.id] = LoadingButtonState.SUCCESS
delay(1.toDuration(DurationUnit.SECONDS))
pendingInvites.remove(invitedMember)
}
}
}

View file

@ -46,13 +46,14 @@ fun PartySeekingListItem(
modifier : Modifier = Modifier,
inviteState : LoadingButtonState = LoadingButtonState.LOADING,
isInvited: Boolean = false,
showHeader: Boolean = false,
showExtendedInfo: Boolean = true,
onInvite : (Member) -> Unit
) {
Column(
modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.padding(bottom = 4.dp)
.padding(bottom = 6.dp)
.background(HabiticaTheme.colors.windowBackground, HabiticaTheme.shapes.large)
.padding(14.dp)
) {
@ -68,6 +69,14 @@ fun PartySeekingListItem(
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxWidth()
) {
if (showHeader) {
Text(
stringResource(R.string.pending_invite).uppercase(),
fontSize = 12.sp,
color = HabiticaTheme.colors.textQuad,
modifier = Modifier.padding(bottom = 4.dp)
)
}
ProvideTextStyle(value = TextStyle(fontSize = 14.sp)) {
ComposableUsernameLabel(
user.displayName,
@ -101,18 +110,22 @@ fun PartySeekingListItem(
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
)
if (showExtendedInfo) {
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(