Merge branch 'main' of github.com:HabitRPG/habitica-android into main

This commit is contained in:
Weblate 2025-07-02 15:54:10 +02:00
commit 5f421dc03d
46 changed files with 3563 additions and 2204 deletions

View file

@ -4,18 +4,37 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/chat_empty_textview"
<LinearLayout
android:id="@+id/chat_empty_container"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:visibility="gone"
android:padding="16dp"
android:visibility="invisible"
android:layout_weight="1"
android:layout_gravity="center"
android:text="@string/chat_empty_state"
android:textColor="@color/gray200_gray400"
android:textSize="16sp" />
android:paddingHorizontal="@dimen/spacing_large"
android:paddingVertical="@dimen/spacing_medium">
<TextView
android:id="@+id/chat_empty_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginBottom="4dp"
android:text="@string/chat_empty_state_title"
android:textColor="@color/gray100_gray400"
android:textSize="17sp"
android:fontFamily="sans-serif-medium" />
<TextView
android:id="@+id/chat_empty_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/chat_empty_state_description"
android:textColor="@color/gray200_gray400"
android:textSize="16sp" />
</LinearLayout>
<com.habitrpg.android.habitica.ui.helpers.RecyclerViewEmptySupport
android:id="@+id/recyclerView"

View file

@ -6,6 +6,7 @@
android:orientation="horizontal">
<LinearLayout
android:id="@+id/skill_item_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/layout_rounded_bg_window"

View file

@ -63,6 +63,7 @@
<color name="purple400_purple500">@color/brand_500</color>
<color name="text_green10_green500">@color/green_500</color>
<color name="gray100_gray400">@color/gray_400</color>
<color name="gray100_gray500">@color/gray_500</color>
<color name="gray200_gray400">@color/gray_400</color>
<color name="gray600_gray10">@color/gray_10</color>
<color name="gray600_gray50">@color/gray_50</color>
@ -70,4 +71,5 @@
<color name="maroon100_red100">@color/red_100</color>
<color name="brand_button">@color/brand_600</color>
</resources>

View file

@ -129,7 +129,8 @@
<color name="purple400_purple500">@color/brand_400</color>
<color name="text_green10_green500">@color/green_10</color>
<color name="gray100_gray400">@color/gray_10</color>
<color name="gray100_gray400">@color/gray_100</color>
<color name="gray100_gray500">@color/gray_100</color>
<color name="gray200_gray400">@color/gray_200</color>
<color name="gray600_gray10">@color/gray_600</color>
<color name="gray600_gray50">@color/gray_600</color>

View file

@ -628,6 +628,8 @@
<string name="profile_summary">Edit your public profile.</string>
<string name="display_name">Display Name</string>
<string name="photo_url">Photo URL</string>
<string name="photo_url_description">You can display an image on your Habitica profile for others to see by adding a link to the image here.</string>
<string name="save_photo_url">Save Photo URL</string>
<string name="login_name">Login Name</string>
<string name="about">About</string>
<string name="app_settings">App Settings</string>
@ -635,7 +637,9 @@
<string name="authentication_summary">Change your authentication options.</string>
<string name="change_password">Change Password</string>
<string name="change_email">Change Email Address</string>
<string name="change_email_description">This is the email address that you use to log in to Habitica, as well as receive notifications.</string>
<string name="change_username">Change Username</string>
<string name="change_username_description">Usernames must be 1 to 20 characters, containing only letters a to z, numbers 0 to 9, hyphens, or underscores.</string>
<string name="change">Change</string>
<string name="character_level">Character Level</string>
<string name="auto_allocate_points">Auto Allocate Points</string>
@ -763,6 +767,8 @@
<string name="username_copied">Username copied to clipboard</string>
<string name="verification_pet">One of these Veteran Pets will be waiting for you after youve finished confirming!</string>
<string name="welcomeNameTitle">What should we call you?</string>
<string name="change_display_name">Change Display Name</string>
<string name="display_name_description">This is the name that will be displayed for your avatar in Habitica. Unlike username, it does not have to be a unique identifier.</string>
<string name="display_name_length_error">Display names must be between 1 and 30 characters</string>
<string name="setup_task_join_habitica">Join Habitica (Check me off!)</string>
<string name="setup_task_join_habitica_notes">You can either complete this To Do, edit it, or remove it.</string>
@ -813,6 +819,8 @@
<string name="old_password">Old Password</string>
<string name="new_password">New Password</string>
<string name="new_password_repeat">Repeat new Password</string>
<string name="password_change_info">Passwords must be 8 characters or more. Changing your password will log you out of any other devices and third-party tools you may use.</string>
<string name="confirm_new_password">Confirm new Password</string>
<string name="adjust_streak">Adjust Streak</string>
<string name="adjust_counter">Adjust Counter</string>
<string name="password_changed">Password successfully changed</string>
@ -1041,6 +1049,7 @@
<string name="stat_allocation">Stat Allocation</string>
<string name="stat_description">All Habitica characters have four stats that affect the gameplay aspects of Habitica.\n\n**Strength (STR)** affects critical hits and raises damage done to a Quest Boss. Warriors and Rogues gain STR from their class equipment.\n\n**Constitution (CON)** raises your HP and makes you take less damage. Healers and Warriors gain CON from their class equipment.\n\n**Intelligence (INT)** raises the amount of EXP you earn and gives you more Mana. Mages and Healers gain INT from their class equipment.\n\n**Perception (PER)** increases the gold you earn and the rate of finding dropped items. Rogues and Mages gain PER from their class equipment.\n\nAfter level 10, you earn 1 Stat Point every level you gain that you can put into any stat youd like. You can also equip gear that has different combinations of stat boosts.</string>
<string name="use_skill">Use Skill</string>
<string name="use_on_party">Use on Party</string>
<string name="standard"> Standard </string>
<string name="premium_currency">Premium Currency</string>
@ -1220,6 +1229,8 @@
<string name="my_account">My Account</string>
<string name="public_profile">Public Profile</string>
<string name="about_me">About Me</string>
<string name="about_me_description">Add a small blurb about yourself that will appear on your Habitica profile when others view it.</string>
<string name="save_about_me">Save About Me</string>
<string name="api" translatable="false">API</string>
<string name="account_info">Account Info</string>
<string name="login_methods">Login Methods</string>
@ -1230,7 +1241,7 @@
<string name="connect">Connect</string>
<string name="disconnect">Disconnect</string>
<string name="add">Add</string>
<string name="apitoken_summary">Copy Token. Be careful, this is a password!</string>
<string name="apitoken_summary">Password token for developers and third-party tools.</string>
<string name="added_social_auth">Added %s authentication</string>
<string name="copied_to_clipboard">Copied %s to clipboard</string>
<string name="removed_social_auth">Disconnected %s</string>
@ -1565,7 +1576,8 @@
<string name="max_gem_cap">Max Gem Cap</string>
<string name="max_gem_cap_text">Instantly start at the max Gem Cap</string>
<string name="max_gem_cap_text_gift">They instantly start at the Max Gem Cap</string>
<string name="chat_empty_state_title">Start chatting!</string>
<string name="chat_empty_state_description">Remember to be friendly and follow the Community Guidelines.</string>
<string name="gem_cap">Gem Cap</string>
<string name="get_12_mystic_hourglasses">Get 12 Mystic Hourglasses immediately after your first 12 month subscription!</string>
<string name="twelve_mystic_hourglasses">12 Mystic Hourglasses</string>
@ -1582,7 +1594,16 @@
<string name="auth_get_credentials_error">Error getting credentials for authentication.</string>
<string name="auth_invalid_credentials">Received invalid credentials.</string>
<string name="auth_unknown_error">Unknown error during authentication.</string>
<string name="chat_empty_state"><b>Start chatting!</b>\nRemember to be friendly and follow the Community Guidelines.</string>
<string name="api_token_title">API Token</string>
<string name="api_token_is_password">Your API Token is like a password</string>
<string name="api_token_is_password_info">
Do not share it publicly. You may occasionally be asked for your User ID, but never post your API Token where others can see it, including on Github.
</string>
<string name="api_token_reset_title">If you need a new API Token</string>
<string name="api_token_reset_info">
You can change your password to reset it. Once it is reset, you will need to log back in to any other devices you use Habitica on and provide the new API Token to third-party tools you may use.
</string>
<string name="copy_token">Copy Token</string>

View file

@ -952,6 +952,21 @@
<item name="hintTextAppearance">@style/TaskFormHintTextAppearance</item>
</style>
<style name="SettingTextInputLayoutAppearance" parent="Widget.MaterialComponents.TextInputLayout.FilledBox">
<item name="boxBackgroundColor">?colorTintedBackground</item>
<item name="boxStrokeColor">@color/gray200_gray400</item>
<item name="boxStrokeWidth">2dp</item>
<item name="boxStrokeWidthFocused">2dp</item>
<item name="android:textColor">?attr/colorPrimaryText</item>
<item name="android:textColorHint">?colorPrimaryText</item>
<item name="colorControlNormal">?attr/colorPrimary</item>
<item name="colorControlActivated">?attr/colorPrimary</item>
<item name="colorControlHighlight">?attr/colorPrimary</item>
<item name="colorAccent">?attr/colorPrimaryText</item>
<item name="colorPrimary">?attr/colorPrimaryText</item>
<item name="hintTextAppearance">@style/TaskFormHintTextAppearance</item>
</style>
<style name="TaskFormHintTextAppearance">
<item name="android:colorPrimary">?attr/colorPrimaryText</item>
<item name="colorPrimary">?attr/colorPrimaryText</item>

View file

@ -11,8 +11,6 @@ import android.content.res.Configuration
import android.content.res.Resources
import android.database.DatabaseErrorHandler
import android.database.sqlite.SQLiteDatabase
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
@ -33,6 +31,8 @@ import com.habitrpg.android.habitica.extensions.DateUtils
import com.habitrpg.android.habitica.helpers.AdHandler
import com.habitrpg.android.habitica.helpers.Analytics
import com.habitrpg.android.habitica.helpers.notifications.PushNotificationManager
import com.habitrpg.android.habitica.helpers.notifications.PushNotificationManager.Companion.DEVICE_TOKEN_PREFERENCE_KEY
import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.android.habitica.modules.AuthenticationHandler
import com.habitrpg.android.habitica.ui.activities.BaseActivity
import com.habitrpg.android.habitica.ui.activities.OnboardingActivity
@ -318,15 +318,26 @@ abstract class HabiticaBaseApplication : Application(), Application.ActivityLife
realm.close()
}
fun logout(context: Context) {
fun logout(context: Context, user: User? = null) {
MainScope().launchCatching {
getInstance(context)?.pushNotificationManager?.removePushDeviceUsingStoredToken()
deleteDatabase(context)
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
val instance = getInstance(context)
val pushManager = instance?.pushNotificationManager
val deviceToken = preferences.getString(DEVICE_TOKEN_PREFERENCE_KEY, "") ?: ""
val useReminder = preferences.getBoolean("use_reminder", false)
val reminderTime = preferences.getString("reminder_time", "19:00")
val lightMode = preferences.getString("theme_mode", "system")
val launchScreen = preferences.getString("launch_screen", "")
// set the user and refreshed token in the push manager, so we can remove the push device
if (deviceToken.isNotEmpty() && user != null) {
pushManager?.setUser(user)
pushManager?.refreshedToken = deviceToken
pushManager?.removePushDeviceUsingStoredToken()
}
deleteDatabase(context)
preferences.edit {
clear()
putBoolean("use_reminder", useReminder)
@ -334,7 +345,9 @@ abstract class HabiticaBaseApplication : Application(), Application.ActivityLife
putString("theme_mode", lightMode)
putString("launch_screen", launchScreen)
}
getInstance(context)?.lazyApiHelper?.updateAuthenticationCredentials(null, null)
pushManager?.clearUser()
instance?.lazyApiHelper?.updateAuthenticationCredentials(null, null)
Wearable.getCapabilityClient(context).removeLocalCapability("provide_auth")
startActivity(OnboardingActivity::class.java, context)
}

View file

@ -15,6 +15,7 @@ import com.habitrpg.android.habitica.models.inventory.Equipment;
import com.habitrpg.android.habitica.models.inventory.Quest;
import com.habitrpg.android.habitica.models.inventory.QuestCollect;
import com.habitrpg.android.habitica.models.inventory.QuestDropItem;
import com.habitrpg.android.habitica.models.invitations.InviteResponse;
import com.habitrpg.android.habitica.models.members.Member;
import com.habitrpg.android.habitica.models.social.Challenge;
import com.habitrpg.android.habitica.models.social.ChatMessage;
@ -43,6 +44,7 @@ import com.habitrpg.android.habitica.utils.FAQArticleListDeserilializer;
import com.habitrpg.android.habitica.utils.FeedResponseDeserializer;
import com.habitrpg.android.habitica.utils.FindUsernameResultDeserializer;
import com.habitrpg.android.habitica.utils.GroupSerialization;
import com.habitrpg.android.habitica.utils.InviteResponseDeserializer;
import com.habitrpg.android.habitica.utils.MemberSerialization;
import com.habitrpg.android.habitica.utils.NotificationDeserializer;
import com.habitrpg.android.habitica.utils.OwnedItemListDeserializer;
@ -136,6 +138,7 @@ public class GSonFactoryCreator {
.registerTypeAdapter(assignedDetailsListType, new AssignedDetailsDeserializer())
.registerTypeAdapter(Quest.class, new QuestDeserializer())
.registerTypeAdapter(Member.class, new MemberSerialization())
.registerTypeAdapter(InviteResponse.class, new InviteResponseDeserializer())
.registerTypeAdapter(WorldState.class, new WorldStateSerialization())
.registerTypeAdapter(FindUsernameResult.class, new FindUsernameResultDeserializer())
.registerTypeAdapter(Notification.class, new NotificationDeserializer())

View file

@ -56,12 +56,12 @@ object Analytics {
data.putAll(additionalData)
}
if (eventAction != null) {
if (this::amplitude.isInitialized) {
executeLambda(AnalyticsTarget.AMPLITUDE) {
if (target == null || target == AnalyticsTarget.AMPLITUDE) {
amplitude.track(eventAction, data)
}
}
if (this::firebase.isInitialized) {
executeLambda(AnalyticsTarget.FIREBASE) {
if (target == null || target == AnalyticsTarget.FIREBASE) {
firebase.logEvent(eventAction, bundleOf(*data.toList().toTypedArray()))
}
@ -94,17 +94,17 @@ object Analytics {
sharedPrefs.getString("launch_screen", "")?.let {
identify.set("launch_screen", it)
}
if (this::amplitude.isInitialized) {
executeLambda(AnalyticsTarget.AMPLITUDE) {
amplitude.identify(identify)
}
}
fun setUserID(userID: String) {
if (this::amplitude.isInitialized) {
executeLambda(AnalyticsTarget.AMPLITUDE) {
amplitude.setUserId(userID)
}
FirebaseCrashlytics.getInstance().setUserId(userID)
if (this::firebase.isInitialized) {
executeLambda(AnalyticsTarget.FIREBASE) {
firebase.setUserId(userID)
}
}
@ -113,10 +113,10 @@ object Analytics {
identifier: String,
value: Any?
) {
if (this::amplitude.isInitialized) {
executeLambda(AnalyticsTarget.AMPLITUDE) {
amplitude.identify(mapOf(identifier to value))
}
if (this::firebase.isInitialized) {
executeLambda(AnalyticsTarget.FIREBASE) {
firebase.setUserProperty(identifier, value?.toString())
}
}
@ -131,12 +131,21 @@ object Analytics {
fun setAnalyticsConsent(consents: Boolean?) {
val isEnabled = consents == true
if (this::firebase.isInitialized) {
executeLambda(AnalyticsTarget.FIREBASE) {
firebase.setAnalyticsCollectionEnabled(isEnabled)
FirebasePerformance.getInstance().isPerformanceCollectionEnabled = isEnabled
}
if (this::amplitude.isInitialized) {
FirebasePerformance.getInstance().isPerformanceCollectionEnabled = isEnabled
executeLambda(AnalyticsTarget.AMPLITUDE) {
amplitude.configuration.optOut = !isEnabled
}
}
private fun executeLambda(analyticsTarget: AnalyticsTarget, action: () -> Unit) {
when (analyticsTarget) {
AnalyticsTarget.AMPLITUDE -> if (!::amplitude.isInitialized) return
AnalyticsTarget.FIREBASE -> if (!::firebase.isInitialized) return
}
action()
}
}

View file

@ -37,6 +37,14 @@ class PushNotificationManager(
this.user = user
}
fun clearUser() {
this.user = null
this.refreshedToken = ""
sharedPreferences.edit {
remove(DEVICE_TOKEN_PREFERENCE_KEY)
}
}
/**
* New installs on Android 13 require
* Notification permissions be approved.
@ -131,7 +139,7 @@ class PushNotificationManager(
const val GROUP_ACTIVITY_NOTIFICATION_KEY = "groupActivity"
const val CONTENT_RELEASE_NOTIFICATION_KEY = "contentRelease"
const val G1G1_PROMO_KEY = "g1g1Promo"
private const val DEVICE_TOKEN_PREFERENCE_KEY = "device-token-preference"
const val DEVICE_TOKEN_PREFERENCE_KEY = "device-token-preference"
fun displayNotification(
remoteMessage: RemoteMessage,

View file

@ -1,3 +1,13 @@
package com.habitrpg.android.habitica.models.invitations
class InviteResponse
sealed class InviteResponse {
data class UserInvite(
val id: String,
val name: String,
val inviter: String
) : InviteResponse()
data class EmailInvite(
val email: String
) : InviteResponse()
}

View file

@ -22,9 +22,14 @@ import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -32,6 +37,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.view.ViewCompat
@ -186,7 +192,7 @@ open class MainActivity : BaseActivity(), SnackbarActivity {
viewModel.updateAllowPushNotifications(false)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !viewModel.sharedPreferences.getBoolean("prompted_exact_scheduling", false)) {
val alarmManager = this.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return@registerForActivityResult
val alarmManager = this.getSystemService(ALARM_SERVICE) as? AlarmManager ?: return@registerForActivityResult
if (!alarmManager.canScheduleExactAlarms()) {
val intent = Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
intent.setData(Uri.fromParts("package", applicationContext?.packageName, null))
@ -344,69 +350,76 @@ open class MainActivity : BaseActivity(), SnackbarActivity {
isMyProfile = true,
onAvatarClicked = {
showAsBottomSheet { dismiss ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(22.dp)
Box(
Modifier
.fillMaxWidth()
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f)
.padding(22.dp)
) {
ComposableAvatarView(
avatar = user,
configManager = appConfigManager
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(15.dp)
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.verticalScroll(rememberScrollState())
) {
HabiticaButton(
background = HabiticaTheme.colors.tintedUiSub,
color = Color.White,
modifier = Modifier.height(48.dp),
onClick = {
dismiss()
MainNavigationController.navigate(
MainNavDirections.openProfileActivity(
user?.id ?: ""
)
)
}
ComposableAvatarView(
avatar = user,
configManager = appConfigManager
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(15.dp)
) {
Text(stringResource(id = R.string.open_profile))
}
HabiticaButton(
background = HabiticaTheme.colors.tintedUiSub,
color = Color.White,
modifier = Modifier.height(48.dp),
onClick = {
dismiss()
MainNavigationController.navigate(R.id.avatarOverviewFragment)
}
) {
Text(stringResource(id = R.string.customize_avatar))
}
HabiticaButton(
background = HabiticaTheme.colors.tintedUiSub,
color = Color.White,
modifier = Modifier.height(48.dp),
onClick = {
dismiss()
user?.let {
val usecase = ShareAvatarUseCase()
lifecycleScope.launchCatching {
usecase.callInteractor(
ShareAvatarUseCase.RequestValues(
this@MainActivity,
it,
"Check out my avatar on Habitica!",
"avatar_bottomsheet"
)
HabiticaButton(
background = HabiticaTheme.colors.tintedUiSub,
color = Color.White,
modifier = Modifier.height(48.dp),
onClick = {
dismiss()
MainNavigationController.navigate(
MainNavDirections.openProfileActivity(
user?.id ?: ""
)
)
}
) {
Text(stringResource(id = R.string.open_profile))
}
HabiticaButton(
background = HabiticaTheme.colors.tintedUiSub,
color = Color.White,
modifier = Modifier.height(48.dp),
onClick = {
dismiss()
MainNavigationController.navigate(R.id.avatarOverviewFragment)
}
) {
Text(stringResource(id = R.string.customize_avatar))
}
HabiticaButton(
background = HabiticaTheme.colors.tintedUiSub,
color = Color.White,
modifier = Modifier.height(48.dp),
onClick = {
dismiss()
user?.let {
val usecase = ShareAvatarUseCase()
lifecycleScope.launchCatching {
usecase.callInteractor(
ShareAvatarUseCase.RequestValues(
this@MainActivity,
it,
"Check out my avatar on Habitica!",
"avatar_bottomsheet"
)
)
}
}
}
) {
Text(stringResource(id = R.string.share_avatar))
}
) {
Text(stringResource(id = R.string.share_avatar))
}
}
}
@ -414,13 +427,18 @@ open class MainActivity : BaseActivity(), SnackbarActivity {
},
onMemberRowClicked = {
showAsBottomSheet { onClose ->
val group by viewModel.userViewModel.currentTeamPlanGroup.collectAsState(
null
)
val group by viewModel.userViewModel.currentTeamPlanGroup.collectAsState(null)
val members by viewModel.userViewModel.currentTeamPlanMembers.observeAsState()
GroupPlanMemberList(members, group, appConfigManager) {
onClose()
FullProfileActivity.open(it)
Box(
Modifier
.fillMaxWidth()
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
GroupPlanMemberList(members, group, appConfigManager) { member ->
onClose()
FullProfileActivity.open(member)
}
}
}
},
@ -645,8 +663,8 @@ open class MainActivity : BaseActivity(), SnackbarActivity {
}
preferences?.sound?.let { soundManager.soundTheme = it }
CrashReporter.setCustomKey("day_start", (user.preferences?.dayStart ?: 0).toString())
CrashReporter.setCustomKey("timezone_offset", (user.preferences?.timezoneOffset ?: 0).toString())
CrashReporter.setCustomKey("day_start", "${user.preferences?.dayStart ?: 0}")
CrashReporter.setCustomKey("timezone_offset", "${user.preferences?.timezoneOffset ?: 0}")
Analytics.setAnalyticsConsent(user.preferences?.analyticsConsent)
displayDeathDialogIfNeeded()

View file

@ -23,10 +23,12 @@ class PrefsActivity :
super.onCreate(savedInstanceState)
setupToolbar(findViewById(R.id.toolbar))
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, PreferencesFragment())
.commit()
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, PreferencesFragment())
.commit()
}
}
override fun onSupportNavigateUp(): Boolean {

View file

@ -9,6 +9,7 @@ import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.databinding.SkillListItemBinding
import com.habitrpg.android.habitica.databinding.SkillTransformationListItemBinding
import com.habitrpg.android.habitica.models.Skill
import com.habitrpg.android.habitica.models.user.OwnedItem
import com.habitrpg.android.habitica.ui.views.HabiticaIconsHelper
@ -18,7 +19,8 @@ import com.habitrpg.common.habitica.extensions.loadImage
import io.realm.RealmList
class SkillsRecyclerViewAdapter :
RecyclerView.Adapter<SkillsRecyclerViewAdapter.SkillViewHolder>() {
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onUseSkill: ((Skill) -> Unit)? = null
var mana: Double = 0.0
@ -26,65 +28,73 @@ class SkillsRecyclerViewAdapter :
field = value
notifyDataSetChanged()
}
var level: Int = 0
set(value) {
field = value
notifyDataSetChanged()
}
var specialItems: RealmList<OwnedItem>? = null
set(value) {
field = value
notifyDataSetChanged()
}
private var skillList: List<Skill> = emptyList()
fun setSkillList(skillList: List<Skill>) {
this.skillList = skillList
this.notifyDataSetChanged()
companion object {
private const val TYPE_NORMAL = 0
private const val TYPE_SPECIAL = 1
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): SkillViewHolder {
return SkillViewHolder(parent.inflate(R.layout.skill_list_item))
fun setSkillList(list: List<Skill>) {
skillList = list
notifyDataSetChanged()
}
override fun onBindViewHolder(
holder: SkillViewHolder,
position: Int
) {
holder.bind(skillList[position])
override fun getItemViewType(position: Int): Int {
return if (skillList[position].habitClass == "special") TYPE_SPECIAL
else TYPE_NORMAL
}
override fun getItemCount(): Int {
return skillList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == TYPE_SPECIAL) {
val view = parent.inflate(R.layout.skill_transformation_list_item)
SpecialViewHolder(view)
} else {
val view = parent.inflate(R.layout.skill_list_item)
NormalViewHolder(view)
}
}
inner class SkillViewHolder(itemView: View) :
RecyclerView.ViewHolder(itemView),
View.OnClickListener {
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is SpecialViewHolder -> holder.bind(skillList[position])
is NormalViewHolder -> holder.bind(skillList[position])
}
}
override fun getItemCount(): Int = skillList.size
private inner class NormalViewHolder(itemView: View) :
RecyclerView.ViewHolder(itemView), View.OnClickListener {
private val binding = SkillListItemBinding.bind(itemView)
private val magicDrawable: Drawable
private val lockDrawable: Drawable
var skill: Skill? = null
var context: Context = itemView.context
private val context = itemView.context
private val magicDrawable: Drawable =
BitmapDrawable(context.resources, HabiticaIconsHelper.imageOfMagic())
private val lockDrawable: Drawable =
BitmapDrawable(
context.resources,
HabiticaIconsHelper.imageOfLocked(
ContextCompat.getColor(context, R.color.text_dimmed)
)
)
private var skill: Skill? = null
init {
binding.buttonWrapper.setOnClickListener(this)
magicDrawable = BitmapDrawable(context.resources, HabiticaIconsHelper.imageOfMagic())
lockDrawable =
BitmapDrawable(
context.resources,
HabiticaIconsHelper.imageOfLocked(
ContextCompat.getColor(
context,
R.color.text_dimmed
)
)
)
binding.skillItemContainer.setOnClickListener(this)
}
fun bind(skill: Skill) {
@ -97,103 +107,79 @@ class SkillsRecyclerViewAdapter :
binding.skillNotes.visibility = View.VISIBLE
binding.priceLabel.visibility = View.VISIBLE
if ("special" == skill.habitClass) {
binding.countLabel.visibility = View.VISIBLE
binding.countLabel.text = getOwnedCount(skill.key).toString()
binding.priceLabel.setText(R.string.skill_transformation_use)
if (context.isUsingNightModeResources()) {
binding.priceLabel.setTextColor(
ContextCompat.getColor(
context,
R.color.brand_500
)
)
} else {
binding.priceLabel.setTextColor(
ContextCompat.getColor(
context,
R.color.color_accent
)
)
}
binding.buttonIconView.setImageDrawable(null)
binding.countLabel.visibility = View.GONE
binding.priceLabel.text = skill.mana?.toString()
val manaColor = if (context.isUsingNightModeResources())
R.color.blue_500 else R.color.blue_10
binding.priceLabel.setTextColor(ContextCompat.getColor(context, manaColor))
binding.buttonIconView.setImageDrawable(magicDrawable)
if ((skill.mana ?: 0) > mana) {
binding.buttonWrapper.setBackgroundColor(
ContextCompat.getColor(
context,
R.color.offset_background
)
ContextCompat.getColor(context, R.color.offset_background)
)
binding.buttonIconView.alpha = 0.3f
binding.priceLabel.alpha = 0.3f
} else {
binding.buttonWrapper.setBackgroundColor(
ContextCompat.getColor(context, R.color.blue_500_24)
)
binding.buttonIconView.alpha = 1.0f
binding.priceLabel.alpha = 1.0f
} else {
binding.countLabel.visibility = View.GONE
binding.priceLabel.text = skill.mana?.toString()
if (context.isUsingNightModeResources()) {
binding.priceLabel.setTextColor(
ContextCompat.getColor(
context,
R.color.blue_500
)
)
} else {
binding.priceLabel.setTextColor(
ContextCompat.getColor(
context,
R.color.blue_10
)
)
}
binding.buttonIconView.setImageDrawable(magicDrawable)
if ((skill.mana ?: 0) > mana) {
binding.buttonWrapper.setBackgroundColor(
ContextCompat.getColor(
context,
R.color.offset_background
)
)
binding.buttonIconView.alpha = 0.3f
binding.priceLabel.alpha = 0.3f
} else {
binding.buttonWrapper.setBackgroundColor(
ContextCompat.getColor(
context,
R.color.blue_500_24
)
)
binding.buttonIconView.alpha = 1.0f
binding.priceLabel.alpha = 1.0f
}
if ((skill.lvl ?: 0) > level) {
binding.buttonWrapper.setBackgroundColor(
ContextCompat.getColor(
context,
R.color.offset_background
)
)
binding.skillText.setTextColor(
ContextCompat.getColor(
context,
R.color.text_dimmed
)
)
binding.skillText.text = context.getString(R.string.skill_unlocks_at, skill.lvl)
binding.skillNotes.visibility = View.GONE
binding.buttonIconView.setImageDrawable(lockDrawable)
binding.priceLabel.visibility = View.GONE
}
}
if ((skill.lvl ?: 0) > level) {
binding.buttonWrapper.setBackgroundColor(
ContextCompat.getColor(context, R.color.offset_background)
)
binding.skillText.setTextColor(
ContextCompat.getColor(context, R.color.text_dimmed)
)
binding.skillText.text = context.getString(
R.string.skill_unlocks_at, skill.lvl
)
binding.skillNotes.visibility = View.GONE
binding.buttonIconView.setImageDrawable(lockDrawable)
binding.priceLabel.visibility = View.GONE
}
binding.skillImage.loadImage("shop_" + skill.key)
}
override fun onClick(v: View) {
if ((skill?.lvl ?: 0) <= level) {
skill?.let { onUseSkill?.invoke(it) }
skill?.takeIf { (it.lvl ?: 0) <= level }?.also {
onUseSkill?.invoke(it)
}
}
}
private fun getOwnedCount(key: String): Int {
return specialItems?.firstOrNull { it.key == key }?.numberOwned ?: 0
private inner class SpecialViewHolder(itemView: View) :
RecyclerView.ViewHolder(itemView), View.OnClickListener {
private val binding = SkillTransformationListItemBinding.bind(itemView)
private val context = itemView.context
init {
binding.specialSkillContainer.setOnClickListener(this)
}
fun bind(skill: Skill) {
binding.skillText.text = skill.text
binding.skillNotes.text = skill.notes
binding.skillText.setTextColor(ContextCompat.getColor(context, R.color.text_primary))
binding.skillNotes.setTextColor(ContextCompat.getColor(context, R.color.text_ternary))
binding.countLabel.text = getOwnedCount(skill.key).toString()
binding.skillImage.loadImage("shop_" + skill.key)
}
override fun onClick(v: View) {
onUseSkill?.invoke(skillList[bindingAdapterPosition])
}
}
private fun getOwnedCount(key: String): Int =
specialItems?.firstOrNull { it.key == key }?.numberOwned ?: 0
}

View file

@ -1,9 +1,13 @@
package com.habitrpg.android.habitica.ui.adapter.inventory
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.drawable.Drawable
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.res.painterResource
import androidx.core.graphics.drawable.toDrawable
import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.habitrpg.android.habitica.R
@ -20,10 +24,15 @@ import com.habitrpg.android.habitica.models.inventory.SpecialItem
import com.habitrpg.android.habitica.models.user.OwnedItem
import com.habitrpg.android.habitica.models.user.OwnedPet
import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.android.habitica.ui.activities.SkillMemberActivity
import com.habitrpg.android.habitica.ui.activities.SkillTasksActivity
import com.habitrpg.android.habitica.ui.adapter.BaseRecyclerViewAdapter
import com.habitrpg.android.habitica.ui.fragments.skills.SkillDialogBottomSheetFragment
import com.habitrpg.android.habitica.ui.menu.BottomSheetMenu
import com.habitrpg.android.habitica.ui.menu.BottomSheetMenuItem
import com.habitrpg.android.habitica.ui.views.HabiticaIconsHelper
import com.habitrpg.android.habitica.ui.views.dialogs.DetailDialog
import com.habitrpg.common.habitica.extensions.asPainter
import com.habitrpg.common.habitica.extensions.layoutInflater
import com.habitrpg.common.habitica.extensions.loadImage
import com.habitrpg.common.habitica.extensions.localizedCapitalizeWithSpaces
@ -258,20 +267,19 @@ class ItemRecyclerAdapter(val context: Context) :
}
} else if (ownedItem?.itemType == "special") {
if ((ownedItem?.numberOwned ?: 0) > 0) {
menu.addMenuItem(BottomSheetMenuItem(resources.getString(R.string.use_item)))
if (item == null && ownedItem != null) {
// Special items that are not Mystery Item
val specialItem = SpecialItem()
ownedItem?.key?.let { key ->
specialItem.key = key
specialItem.text = key.localizedCapitalizeWithSpaces()
}
onUseSpecialItem?.invoke(specialItem)
}
}
return
}
menu.setSelectionRunnable { index ->
if (item == null && ownedItem != null) {
// Special items that are not Mystery Item
val specialItem = SpecialItem()
ownedItem?.key?.let { key ->
specialItem.key = key
specialItem.text = key.localizedCapitalizeWithSpaces()
}
onUseSpecialItem?.invoke(specialItem)
return@setSelectionRunnable
}
item?.let { selectedItem ->
if (!(selectedItem is QuestContent || selectedItem is SpecialItem || ownedItem?.itemType == "special") && index == 0) {
ownedItem?.let { selectedOwnedItem ->

View file

@ -13,6 +13,7 @@ import com.habitrpg.android.habitica.models.tasks.ChecklistItem
import com.habitrpg.android.habitica.models.tasks.Task
import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.android.habitica.ui.adapter.BaseRecyclerViewAdapter
import com.habitrpg.android.habitica.ui.adapter.DiffCallback
import com.habitrpg.android.habitica.ui.viewHolders.tasks.BaseTaskViewHolder
import com.habitrpg.android.habitica.ui.viewmodels.TasksViewModel
import com.habitrpg.android.habitica.ui.views.HabiticaIconsHelper

View file

@ -40,6 +40,7 @@ import com.habitrpg.android.habitica.data.InventoryRepository
import com.habitrpg.android.habitica.databinding.FragmentEquipmentDetailBinding
import com.habitrpg.android.habitica.helpers.AppConfigManager
import com.habitrpg.android.habitica.helpers.ReviewManager
import com.habitrpg.android.habitica.models.inventory.Equipment
import com.habitrpg.android.habitica.ui.adapter.inventory.EquipmentRecyclerViewAdapter
import com.habitrpg.android.habitica.ui.fragments.BaseMainFragment
import com.habitrpg.android.habitica.ui.helpers.KeyboardUtil
@ -80,6 +81,8 @@ class EquipmentDetailFragment :
@Inject
lateinit var configManager: AppConfigManager
private var pinnedGearKey: String? = null
override fun createBinding(
inflater: LayoutInflater,
container: ViewGroup?
@ -133,6 +136,7 @@ class EquipmentDetailFragment :
type = args.type
isCostume = args.isCostume
equippedGear = args.equippedGear
pinnedGearKey = equippedGear
}
binding?.refreshLayout?.setOnRefreshListener(this)
binding?.recyclerView?.onRefresh = { onRefresh() }
@ -175,26 +179,29 @@ class EquipmentDetailFragment :
binding?.recyclerView?.layoutManager = LinearLayoutManager(mainActivity)
binding?.recyclerView?.itemAnimator = SafeDefaultItemAnimator()
type?.let { type ->
type?.let { gearType ->
lifecycleScope.launchCatching {
inventoryRepository.getOwnedEquipment(type)
inventoryRepository.getOwnedEquipment(gearType)
.combine(searchedText) { equipment, query ->
if (query.isNullOrBlank()) {
return@combine equipment
}
val tokens = query.split(" ")
val tokenCount = tokens.size
equipment.filter {
var matchCount = 0
for (token in tokens) {
if (it.text.contains(token, true) || it.notes.contains(token, true)) {
matchCount += 1
if (query.isNullOrBlank()) equipment
else {
val tokens = query.split(" ")
equipment.filter { gear ->
tokens.all { token ->
gear.text.contains(token, true) || gear.notes.contains(token, true)
}
}
return@filter matchCount == tokenCount
}
}
.map { it.sortedBy { equipment -> equipment.text } }
.map { list ->
val sorted = list.sortedBy { it.text }
pinnedGearKey?.let { key ->
sorted.sortedWith(
compareBy<Equipment> { it.key != key }
.thenBy { it.text }
)
} ?: sorted
}
.collect { adapter.data = it }
}
}

View file

@ -24,6 +24,7 @@ import com.habitrpg.android.habitica.helpers.EventCategory
import com.habitrpg.android.habitica.helpers.HitType
import com.habitrpg.android.habitica.helpers.ReviewManager
import com.habitrpg.android.habitica.interactors.HatchPetUseCase
import com.habitrpg.android.habitica.models.Skill
import com.habitrpg.android.habitica.models.inventory.Egg
import com.habitrpg.android.habitica.models.inventory.Food
import com.habitrpg.android.habitica.models.inventory.HatchingPotion
@ -38,18 +39,22 @@ import com.habitrpg.android.habitica.ui.activities.MainActivity
import com.habitrpg.android.habitica.ui.activities.SkillMemberActivity
import com.habitrpg.android.habitica.ui.adapter.inventory.ItemRecyclerAdapter
import com.habitrpg.android.habitica.ui.fragments.BaseFragment
import com.habitrpg.android.habitica.ui.fragments.skills.SkillDialogBottomSheetFragment
import com.habitrpg.android.habitica.ui.helpers.SafeDefaultItemAnimator
import com.habitrpg.android.habitica.ui.menu.BottomSheetMenuItem
import com.habitrpg.android.habitica.ui.viewmodels.MainUserViewModel
import com.habitrpg.android.habitica.ui.views.HabiticaSnackbar
import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaAlertDialog
import com.habitrpg.android.habitica.ui.views.dialogs.OpenedMysteryitemDialog
import com.habitrpg.common.habitica.extensions.loadImage
import com.habitrpg.common.habitica.extensions.localizedCapitalizeWithSpaces
import com.habitrpg.common.habitica.extensions.observeOnce
import com.habitrpg.common.habitica.helpers.EmptyItem
import com.habitrpg.common.habitica.helpers.ExceptionHandler
import com.habitrpg.common.habitica.helpers.MainNavigationController
import com.habitrpg.common.habitica.helpers.launchCatching
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -87,6 +92,7 @@ class ItemRecyclerFragment :
var transformationItems: MutableList<OwnedItem> = mutableListOf()
var itemTypeText: String? = null
private var selectedSpecialItem: SpecialItem? = null
private var specialSkills: MutableList<Skill> = mutableListOf()
internal var layoutManager: androidx.recyclerview.widget.LinearLayoutManager? = null
override var binding: FragmentItemsBinding? = null
@ -113,6 +119,7 @@ class ItemRecyclerFragment :
this.itemType = savedInstanceState.getString(ITEM_TYPE_KEY, "")
this.itemTypeText = savedInstanceState.getString(ITEM_TYPE_TEXT_KEY, "")
}
getSpecialSkills()
binding?.refreshLayout?.setOnRefreshListener(this)
val buttonMethod = {
@ -170,6 +177,24 @@ class ItemRecyclerFragment :
this.loadItems()
}
private fun getSpecialSkills() {
// Get special skills for description of special items
lifecycleScope.launchCatching {
val user = userViewModel.user.value ?: return@launchCatching
userRepository.getSkills(user)
.combine(userRepository.getSpecialItems(user)) { skills, items ->
val allEntries = mutableListOf<Skill>()
for (skill in skills) {
allEntries.add(skill)
}
for (item in items) {
allEntries.add(item)
}
return@combine allEntries
}.collect { skills -> specialSkills = skills }
}
}
private fun setAdapter() {
val context = activity
@ -180,7 +205,23 @@ class ItemRecyclerFragment :
}
binding?.recyclerView?.adapter = adapter
}
adapter?.onUseSpecialItem = { onSpecialItemSelected(it) }
adapter?.onUseSpecialItem = { specialItem ->
val specialSkill = specialSkills.find { it.key == specialItem.key }
if (specialSkill != null) {
val skillIdentifier = "shop_"
val bottomSheet = SkillDialogBottomSheetFragment.newInstance(
skillTitle = specialSkill.text,
skillDescription = specialSkill.notes ?: "",
skillKey = specialSkill.key,
skillPath = skillIdentifier,
isTransformationItem = true,
onUseSkill = {
onSpecialItemSelected(specialItem)
}
)
bottomSheet.show(childFragmentManager, "SkillDialogBottomSheet")
}
}
adapter?.onSellItem = { item, ownedItem ->
showSellItemConfirmation(item, ownedItem)
}

View file

@ -8,7 +8,10 @@ import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import android.text.InputType
import android.view.View
import android.widget.EditText
import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getSystemService
@ -23,13 +26,21 @@ import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.data.ApiClient
import com.habitrpg.android.habitica.extensions.addCancelButton
import com.habitrpg.android.habitica.extensions.addCloseButton
import com.habitrpg.android.habitica.extensions.addOkButton
import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.android.habitica.ui.activities.FixCharacterValuesActivity
import com.habitrpg.android.habitica.ui.fragments.preferences.HabiticaAccountDialog.AccountUpdateConfirmed
import com.habitrpg.android.habitica.ui.helpers.KeyboardUtil
import com.habitrpg.android.habitica.ui.viewmodels.AuthenticationViewModel
import com.habitrpg.android.habitica.ui.views.AboutMeScreen
import com.habitrpg.android.habitica.ui.views.ApiTokenBottomSheet
import com.habitrpg.android.habitica.ui.views.ChangeDisplayNameScreen
import com.habitrpg.android.habitica.ui.views.ChangeEmailScreen
import com.habitrpg.android.habitica.ui.views.ChangePasswordScreen
import com.habitrpg.android.habitica.ui.views.ChangeUsernameScreen
import com.habitrpg.android.habitica.ui.views.ExtraLabelPreference
import com.habitrpg.android.habitica.ui.views.HabiticaSnackbar
import com.habitrpg.android.habitica.ui.views.PhotoUrlScreen
import com.habitrpg.android.habitica.ui.views.SnackbarActivity
import com.habitrpg.android.habitica.ui.views.ValidatingEditText
import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaAlertDialog
@ -155,13 +166,13 @@ class AccountPreferenceFragment :
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
"username" -> showLoginNameDialog()
"username" -> showChangeUsernameDialog()
"confirm_username" -> showConfirmUsernameDialog()
"email" -> {
if (user?.authentication?.hasPassword != true && user?.authentication?.localAuthentication?.email?.isNotBlank() != true) {
showAddPasswordDialog(true)
} else {
showEmailDialog()
showChangeEmailDialog()
}
}
@ -179,25 +190,15 @@ class AccountPreferenceFragment :
}
"APIToken" -> {
copyValue(getString(R.string.SP_APIToken_title), hostConfig.apiKey)
ApiTokenBottomSheetFragment.newInstance(hostConfig.apiKey).show(childFragmentManager, ApiTokenBottomSheetFragment.TAG)
return true
}
"display_name" ->
updateUser(
"profile.name",
user?.profile?.name,
getString(R.string.display_name),
)
"display_name" -> showChangeDisplayNameDialog()
"photo_url" ->
updateUser(
"profile.imageUrl",
user?.profile?.imageUrl,
getString(R.string.photo_url),
)
"photo_url" -> showPhotoUrlDialog()
"about" -> updateUser("profile.blurb", user?.profile?.blurb, getString(R.string.about))
"about" -> showAboutMeDialog()
"google_auth" -> {
if (user?.authentication?.hasGoogleAuth == true) {
disconnect("google", "Google")
@ -256,61 +257,173 @@ class AccountPreferenceFragment :
)
}
private fun updateUser(
path: String,
value: String?,
title: String,
) {
showSingleEntryDialog(value, title) {
if (value != it) {
lifecycleScope.launchCatching {
userRepository.updateUser(path, it ?: "")
private fun showChangePasswordDialog() {
val sheet = SettingsFormBottomSheet()
sheet.content = {
ChangePasswordScreen(
onBack = { sheet.dismiss() },
onSave = { oldPassword, newPassword ->
lifecycleScope.launchCatching {
KeyboardUtil.dismissKeyboard(activity)
val response = userRepository.updatePassword(
oldPassword, newPassword, newPassword
)
response?.apiToken?.let {
viewModel.saveTokens(it, user?.id ?: "")
sheet.dismiss()
}
}
},
onForgot = {
showForgotPasswordDialog()
sheet.dismiss()
}
}
)
}
sheet.show(childFragmentManager, SettingsFormBottomSheet.TAG)
}
private fun showChangePasswordDialog() {
val inflater = context?.layoutInflater
val view = inflater?.inflate(R.layout.dialog_edittext_change_pw, null)
val oldPasswordEditText =
view?.findViewById<ValidatingEditText>(R.id.old_password_edit_text)
val passwordEditText = view?.findViewById<ValidatingEditText>(R.id.new_password_edit_text)
passwordEditText?.validator = { (it?.length ?: 0) >= 8 }
passwordEditText?.errorText = getString(R.string.password_too_short, 8)
val passwordRepeatEditText =
view?.findViewById<ValidatingEditText>(R.id.new_password_repeat_edit_text)
passwordRepeatEditText?.validator = { it == passwordEditText?.text }
passwordRepeatEditText?.errorText = getString(R.string.password_not_matching)
context?.let { context ->
val dialog = HabiticaAlertDialog(context)
dialog.setTitle(R.string.change_password)
dialog.addButton(R.string.change, true, false, false) { d, _ ->
KeyboardUtil.dismissKeyboard(activity)
passwordEditText?.showErrorIfNecessary()
passwordRepeatEditText?.showErrorIfNecessary()
if (passwordEditText?.isValid != true || passwordRepeatEditText?.isValid != true) return@addButton
lifecycleScope.launchCatching {
val response = userRepository.updatePassword(
oldPasswordEditText?.text ?: "",
passwordEditText.text ?: "",
passwordRepeatEditText.text ?: "",
)
response?.apiToken?.let {
viewModel.saveTokens(it, user?.id ?: "")
private fun showChangeEmailDialog() {
val sheet = SettingsFormBottomSheet()
sheet.content = {
ChangeEmailScreen(
initialEmail = user?.authentication?.localAuthentication?.email ?: "",
onBack = { sheet.dismiss() },
onSave = { newEmail, password ->
lifecycleScope.launchCatching {
KeyboardUtil.dismissKeyboard(activity)
userRepository.updateEmail(
newEmail,
password,
)
lifecycleScope.launch(ExceptionHandler.coroutine()) {
userRepository.retrieveUser(true, true)
}
configurePreference(findPreference("email"), newEmail)
sheet.dismiss()
}
(activity as? SnackbarActivity)?.showSnackbar(
content = context.getString(R.string.password_changed),
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS,
)
},
onForgotPassword = {
showForgotPasswordDialog()
sheet.dismiss()
}
d.dismiss()
}
dialog.addCancelButton()
dialog.setAdditionalContentView(view)
dialog.setAdditionalContentSidePadding(12)
dialog.show()
)
}
sheet.show(childFragmentManager, SettingsFormBottomSheet.TAG)
}
private fun showChangeUsernameDialog() {
val sheet = SettingsFormBottomSheet()
sheet.content = {
ChangeUsernameScreen(
initial = user?.username ?: "",
onBack = { sheet.dismiss() },
onSave = { newUsername ->
lifecycleScope.launchCatching {
KeyboardUtil.dismissKeyboard(activity)
if (!newUsername.contains(" ") && newUsername.length > 1 && newUsername.length < 20 && !newUsername.contains(regex)) {
val user = userRepository.updateLoginName(newUsername ?: "")
if (user == null || user.username != newUsername) {
userRepository.retrieveUser(false, forced = true)
}
}
sheet.dismiss()
}
}
)
}
sheet.show(childFragmentManager, SettingsFormBottomSheet.TAG)
}
private fun showChangeDisplayNameDialog() {
val sheet = SettingsFormBottomSheet()
sheet.content = {
ChangeDisplayNameScreen(
initial = user?.profile?.name ?: "",
onBack = { sheet.dismiss() },
onSave = { newDisplayName ->
lifecycleScope.launchCatching {
KeyboardUtil.dismissKeyboard(activity)
userRepository.updateUser("profile.name", newDisplayName)
sheet.dismiss()
}
}
)
}
sheet.show(childFragmentManager, SettingsFormBottomSheet.TAG)
}
private fun showForgotPasswordDialog() {
val input = EditText(requireContext())
input.setAutofillHints(EditText.AUTOFILL_HINT_EMAIL_ADDRESS)
input.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
input.hint = getString(R.string.forgot_password_hint_example)
input.textSize = 16f
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
)
input.layoutParams = lp
val alertDialog = HabiticaAlertDialog(requireContext())
alertDialog.setTitle(R.string.forgot_password_title)
alertDialog.setMessage(R.string.forgot_password_description)
alertDialog.setAdditionalContentView(input)
alertDialog.addButton(R.string.send, true) { _, _ ->
lifecycleScope.launchCatching {
userRepository.sendPasswordResetEmail(input.text.toString())
showPasswordEmailConfirmation()
}
}
alertDialog.addCancelButton()
alertDialog.show()
}
private fun showAboutMeDialog() {
val sheet = SettingsFormBottomSheet()
sheet.content = {
AboutMeScreen(
initial = user?.profile?.blurb.orEmpty(),
onBack = { sheet.dismiss() },
onSave = { about ->
lifecycleScope.launchCatching {
userRepository.updateUser("profile.blurb", about)
sheet.dismiss()
}
}
)
}
sheet.show(childFragmentManager, SettingsFormBottomSheet.TAG)
}
private fun showPhotoUrlDialog() {
val sheet = SettingsFormBottomSheet()
sheet.content = {
PhotoUrlScreen(
initial = user?.profile?.imageUrl ?: "",
onBack = { sheet.dismiss() },
onSave = { photoUrl ->
lifecycleScope.launchCatching {
KeyboardUtil.dismissKeyboard(activity)
userRepository.updateUser("profile.imageUrl", photoUrl)
sheet.dismiss()
}
}
)
}
sheet.show(childFragmentManager, SettingsFormBottomSheet.TAG)
}
private fun showPasswordEmailConfirmation() {
val alert = HabiticaAlertDialog(requireContext())
alert.setMessage(R.string.forgot_password_confirmation)
alert.addOkButton()
alert.show()
}
private fun showAddPasswordDialog(showEmail: Boolean) {
@ -363,44 +476,6 @@ class AccountPreferenceFragment :
}
}
private fun showEmailDialog() {
val inflater = context?.layoutInflater
val view = inflater?.inflate(R.layout.dialog_edittext_confirm_pw, null)
val emailEditText = view?.findViewById<ValidatingEditText>(R.id.email_edit_text)
emailEditText?.text = user?.authentication?.localAuthentication?.email
emailEditText?.validator = { PatternsCompat.EMAIL_ADDRESS.matcher(it ?: "").matches() }
emailEditText?.errorText = getString(R.string.email_invalid)
emailEditText?.hint = context?.getString(R.string.email)
val passwordEditText = view?.findViewById<ValidatingEditText>(R.id.password_edit_text)
if (user?.authentication?.hasPassword != true) {
passwordEditText?.isVisible = false
}
context?.let { context ->
val dialog = HabiticaAlertDialog(context)
dialog.setTitle(R.string.change_email)
dialog.addButton(R.string.change, true, false, false) { _, _ ->
KeyboardUtil.dismissKeyboard(activity)
emailEditText?.showErrorIfNecessary()
if (emailEditText?.isValid != true) return@addButton
lifecycleScope.launchCatching {
userRepository.updateEmail(
emailEditText.text.toString(),
passwordEditText?.text.toString(),
)
lifecycleScope.launch(ExceptionHandler.coroutine()) {
userRepository.retrieveUser(true, true)
}
configurePreference(findPreference("email"), emailEditText.text.toString())
}
dialog.dismiss()
}
dialog.addCancelButton()
dialog.setAdditionalContentView(view)
dialog.setAdditionalContentSidePadding(12.dpToPx(context))
dialog.show()
}
}
private val regex = "[^a-zA-Z0-9_-]".toRegex()
private fun showLoginNameDialog() {
@ -486,7 +561,10 @@ class AccountPreferenceFragment :
userRepository.deleteAccount(password)
dialog?.dismiss()
accountDialog.dismiss()
context?.let { HabiticaBaseApplication.logout(it) }
context?.let {
val user = userViewModel.user.value
HabiticaBaseApplication.logout(it, user)
}
activity?.finish()
}
}

View file

@ -0,0 +1,81 @@
package com.habitrpg.android.habitica.ui.fragments.preferences
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.ui.views.ApiTokenBottomSheet
import com.habitrpg.android.habitica.ui.views.HabiticaSnackbar
import com.habitrpg.android.habitica.ui.views.SnackbarActivity
class ApiTokenBottomSheetFragment : BottomSheetDialogFragment() {
private lateinit var apiToken: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
apiToken = arguments?.getString(ARG_API_TOKEN) ?: ""
}
override fun onStart() {
super.onStart()
val nightModeFlags = requireContext()
.resources
.configuration
.uiMode and Configuration.UI_MODE_NIGHT_MASK
if (nightModeFlags == Configuration.UI_MODE_NIGHT_NO) {
dialog?.window?.let { window ->
window.statusBarColor = ContextCompat.getColor(
requireContext(),
android.R.color.transparent
)
window.navigationBarColor = ContextCompat.getColor(
requireContext(),
android.R.color.transparent
)
WindowInsetsControllerCompat(window, window.decorView).apply {
isAppearanceLightStatusBars = true
isAppearanceLightNavigationBars = true
}
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
ApiTokenBottomSheet(apiToken = apiToken, onCopyToken = { copiedToken ->
(activity as? SnackbarActivity)?.showSnackbar(
content = getString(R.string.copied_to_clipboard, copiedToken),
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS,
)
dismiss()
})
}
}
}
companion object {
private const val ARG_API_TOKEN = "arg_api_token"
fun newInstance(apiToken: String): ApiTokenBottomSheetFragment =
ApiTokenBottomSheetFragment().apply {
arguments = Bundle().apply {
putString(ARG_API_TOKEN, apiToken)
}
}
const val TAG = "ApiTokenBottomSheet"
}
}

View file

@ -251,7 +251,7 @@ class PreferencesFragment :
val dialog = HabiticaAlertDialog(context)
dialog.setTitle(R.string.are_you_sure)
dialog.addButton(R.string.logout, true) { _, _ ->
HabiticaBaseApplication.logout(context)
HabiticaBaseApplication.logout(context, user)
activity?.finish()
}
dialog.addCancelButton()

View file

@ -0,0 +1,82 @@
package com.habitrpg.android.habitica.ui.fragments.preferences
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.habitrpg.common.habitica.theme.HabiticaTheme
import androidx.compose.runtime.setValue
import androidx.core.view.WindowInsetsControllerCompat
import com.google.android.material.bottomsheet.BottomSheetBehavior
class SettingsFormBottomSheet : BottomSheetDialogFragment() {
var content: @Composable () -> Unit = {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// this is a workaround to prevent the screen from appearing blank during config changes (Light/Dark mode change for example)
retainInstance = true
}
override fun onStart() {
super.onStart()
val nightModeFlags = requireContext()
.resources
.configuration
.uiMode and Configuration.UI_MODE_NIGHT_MASK
if (nightModeFlags == Configuration.UI_MODE_NIGHT_NO) {
dialog?.window?.apply {
statusBarColor = Color.TRANSPARENT
navigationBarColor = Color.TRANSPARENT
WindowInsetsControllerCompat(this, decorView).apply {
isAppearanceLightStatusBars = true
isAppearanceLightNavigationBars = true
}
}
}
dialog?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
?.let { sheet ->
sheet.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
BottomSheetBehavior.from(sheet).apply {
state = BottomSheetBehavior.STATE_EXPANDED
isDraggable = false
skipCollapsed = true
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = ComposeView(requireContext()).apply {
setContent {
HabiticaTheme {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
AnimatedVisibility(visible = visible, enter = fadeIn()) {
content()
}
}
}
}
companion object {
const val TAG = "SettingsFormBottomSheet"
}
}

View file

@ -21,12 +21,13 @@ class SkillDialogBottomSheetFragment : BottomSheetDialogFragment() {
private const val ARG_SKILL_MP_COST = "skill_mp_cost"
fun newInstance(
skillTitle: String,
skillDescription: String,
skillMpCost: String,
skillPath: String,
skillKey: String,
resourceIcon: Drawable,
skillTitle: String? = "",
skillDescription: String? = "",
skillMpCost: String? = "",
skillPath: String? = "",
skillKey: String? = "",
resourceIcon: Drawable? = null,
isTransformationItem: Boolean = false,
onUseSkill: () -> Unit
): SkillDialogBottomSheetFragment {
return SkillDialogBottomSheetFragment().apply {
@ -36,9 +37,10 @@ class SkillDialogBottomSheetFragment : BottomSheetDialogFragment() {
putString(ARG_SKILL_MP_COST, skillMpCost)
}
this.resourceIcon = resourceIcon
this.skillKey = skillKey
this.skillPath = skillPath
this.skillKey = skillKey ?: ""
this.skillPath = skillPath ?: ""
this.onUseSkill = onUseSkill
this.isTransformationItem = isTransformationItem
}
}
}
@ -47,6 +49,7 @@ class SkillDialogBottomSheetFragment : BottomSheetDialogFragment() {
private var resourceIcon: Drawable? = null
var skillKey = ""
var skillPath = ""
var isTransformationItem: Boolean = false
override fun onCreateView(
inflater: LayoutInflater,
@ -62,6 +65,7 @@ class SkillDialogBottomSheetFragment : BottomSheetDialogFragment() {
title = requireArguments().getString(ARG_SKILL_TITLE) ?: "",
description = requireArguments().getString(ARG_SKILL_DESCRIPTION) ?: "",
mpCost = requireArguments().getString(ARG_SKILL_MP_COST) ?: "",
isTransformationItem = isTransformationItem,
onUseSkill = {
onUseSkill?.invoke()
dismiss()

View file

@ -106,6 +106,7 @@ class SkillsFragment : BaseMainFragment<FragmentRecyclerviewBinding>() {
val context = context ?: return
val resourceIconDrawable: Drawable = HabiticaIconsHelper.imageOfMagic().toDrawable(context.resources)
val skillIdentifier = "shop_"
val isTransformationItem = skill.habitClass == "special"
val bottomSheet = SkillDialogBottomSheetFragment.newInstance(
skillTitle = skill.text,
@ -114,6 +115,7 @@ class SkillsFragment : BaseMainFragment<FragmentRecyclerviewBinding>() {
skillPath = skillIdentifier,
skillMpCost = "${skill.mana?.toInt() ?: 0} MP",
resourceIcon = resourceIconDrawable,
isTransformationItem = isTransformationItem,
onUseSkill = {
when {
"special" == skill.habitClass -> {
@ -133,8 +135,6 @@ class SkillsFragment : BaseMainFragment<FragmentRecyclerviewBinding>() {
}
)
bottomSheet.show(childFragmentManager, "SkillDialogBottomSheet")
}
private fun displaySkillResult(

View file

@ -8,6 +8,8 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
@ -140,11 +142,41 @@ open class ChatFragment : BaseFragment<FragmentChatBinding>() {
}
}
}
binding?.chatBarView?.let { applyScrollContentWindowInsets(it) }
binding?.root.apply {
ViewCompat.setOnApplyWindowInsetsListener(this!!) { _, insets ->
val ime = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
val nav = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
binding?.chatBarView?.translationY = -ime.toFloat()
binding?.chatBarView?.setPadding(
binding?.chatBarView!!.paddingLeft,
binding?.chatBarView!!.paddingTop,
binding?.chatBarView!!.paddingRight,
nav
)
binding?.recyclerView?.translationY = -ime.toFloat()
binding?.recyclerView?.setPadding(
binding?.recyclerView!!.paddingLeft,
binding?.recyclerView!!.paddingTop,
binding?.recyclerView!!.paddingRight,
ime + nav
)
insets
}
ViewCompat.requestApplyInsets(this)
}
}
override fun onResume() {
super.onResume()
binding?.root?.let { ViewCompat.requestApplyInsets(it) }
setNavigatedToFragment()
}
@ -224,10 +256,10 @@ open class ChatFragment : BaseFragment<FragmentChatBinding>() {
if (chatMessages.isEmpty()) {
binding?.recyclerView?.state = RecyclerViewState.EMPTY
binding?.chatEmptyTextview?.fadeInAnimation()
binding?.chatEmptyContainer?.fadeInAnimation()
} else {
binding?.recyclerView?.state = RecyclerViewState.DISPLAYING_DATA
binding?.chatEmptyTextview?.isGone = true
binding?.chatEmptyContainer?.isGone = true
}
viewModel.gotNewMessages = true

View file

@ -1,6 +1,7 @@
package com.habitrpg.android.habitica.ui.fragments.social.party
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -72,6 +73,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@ -279,14 +281,25 @@ fun PartyInviteView(
inviteButtonState = LoadingButtonState.CONTENT
}
}) {
val responses = viewModel.sendInvites()
if ((responses?.size ?: 0) > 0) {
val responses: List<InviteResponse>? = viewModel.sendInvites()
if (!responses.isNullOrEmpty()) {
inviteButtonState = LoadingButtonState.SUCCESS
delay(2.toDuration(DurationUnit.SECONDS))
// we are not differentiating between user and email invites here, however in the event we do - we can handle it
responses.forEach { resp ->
when (resp) {
is InviteResponse.UserInvite -> {
// UserInvite is a UUID
}
is InviteResponse.EmailInvite -> {
// EmailInvite is an email address
}
}
}
delay(2.seconds)
dismiss()
} else {
inviteButtonState = LoadingButtonState.FAILED
delay(2.toDuration(DurationUnit.SECONDS))
delay(2.seconds)
inviteButtonState = LoadingButtonState.CONTENT
}
}

View file

@ -581,12 +581,19 @@ open class TaskRecyclerViewFragment :
if (it != null) {
when (taskType) {
TaskType.TODO -> {
viewModel.setActiveFilter(
TaskType.TODO,
viewModel.getTaskFilterPreference(TaskType.TODO)
)
// Handle case where a initial filter preference for to-dos were set for FILTER_ALL
if (viewModel.getTaskFilterPreference(TaskType.TODO) == Task.FILTER_ALL) {
viewModel.setActiveFilter(
TaskType.TODO,
Task.FILTER_ACTIVE
)
} else {
viewModel.setActiveFilter(
TaskType.TODO,
viewModel.getTaskFilterPreference(TaskType.TODO)
)
}
}
TaskType.DAILY -> {
if (!viewModel.initialPreferenceFilterSet) {
viewModel.initialPreferenceFilterSet = true

View file

@ -249,7 +249,9 @@ class TasksFragment :
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
bottomNavigation?.selectedPosition = position
updateFilterIcon(getTaskTypeFromTabPosition(position))
binding?.viewPager?.post {
updateFilterIcon(getTaskTypeFromTabPosition(position))
}
}
}
)

View file

@ -1,7 +1,6 @@
package com.habitrpg.android.habitica.ui.viewHolders.tasks
import android.content.Context
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.view.MotionEvent
import android.view.View
@ -38,8 +37,6 @@ abstract class BaseTaskViewHolder(
private val scope = MainScope()
var task: Task? = null
var existingMarkdownText: Spanned? = null
var existingMarkdownNotes: Spanned? = null
var movingFromPosition: Int? = null
var errorButtonClicked: (() -> Unit)? = null
var userID: String? = null
@ -172,46 +169,23 @@ abstract class BaseTaskViewHolder(
notesTextView?.visibility = View.GONE
}
val text = data.text ?: ""
if (!MarkdownParser.containsMarkdown(text)) {
titleTextView.text = text
existingMarkdownText = null
val titleText = data.text ?: ""
if (!MarkdownParser.containsMarkdown(titleText)) {
titleTextView.text = titleText
} else {
scope.launch(Dispatchers.IO) {
if (text.isNotEmpty() && MarkdownParser.containsMarkdown(text)) {
val parsedText = MarkdownParser.parseMarkdown(text)
if (existingMarkdownText != null && existingMarkdownText == parsedText) {
return@launch
}
existingMarkdownText = parsedText
withContext(Dispatchers.Main) {
data.parsedText = parsedText
titleTextView.setParsedMarkdown(parsedText)
}
}
}
val parsedText = MarkdownParser.parseMarkdown(titleText)
data.parsedText = parsedText
titleTextView.setParsedMarkdown(parsedText)
}
if (displayMode != "minimal") {
val notes = data.notes ?: ""
if (!MarkdownParser.containsMarkdown(notes)) {
notesTextView?.text = data.notes
existingMarkdownNotes = null
notesTextView?.text = notes
} else {
scope.launch(Dispatchers.IO) {
if (notes.isEmpty() || !MarkdownParser.containsMarkdown(notes)) {
return@launch
}
val parsedNotes = MarkdownParser.parseMarkdown(notes)
if (existingMarkdownNotes != null && existingMarkdownNotes == parsedNotes) {
return@launch
}
existingMarkdownNotes = parsedNotes
withContext(Dispatchers.Main) {
data.parsedNotes = parsedNotes
notesTextView?.setParsedMarkdown(parsedNotes)
}
}
val parsedNotes = MarkdownParser.parseMarkdown(notes)
data.parsedNotes = parsedNotes
notesTextView?.setParsedMarkdown(parsedNotes)
}
} else {
notesTextView?.visibility = View.GONE

View file

@ -40,7 +40,7 @@ constructor(
val partyID: String?
get() = validatedUser?.party?.id
val isUserFainted: Boolean
get() = (validatedUser?.stats?.hp ?: 1.0) == 0.0
get() = (validatedUser?.stats?.hp ?: 1.0) <= 0.0
val isUserInParty: Boolean
get() = validatedUser?.hasParty == true
val mirrorGroupTasks: List<String>

View file

@ -199,9 +199,12 @@ constructor(
if (activeFilters[type] == null) {
return false
}
return if (TaskType.TODO == type) {
Task.FILTER_ACTIVE != activeFilters[type]
when(activeFilters[type]) {
Task.FILTER_ACTIVE -> false
Task.FILTER_ALL -> false
else -> true
}
} else {
Task.FILTER_ALL != activeFilters[type]
}
@ -289,7 +292,7 @@ constructor(
fun getTaskFilterPreference(
type: TaskType
): String {
return sharedPreferences.getString("filter_${type.value}", Task.FILTER_ALL) ?: Task.FILTER_ALL
return sharedPreferences.getString("filter_${type.value}", Task.FILTER_ALL) ?: if (TaskType.TODO == type) Task.FILTER_ACTIVE else Task.FILTER_ALL
}
fun createQuery(unfilteredData: OrderedRealmCollection<Task>): RealmQuery<Task>? {

View file

@ -0,0 +1,142 @@
package com.habitrpg.android.habitica.ui.views
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.ui.theme.colors
import com.habitrpg.common.habitica.theme.HabiticaTheme
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
@Composable
fun ApiTokenBottomSheet(
apiToken: String,
onCopyToken: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = HabiticaTheme.colors
val background = colors.windowBackground
val fieldBackground = colorResource(id = R.color.gray600_gray10)
val mainTextColor = colors.textPrimary
val secondaryText = colors.textSecondary
val tokenTextColor = colorResource(id = R.color.gray200_gray400)
val buttonBg = colorResource(id = R.color.yellow_100)
val buttonText = colorResource(id = R.color.yellow_1)
val lockIconColor = colors.textSecondary
Box(
modifier
.fillMaxWidth()
.background(background, RoundedCornerShape(22.dp))
.padding(horizontal = 20.dp, vertical = 8.dp)
) {
Column(Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.padding(bottom = 16.dp)
.background(colorResource(R.color.content_background_offset))
.size(24.dp, 3.dp)
.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(id = R.string.api_token_title),
color = mainTextColor,
fontSize = 21.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(22.dp))
Text(
stringResource(id = R.string.api_token_is_password),
color = mainTextColor,
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
stringResource(id = R.string.api_token_is_password_info),
color = secondaryText,
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
lineHeight = 20.sp,
)
Spacer(modifier = Modifier.height(22.dp))
Text(
stringResource(id = R.string.api_token_reset_title),
color = mainTextColor,
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
stringResource(id = R.string.api_token_reset_info),
color = secondaryText,
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
lineHeight = 20.sp,
)
Spacer(modifier = Modifier.height(22.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(fieldBackground)
.padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.icon_lock),
contentDescription = stringResource(R.string.locked),
tint = lockIconColor,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
apiToken,
color = tokenTextColor,
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier.weight(1f),
maxLines = 1
)
}
Spacer(modifier = Modifier.height(22.dp))
HabiticaButton(
background = buttonBg,
color = buttonText,
onClick = { onCopyToken(apiToken) },
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
contentPadding = PaddingValues(0.dp),
fontSize = 16.sp
) {
Text(stringResource(id = R.string.copy_token))
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Preview(showBackground = true, widthDp = 380, heightDp = 550, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun ApiTokenBottomSheetPreview() {
HabiticaTheme {
ApiTokenBottomSheet(
apiToken = "sample_api_token_1234567890",
onCopyToken = {}
)
}
}

View file

@ -0,0 +1,623 @@
package com.habitrpg.android.habitica.ui.views
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.text.InputType
import android.text.method.PasswordTransformationMethod
import android.view.Gravity
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatEditText
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.widget.doAfterTextChanged
import com.google.android.material.textfield.TextInputLayout
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.ui.theme.colors
import com.habitrpg.common.habitica.extensions.dpToPx
import com.habitrpg.common.habitica.theme.HabiticaTheme
@Composable
fun ConfigurableFormScreen(cfg: FormScreenConfig) {
val colors = HabiticaTheme.colors
var values by remember { mutableStateOf(cfg.fields.associate { it.key to it.initialValue }) }
var attempted by remember { mutableStateOf(false) }
val touchedFields = remember { mutableStateMapOf<String, Boolean>() }
val errorRes: Map<String, Int?> = remember(values, attempted, touchedFields) {
cfg.fields.associate { f ->
val show = attempted || (touchedFields[f.key] == true)
val rawError: Int? = when (f.key) {
"confirmPw" -> {
if (show && values["confirmPw"].orEmpty() != values["newPw"].orEmpty()) {
R.string.password_not_matching
} else {
f.validator(values[f.key].orEmpty())
}
}
else -> f.validator(values[f.key].orEmpty())
}
f.key to if (show) rawError else null
}
}
val errors: Map<String, String?> = errorRes.mapValues { (key, resId) ->
resId?.let {
if (it == R.string.password_too_short) {
stringResource(it, 8)
} else {
stringResource(it)
}
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = colors.windowBackground
) {
Box(Modifier.fillMaxSize()) {
IconButton(
onClick = cfg.onBack,
modifier = Modifier
.size(48.dp)
.align(Alignment.TopStart)
.padding(start = 22.dp, top = 16.dp)
) {
Icon(
painterResource(R.drawable.arrow_back),
contentDescription = stringResource(R.string.action_back),
tint = colors.textPrimary
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(start = 16.dp, end = 16.dp, top = (16.dp + 40.dp + 8.dp))
) {
Text(
text = stringResource(cfg.titleRes),
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
color = colors.textPrimary,
modifier = Modifier
.align(Alignment.Start)
.padding(start = 6.dp, bottom = 12.dp)
)
cfg.descriptionRes?.let {
Text(
text = stringResource(it),
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
lineHeight = 20.sp,
color = colors.textPrimary,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(start = 6.dp, bottom = 22.dp)
)
}
cfg.fields.forEach { f ->
ComponentTextInput(
hintRes = f.labelRes,
value = values[f.key].orEmpty(),
onValueChange = { v -> values = values.toMutableMap().also { it[f.key] = v } },
kind = f.kind,
isError = errors[f.key] != null,
errorMessage = errors[f.key],
onFocusChanged = { focused ->
if (!focused) touchedFields[f.key] = true
},
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
attempted = true
if (cfg.canSubmit(values)) cfg.onSubmit(values)
},
enabled = true,
colors = ButtonDefaults.buttonColors(
containerColor = colorResource(id = R.color.brand_400),
disabledContainerColor = colorResource(id = R.color.brand_400),
contentColor = Color.White,
disabledContentColor = Color.White
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
) {
Text(
text = stringResource(cfg.submitButtonRes),
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(18.dp))
cfg.textButtonRes?.let { btnRes ->
TextButton(
onClick = cfg.onTextButton,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(
text = stringResource(btnRes),
color = colorResource(id = R.color.purple400_purple500),
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
}
@Composable
fun ComponentTextInput(
@StringRes hintRes: Int,
value: String,
onValueChange: (String) -> Unit,
kind: FieldKind,
isError: Boolean,
errorMessage: String?,
onFocusChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val onTextChangedColor = if (value.isNotBlank())
colorResource(id = R.color.purple400_purple500)
else
colorResource(id = R.color.gray200_gray400)
val activeNotFilledColor = colorResource(id = R.color.purple400_purple500)
val filledNotActiveColor = colorResource(id = R.color.gray_400)
val labelColor = colorResource(id = R.color.gray200_gray400)
val textColorArgb = HabiticaTheme.colors.textPrimary.toArgb()
AndroidView(
factory = { ctx ->
LayoutInflater.from(ctx)
.inflate(R.layout.component_text_input, null, false)
.apply {
findViewById<TextInputLayout>(R.id.text_input_layout)
.setBoxBackgroundColorResource(R.color.gray600_gray10)
}
},
update = { view ->
val til = view.findViewById<TextInputLayout>(R.id.text_input_layout)
val edit = view.findViewById<AppCompatEditText>(R.id.text_edit_text)
til.hint = view.context.getString(hintRes)
edit.inputType = when (kind) {
FieldKind.EMAIL -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
FieldKind.URI -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
FieldKind.MULTILINE -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
else ->
InputType.TYPE_CLASS_TEXT or
if (kind == FieldKind.PASSWORD) InputType.TYPE_TEXT_VARIATION_PASSWORD else 0
}
edit.transformationMethod =
if (kind == FieldKind.PASSWORD) PasswordTransformationMethod.getInstance() else null
if (kind == FieldKind.MULTILINE) {
edit.inputType = edit.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE
edit.height = 115.dpToPx(context = view.context)
edit.gravity = Gravity.TOP and Gravity.START
edit.isVerticalScrollBarEnabled = true
}
fun syncColors(focused: Boolean) {
val active = focused || edit.text?.isNotBlank() == true
val filledNotActive = !focused && edit.text?.isNotBlank() == true
val activeNotFilled = focused && edit.text?.isBlank() == true
val strokeColor = if (active) onTextChangedColor else labelColor
til.defaultHintTextColor = ColorStateList.valueOf(strokeColor.toArgb())
til.setBoxStrokeColorStateList(ColorStateList.valueOf(strokeColor.toArgb()))
if (activeNotFilled) {
til.defaultHintTextColor = ColorStateList.valueOf(activeNotFilledColor.toArgb())
til.setBoxStrokeColorStateList(ColorStateList.valueOf(activeNotFilledColor.toArgb()))
}
if (filledNotActive) {
til.defaultHintTextColor = ColorStateList.valueOf(filledNotActiveColor.toArgb())
til.setBoxStrokeColorStateList(ColorStateList.valueOf(filledNotActiveColor.toArgb()))
}
}
syncColors(edit.isFocused)
edit.setOnFocusChangeListener { _, focused ->
syncColors(focused)
onFocusChanged(focused)
}
edit.doAfterTextChanged {
syncColors(edit.isFocused)
onValueChange(it.toString())
}
if (edit.text.toString() != value) {
edit.setText(value)
edit.setSelection(value.length)
}
til.isErrorEnabled = isError
til.error = errorMessage
til.setBoxStrokeWidth(2)
edit.setTextColor(textColorArgb)
},
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
}
enum class FieldKind { TEXT, PASSWORD, MULTILINE, URI, EMAIL }
data class FieldConfig(
val key: String,
@StringRes val labelRes: Int,
val kind: FieldKind = FieldKind.TEXT,
val initialValue: String = "",
val validator: (String) -> Int? = { null }
)
data class FormScreenConfig(
@StringRes val titleRes: Int,
@StringRes val descriptionRes: Int? = null,
val fields: List<FieldConfig>,
@StringRes val submitButtonRes: Int,
val onSubmit: (Map<String, String>) -> Unit,
val canSubmit: (Map<String, String>) -> Boolean,
@StringRes val textButtonRes: Int? = null,
val onTextButton: () -> Unit = {},
val onBack: () -> Unit
)
@Composable
fun ChangePasswordScreen(
onBack: () -> Unit,
onSave: (old: String, new: String) -> Unit,
onForgot: () -> Unit
) {
val fields = listOf(
FieldConfig(
key = "oldPw",
labelRes = R.string.old_password,
kind = FieldKind.PASSWORD,
validator = { if (it.length < 8) R.string.password_too_short else null }
),
FieldConfig(
key = "newPw",
labelRes = R.string.new_password,
kind = FieldKind.PASSWORD,
validator = { if (it.length < 8) R.string.password_too_short else null }
),
FieldConfig(
key = "confirmPw",
labelRes = R.string.confirm_new_password,
kind = FieldKind.PASSWORD,
validator = { if (it.isBlank()) R.string.password_not_matching else null }
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.change_password,
descriptionRes = R.string.password_change_info,
fields = fields,
submitButtonRes = R.string.change_password,
canSubmit = { vals ->
fields.all { it.validator(vals[it.key].orEmpty()) == null }
&& vals["newPw"] == vals["confirmPw"]
},
onSubmit = { v -> onSave(v["oldPw"]!!, v["newPw"]!!) },
textButtonRes = R.string.forgot_pw_btn,
onTextButton = onForgot,
onBack = onBack
)
)
}
@Composable
fun ChangeUsernameScreen(
initial: String,
onBack: () -> Unit,
onSave: (newUsername: String) -> Unit
) {
val fields = listOf(
FieldConfig(
key = "username",
labelRes = R.string.username,
kind = FieldKind.TEXT,
initialValue = initial,
validator = {
when {
it.isBlank() -> R.string.username_requirements
it.length > 20 -> R.string.username_requirements
!Regex("^[A-Za-z0-9_-]+$").matches(it) -> R.string.username_requirements
else -> null
}
}
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.change_username,
descriptionRes = R.string.change_username_description,
fields = fields,
submitButtonRes = R.string.change_username,
canSubmit = { vals -> fields.all { f -> f.validator(vals[f.key].orEmpty()) == null } },
onSubmit = { vals -> onSave(vals["username"]!!.trim()) },
onBack = onBack
)
)
}
@Composable
fun ChangeEmailScreen(
initialEmail: String,
onBack: () -> Unit,
onSave: (newEmail: String, password: String) -> Unit,
onForgotPassword: () -> Unit
) {
val fields = listOf(
FieldConfig(
key = "email",
labelRes = R.string.email,
kind = FieldKind.EMAIL,
initialValue = initialEmail,
validator = { if (it.isBlank()) R.string.email_invalid else null }
),
FieldConfig(
key = "password",
labelRes = R.string.password,
kind = FieldKind.PASSWORD,
validator = { if (it.length < 8) R.string.password_too_short else null }
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.change_email,
descriptionRes = R.string.change_email_description,
fields = fields,
submitButtonRes = R.string.change_email,
canSubmit = { vals -> fields.all { f -> f.validator(vals[f.key].orEmpty()) == null } },
onSubmit = { vals -> onSave(vals["email"]!!, vals["password"]!!) },
textButtonRes = R.string.forgot_pw_btn,
onTextButton = onForgotPassword,
onBack = onBack
)
)
}
@Composable
fun ChangeDisplayNameScreen(
initial: String,
onBack: () -> Unit,
onSave: (newDisplayName: String) -> Unit
) {
val fields = listOf(
FieldConfig(
key = "displayName",
labelRes = R.string.display_name,
kind = FieldKind.TEXT,
validator = { if (it.isBlank()) R.string.display_name_length_error else null },
initialValue = initial
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.change_display_name,
descriptionRes = R.string.display_name_description,
fields = fields,
submitButtonRes = R.string.change_display_name,
canSubmit = { vals -> vals["displayName"]!!.isNotBlank() },
onSubmit = { vals -> onSave(vals["displayName"]!!) },
onBack = onBack
)
)
}
@Composable
fun AboutMeScreen(
initial: String,
onBack: () -> Unit,
onSave: (aboutText: String) -> Unit
) {
val fields = listOf(
FieldConfig(
key = "about",
labelRes = R.string.about_me,
kind = FieldKind.MULTILINE,
initialValue = initial
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.about_me,
descriptionRes = R.string.about_me_description,
fields = fields,
submitButtonRes = R.string.save_about_me,
canSubmit = { true },
onSubmit = { vals -> onSave(vals["about"]!!.trim()) },
onBack = onBack
)
)
}
@Composable
fun PhotoUrlScreen(
initial: String,
onBack: () -> Unit,
onSave: (photoUrl: String) -> Unit
) {
val fields = listOf(
FieldConfig(
key = "photoUrl",
labelRes = R.string.photo_url,
kind = FieldKind.URI,
initialValue = initial
)
)
ConfigurableFormScreen(
FormScreenConfig(
titleRes = R.string.photo_url,
descriptionRes = R.string.photo_url_description,
fields = fields,
submitButtonRes = R.string.save_photo_url,
canSubmit = { vals -> vals["photoUrl"]!!.isNotBlank() },
onSubmit = { vals -> onSave(vals["photoUrl"]!!.trim()) },
onBack = onBack
)
)
}
@Preview(
name = "ChangeUsername Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewChangeUsernameScreenDark() {
HabiticaTheme {
ChangeUsernameScreen(
initial = "",
onBack = {},
onSave = { }
)
}
}
@Preview(
name = "ChangeEmail Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewChangeEmailScreenDark() {
HabiticaTheme {
ChangeEmailScreen(
onBack = {},
initialEmail = "",
onSave = { newEmail, password -> },
onForgotPassword = {}
)
}
}
@Preview(
name = "ChangeDisplayName Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewChangeDisplayNameScreenDark() {
HabiticaTheme {
ChangeDisplayNameScreen(
initial = "displayName",
onBack = {},
onSave = { }
)
}
}
@Preview(
name = "AboutMe Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewAboutMeScreenDark() {
HabiticaTheme {
AboutMeScreen(
initial = "",
onBack = {},
onSave = { }
)
}
}
@Preview(
name = "PhotoURL Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewPhotoUrlScreenDark() {
HabiticaTheme {
PhotoUrlScreen(
initial = "",
onBack = {},
onSave = { }
)
}
}
@Preview(
name = "ChangePasswordScreen Dark",
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewChangePasswordScreenDark() {
HabiticaTheme {
ChangePasswordScreen(
onBack = {},
onSave = { old, new -> },
onForgot = {}
)
}
}

View file

@ -40,9 +40,10 @@ fun SkillDialog(
skillPath: String = "",
skillKey: String = "",
resourceIconPainter: Painter,
title: String,
description: String,
mpCost: String,
title: String = "",
description: String = "",
mpCost: String = "",
isTransformationItem: Boolean = false,
onUseSkill: () -> Unit,
) {
val colors = HabiticaTheme.colors
@ -61,11 +62,10 @@ fun SkillDialog(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier =
Modifier
.padding(bottom = 16.dp)
.background(colorResource(R.color.content_background_offset))
.size(24.dp, 3.dp)
modifier = Modifier
.padding(bottom = 16.dp)
.background(colorResource(R.color.content_background_offset))
.size(24.dp, 3.dp)
)
Box(
@ -75,14 +75,12 @@ fun SkillDialog(
.background(colors.pixelArtBackground(hasIcon = true)),
contentAlignment = Alignment.Center
) {
PixelArtView (
imageName = skillPath + skillKey,
PixelArtView(
imageName = "$skillPath$skillKey",
modifier = Modifier.size(62.dp)
)
}
Spacer(Modifier.height(16.dp))
Text(
text = title,
fontSize = 16.sp,
@ -96,58 +94,63 @@ fun SkillDialog(
fontSize = 14.sp,
color = colors.textSecondary,
modifier = Modifier.padding(top = 8.dp),
fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(18.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(chipBg)
.padding(horizontal = 20.dp, vertical = 8.dp)
) {
Icon(
painter = resourceIconPainter,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.size(22.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = mpCost,
color = chipTextColor,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
)
if (!isTransformationItem) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(chipBg)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Icon(
painter = resourceIconPainter,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.size(22.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = mpCost,
color = chipTextColor,
fontWeight = FontWeight.Medium,
fontSize = 16.sp
)
}
Spacer(Modifier.height(28.dp))
} else {
Spacer(Modifier.height(18.dp))
}
Spacer(Modifier.height(28.dp))
Button(
onClick = onUseSkill,
shape = RoundedCornerShape(8.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = colorResource(R.color.brand_400),
contentColor = Color.White
),
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.height(43.dp)
) {
val label = if (isTransformationItem)
stringResource(R.string.use_on_party)
else
stringResource(R.string.use_skill)
Text(
text = stringResource(R.string.use_skill),
fontWeight = FontWeight.Normal,
text = label,
fontWeight = FontWeight.Medium,
fontSize = 17.sp
)
}
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFF232136)
@Composable
fun PreviewSkillDialog() {
@ -158,6 +161,7 @@ fun PreviewSkillDialog() {
title = "Title Skill",
description = "Skill Description",
mpCost = "10 MP",
isTransformationItem = true,
onUseSkill = {
}

View file

@ -1,7 +1,9 @@
package com.habitrpg.android.habitica.ui.views.preferences
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -11,6 +13,7 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.habitrpg.android.habitica.R
@ -24,6 +27,8 @@ fun PauseResumeDamageView(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = HabiticaTheme.colors
val mainTextColor = colors.textPrimary
Column(
horizontalAlignment = Alignment.Start,
modifier =
@ -78,6 +83,9 @@ fun PauseResumeDamageView(
HabiticaButton(
background = colorResource(R.color.yellow_100),
color = colorResource(R.color.yellow_1),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
onClick = { onClick() }
) {
Text(stringResource(R.string.resume_damage))
@ -85,8 +93,9 @@ fun PauseResumeDamageView(
} else {
Text(
stringResource(R.string.pause_damage),
color = HabiticaTheme.colors.textSecondary,
fontSize = 16.sp,
fontSize = 21.sp,
color = mainTextColor,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
modifier =
Modifier
@ -97,12 +106,16 @@ fun PauseResumeDamageView(
stringResource(R.string.pause_damage_1_title),
color = HabiticaTheme.colors.textPrimary,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
fontSize = 16.sp,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Text(
stringResource(R.string.pause_damage_1_description),
color = HabiticaTheme.colors.textSecondary,
fontSize = 14.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier.padding(bottom = 12.dp)
)
@ -110,31 +123,44 @@ fun PauseResumeDamageView(
stringResource(R.string.pause_damage_2_title),
color = HabiticaTheme.colors.textPrimary,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
fontSize = 16.sp,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Text(
stringResource(R.string.pause_damage_2_description),
color = HabiticaTheme.colors.textSecondary,
fontSize = 14.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier.padding(bottom = 12.dp)
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
)
Text(
stringResource(R.string.pause_damage_3_title),
color = HabiticaTheme.colors.textPrimary,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
fontSize = 16.sp,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Text(
stringResource(R.string.pause_damage_3_description),
color = HabiticaTheme.colors.textSecondary,
fontSize = 14.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier.padding(bottom = 18.dp)
modifier = Modifier.padding(bottom = 24.dp)
)
HabiticaButton(
background = colorResource(R.color.yellow_100),
color = colorResource(R.color.yellow_1),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
onClick = { onClick() }
) {
Text(stringResource(R.string.pause_damage))
@ -142,3 +168,19 @@ fun PauseResumeDamageView(
}
}
}
@Preview(
showBackground = true,
widthDp = 360,
name = "Pause Damage (isPaused = false)",
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
private fun PauseDamagePreview() {
HabiticaTheme {
PauseResumeDamageView(
isPaused = false,
onClick = {}
)
}
}

View file

@ -163,20 +163,16 @@ class ChatBarView : LinearLayout, OnImeVisibilityChangedListener {
}
override fun onImeVisibilityChanged(visible: Boolean, height: Int, safeInsets: Insets) {
this.safeInsets = safeInsets
imeHeight = if (visible) {
height
} else {
0
}
applyAllPadding()
val navInset = safeInsets.bottom
val imeOffset = if (visible) (height - navInset).coerceAtLeast(0) else 0
updatePadding(
left = safeInsets.left,
right = safeInsets.right,
bottom = navInset
)
translationY = -imeOffset.toFloat()
}
private fun applyAllPadding() {
Log.e("ChatBarView", "applyAllPadding: safeInsets = $safeInsets, imeHeight = $imeHeight")
updatePadding(
left = safeInsets.left,
right = safeInsets.right,
bottom = if (imeHeight > 0) imeHeight else safeInsets.bottom)
}
}

View file

@ -0,0 +1,32 @@
package com.habitrpg.android.habitica.utils
import com.google.gson.JsonDeserializer
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.habitrpg.android.habitica.models.invitations.InviteResponse
import io.realm.RealmList
import java.lang.reflect.Type
class InviteResponseDeserializer : JsonDeserializer<InviteResponse> {
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): InviteResponse {
return when {
json.isJsonPrimitive && json.asJsonPrimitive.isString -> {
InviteResponse.EmailInvite(json.asString)
}
json.isJsonObject -> {
val obj = json.asJsonObject
InviteResponse.UserInvite(
id = obj["id"].asString,
name = obj["name"].asString,
inviter = obj["inviter"].asString
)
}
else -> throw JsonParseException("Unexpected InviteResponse: $json")
}
}
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/text_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/SettingTextInputLayoutAppearance"
app:boxStrokeWidth="2dp"
app:boxStrokeWidthFocused="2dp"
app:boxBackgroundColor="@color/gray600_gray10"
app:hintTextColor="@color/gray100_gray500"
app:boxStrokeColor="@color/gray200_gray400"
android:backgroundTint="?attr/colorPrimaryText"
android:hint="@string/task_title"
android:alpha="0.75">
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/text_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?attr/colorPrimaryText"
android:inputType="textCapSentences|textAutoCorrect" />
</com.google.android.material.textfield.TextInputLayout>
</FrameLayout>

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/special_skill_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:orientation="horizontal"
android:background="@drawable/layout_rounded_bg_window"
android:clipToPadding="true"
android:clipChildren="true"
android:clipToOutline="true"
android:clickable="true"
android:foreground="?android:attr/selectableItemBackground"
android:padding="12dp">
<com.habitrpg.common.habitica.views.PixelArtView
android:id="@+id/skill_image"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center_vertical"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/skill_text"
style="@style/SubHeader1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Seafoam" />
<TextView
android:id="@+id/skill_notes"
style="@style/Caption2.Regular"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
tools:text="Turn a friend into a sea creature!" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/count_label"
style="@style/CountLabel"
android:layout_width="24dp"
android:layout_height="24dp"
android:textSize="14sp"
android:layout_alignParentTop="true"
android:layout_alignParentStart="true"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
tools:text="6" />
</RelativeLayout>

View file

@ -215,6 +215,9 @@ object DataBindingUtils {
tempMap["Pet_HatchingPotion_Fungi"] = "gif"
tempMap["shop_armoire"] = "gif"
tempMap["Pet_HatchingPotion_Cryptid"] = "gif"
tempMap["Mount_Head_Dragon-Hydra"] = "gif"
tempMap["Mount_Body_Dragon-Hydra"] = "gif"
tempMap["stable_Mount_Icon_Dragon-Hydra"] = "gif"
FILEFORMAT_MAP = tempMap
val tempNameMap = mutableMapOf<String, String>()

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@ package com.habitrpg.common.habitica.helpers
import java.util.regex.Pattern
object EmojiParser {
private val pattern: Pattern = Pattern.compile("(:[^:\\s]+:)")
/**
* Converts Cheat Sheet emoji-codes into unicode characters
*
@ -14,12 +16,10 @@ object EmojiParser {
return text
}
var returnString: String = text
val pattern = Pattern.compile("(:[^:]+:)")
val matcher = pattern.matcher(text)
while (matcher.find()) {
val found = matcher.group()
if (EmojiMap.invertedEmojiMap[found] == null) continue
val hexInt = EmojiMap.invertedEmojiMap[found]!!
val hexInt = EmojiMap.invertedEmojiMap[found] ?: continue
val replacement = String(Character.toChars(hexInt))
returnString = returnString.replace(found, replacement)
}
@ -41,9 +41,8 @@ object EmojiParser {
for (i in 0..charArray.size - 2) {
val testString = String(charArray.copyOfRange(i, i + 2))
val test = testString.codePointAt(0)
if (EmojiMap.emojiMap.containsKey(test)) {
returnString = returnString.replace(testString, EmojiMap.emojiMap[test]!!)
}
val cheatCode = EmojiMap.emojiMap[test] ?: continue
returnString = returnString.replace(testString, cheatCode)
}
return returnString
}

View file

@ -1,8 +1,11 @@
New in 4.7.4
- Hungarian language support
- Upgraded to the latest Google Sign In authentication standards
- Implemented full edge-to-edge display functionality on Android 11+ devices
- Fixed some issues where the text box in chat wasn't adjusting properly
- More support for landscape mode
- Various other bug fixes and improvements
- Support for future events
New in 4.7.6
- Monthly Dailies should repeat more accurately
- Reminders update correctly after being deleted
- More accessible designs for the Skill section
- Scheduled To Do filter will stay applied on app reopen
- Party description box no longer gets hidden by keyboard
- Fixed a crash with certain languages in Avatar Customization
- Fixed a crash with Group Plan member lists
- Animated backgrounds move again
- Markdown formatting no longer flashes when refreshing
- Various other bug fixes

View file

@ -9,7 +9,7 @@ androidTest = "1.6.1"
androidTestMonitor = "1.7.2"
androidTestRunner = "1.6.2"
annotationApi = "1.3.2"
appcompat = "1.7.0"
appcompat = "1.7.1"
billing = "7.1.1"
coil = "3.1.0"
compose = "1.8.1"
@ -21,7 +21,7 @@ coordinatorlayout = "1.3.0"
coreSplashscreen = "1.1.0-rc01"
core_ktx = "1.16.0"
coroutines = "1.10.2"
crashlytics = "3.0.3"
crashlytics = "3.0.4"
credentials = "1.5.0"
daggerhilt = "2.56.2"
desuggar = "2.1.5"
@ -29,8 +29,8 @@ detekt = "1.23.8"
firebase-perf = "1.4.2"
firebase_bom = "33.13.0"
flexbox = "3.0.0"
fragmentKtx = "1.8.6"
fragmentTesting = "1.8.6"
fragmentKtx = "1.8.8"
fragmentTesting = "1.8.8"
google-service = "4.4.2"
googleid = "1.1.1"
gson = "2.13.1"
@ -39,12 +39,12 @@ inAppReview = "2.0.2"
junitKtx = "1.2.1"
kaspresso = "1.6.0"
kotest = "5.9.1"
kotlin = "2.1.20"
ksp = "2.1.20-2.0.1"
ktlintPlugin = "12.2.0"
kotlin = "2.1.21"
ksp = "2.1.21-2.0.2"
ktlintPlugin = "12.3.0"
leakCanary = "2.14"
lifecycle = "2.9.0"
lifecycleRuntimeKtx = "2.9.0"
lifecycle = "2.9.1"
lifecycleRuntimeKtx = "2.9.1"
markwon = "4.6.2"
material = "1.12.0"
material3 = "1.3.2"
@ -59,10 +59,10 @@ play_wearables = "19.0.0"
preferences = "1.2.1"
realmPlugin = "10.19.0"
recyclerview = "1.4.0"
retrofit = "2.11.0"
retrofit = "3.0.0"
shimmer = "0.5.0"
swipeRefresh = "1.1.0"
turbine = "1.2.0"
turbine = "1.2.1"
wear = "1.3.0"
wearInput = "1.1.0"

View file

@ -1,2 +1,2 @@
NAME=4.7.5
CODE=12981
NAME=4.7.6
CODE=13431