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
+