diff --git a/atox/src/main/AndroidManifest.xml b/atox/src/main/AndroidManifest.xml index 31258873..7487de10 100644 --- a/atox/src/main/AndroidManifest.xml +++ b/atox/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/launcher_icon_round" android:supportsRtl="true" - android:theme="@style/AppTheme"> + android:theme="@style/Theme.aTox.DayNight"> diff --git a/atox/src/main/kotlin/Extensions.kt b/atox/src/main/kotlin/Extensions.kt index d512dd9b..17859e5a 100644 --- a/atox/src/main/kotlin/Extensions.kt +++ b/atox/src/main/kotlin/Extensions.kt @@ -6,6 +6,7 @@ package ltd.evilcorp.atox import android.content.Context import android.content.pm.PackageManager +import androidx.annotation.ColorRes import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import ltd.evilcorp.atox.di.ViewModelFactory @@ -21,6 +22,12 @@ class NoSuchArgumentException(arg: String) : Exception("No such argument: $arg") fun Fragment.requireStringArg(key: String) = arguments?.getString(key) ?: throw NoSuchArgumentException(key) +fun Fragment.getColor(@ColorRes id: Int) = + ContextCompat.getColor(requireContext(), id) + +fun Fragment.getColorStateList(@ColorRes id: Int) = + ContextCompat.getColorStateList(requireContext(), id) + fun String.truncated(length: Int): String = if (this.length > length) { this.take(length - 1) + "…" diff --git a/atox/src/main/kotlin/di/ViewModelModule.kt b/atox/src/main/kotlin/di/ViewModelModule.kt index 94088ad5..80801e88 100644 --- a/atox/src/main/kotlin/di/ViewModelModule.kt +++ b/atox/src/main/kotlin/di/ViewModelModule.kt @@ -16,6 +16,8 @@ import ltd.evilcorp.atox.ui.chat.ChatViewModel import ltd.evilcorp.atox.ui.contact_profile.ContactProfileViewModel import ltd.evilcorp.atox.ui.contactlist.ContactListViewModel import ltd.evilcorp.atox.ui.create_profile.CreateProfileViewModel +import ltd.evilcorp.atox.ui.edit_text_value_dialog.EditTextValueDialogViewModel +import ltd.evilcorp.atox.ui.edit_user_profile.EditUserProfileViewModel import ltd.evilcorp.atox.ui.friend_request.FriendRequestViewModel import ltd.evilcorp.atox.ui.settings.SettingsViewModel import ltd.evilcorp.atox.ui.user_profile.UserProfileViewModel @@ -77,4 +79,14 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(UserProfileViewModel::class) abstract fun bindUserProfileViewModel(vm: UserProfileViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(EditUserProfileViewModel::class) + abstract fun bindEditUserProfileViewModel(vm: EditUserProfileViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(EditTextValueDialogViewModel::class) + abstract fun bindEditTextValueDialogViewModel(vm: EditTextValueDialogViewModel): ViewModel } diff --git a/atox/src/main/kotlin/ui/AvatarMaker.kt b/atox/src/main/kotlin/ui/AvatarMaker.kt new file mode 100644 index 00000000..0923eec2 --- /dev/null +++ b/atox/src/main/kotlin/ui/AvatarMaker.kt @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2019-2021 aTox contributors +// +// SPDX-License-Identifier: GPL-3.0-only + +package ltd.evilcorp.atox.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.net.Uri +import android.widget.ImageView +import kotlin.math.abs +import ltd.evilcorp.atox.R +import ltd.evilcorp.core.vo.Contact +import ltd.evilcorp.core.vo.User + +internal enum class SizeUnit { + DP, + PX, +} + +/** + * Class for creating an avatar for user or contact and setting it in the ImageView + */ +internal class AvatarMaker { + + companion object { + private const val DEFAULT_AVATAR_SIZE_DP = 50 + } + + private var name: String = "" + private var publicKey: String = "" + private var avatarUri: String = "" + private var initials: String = "" + + constructor(contact: Contact) { + name = contact.name + publicKey = contact.publicKey + avatarUri = contact.avatarUri + initials = getInitials() + } + constructor(user: User) { + name = user.name + publicKey = user.publicKey + avatarUri = user.avatarUri + initials = getInitials() + } + + /** + * Method will get the initial characters of the name + * @return The initial characters of the name. + */ + private fun getInitials(): String { + val segments = name.split(" ") + if (segments.size == 1) return segments.first().take(1) + return segments.first().take(1) + segments[1][0] + } + + /** + * Method will set an avatar to an image view. If avatar image exists then it will be set to the image view, + * otherwise a new avatar image will be created based on the initials of the name + * and the public key for the background color. + * @param imageView The image view for whom to set the avatar image. + * @param size The size of the avatar image in the units specified in sizeUnit (default: DP units). + * @param sizeUnit The size unit of size parameter. + */ + fun setAvatar(imageView: ImageView, size: Int = DEFAULT_AVATAR_SIZE_DP, sizeUnit: SizeUnit = SizeUnit.DP) = + if (avatarUri.isNotEmpty()) { + imageView.setImageURI(Uri.parse(avatarUri)) + } else { + val side: Int + val textScale: Float + + if (sizeUnit == SizeUnit.DP) { + side = (size * imageView.resources.displayMetrics.density).toInt() + textScale = size.toFloat() / DEFAULT_AVATAR_SIZE_DP + } else { + side = size + textScale = size.toFloat() / dpToPx(DEFAULT_AVATAR_SIZE_DP.toFloat(), imageView.resources) + } + + val bitmap = Bitmap.createBitmap(side, side, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val rect = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat()) + val colors = imageView.resources.getIntArray(R.array.contactBackgrounds) + val backgroundPaint = Paint().apply { color = colors[abs(publicKey.hashCode()).rem(colors.size)] } + + val textPaint = Paint().apply { + color = Color.WHITE + textSize = imageView.resources.getDimension(R.dimen.contact_avatar_placeholder_text) * textScale + textAlign = Paint.Align.CENTER + isAntiAlias = true + typeface = Typeface.create("sans-serif-light", Typeface.NORMAL) + } + + val textBounds = Rect() + textPaint.getTextBounds(initials, 0, initials.length, textBounds) + canvas.drawRoundRect(rect, rect.bottom, rect.right, backgroundPaint) + canvas.drawText(initials, rect.centerX(), rect.centerY() - textBounds.exactCenterY(), textPaint) + imageView.setImageBitmap(bitmap) + } +} diff --git a/atox/src/main/kotlin/ui/NotificationHelper.kt b/atox/src/main/kotlin/ui/NotificationHelper.kt index e2cd77b5..92bd43d1 100644 --- a/atox/src/main/kotlin/ui/NotificationHelper.kt +++ b/atox/src/main/kotlin/ui/NotificationHelper.kt @@ -111,7 +111,7 @@ class NotificationHelper @Inject constructor( .addAction( NotificationCompat.Action .Builder( - IconCompat.createWithResource(context, R.drawable.send), + IconCompat.createWithResource(context, R.drawable.ic_send), context.getString(R.string.reply), PendingIntentCompat.getBroadcast( context, diff --git a/atox/src/main/kotlin/ui/StatusDialog.kt b/atox/src/main/kotlin/ui/StatusDialog.kt deleted file mode 100644 index 5d0522c8..00000000 --- a/atox/src/main/kotlin/ui/StatusDialog.kt +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-FileCopyrightText: 2020 aTox contributors -// -// SPDX-License-Identifier: GPL-3.0-only - -package ltd.evilcorp.atox.ui - -import android.app.Dialog -import android.content.Context -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.TransitionDrawable -import android.os.Bundle -import android.view.Window -import javax.inject.Inject -import ltd.evilcorp.atox.R -import ltd.evilcorp.atox.databinding.DialogStatusBinding -import ltd.evilcorp.core.vo.UserStatus -import ltd.evilcorp.domain.feature.UserManager - -private const val TRANSITION_TIME = 250 - -class StatusDialog( - ctx: Context, - private var activeStatus: UserStatus, - private val setStatusFunc: (UserStatus) -> Unit -) : Dialog(ctx, R.style.DialogSlideAnimation) { - @Inject - lateinit var userManager: UserManager - - private var _binding: DialogStatusBinding? = null - private val binding get() = _binding!! - - private fun viewByStatus(status: UserStatus): TransitionDrawable = when (status) { - UserStatus.None -> binding.statusAvailable.background as TransitionDrawable - UserStatus.Away -> binding.statusAway.background as TransitionDrawable - UserStatus.Busy -> binding.statusBusy.background as TransitionDrawable - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - requestWindowFeature(Window.FEATURE_NO_TITLE) - _binding = DialogStatusBinding.inflate(layoutInflater) - setContentView(binding.root) - window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - - selectStatus(activeStatus) - binding.run { - statusAvailable.setOnClickListener { selectStatus(UserStatus.None) } - statusAway.setOnClickListener { selectStatus(UserStatus.Away) } - statusBusy.setOnClickListener { selectStatus(UserStatus.Busy) } - - cancel.setOnClickListener { dismiss() } - confirm.setOnClickListener { - setStatusFunc(activeStatus) - dismiss() - } - } - } - - private fun selectStatus(status: UserStatus) { - viewByStatus(activeStatus).reverseTransition(TRANSITION_TIME) - activeStatus = status - viewByStatus(activeStatus).startTransition(TRANSITION_TIME) - } -} diff --git a/atox/src/main/kotlin/ui/Util.kt b/atox/src/main/kotlin/ui/Util.kt index 08bc2dd8..73e3314e 100644 --- a/atox/src/main/kotlin/ui/Util.kt +++ b/atox/src/main/kotlin/ui/Util.kt @@ -4,66 +4,96 @@ package ltd.evilcorp.atox.ui +import android.content.Context +import android.content.res.ColorStateList +import android.content.res.Configuration import android.content.res.Resources -import android.graphics.Bitmap -import android.graphics.Canvas import android.graphics.Color -import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF -import android.graphics.Typeface -import android.net.Uri -import android.widget.ImageView +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.os.Build +import android.util.TypedValue +import android.widget.ImageButton import androidx.core.content.res.ResourcesCompat -import kotlin.math.abs import ltd.evilcorp.atox.R import ltd.evilcorp.core.vo.ConnectionStatus import ltd.evilcorp.core.vo.Contact import ltd.evilcorp.core.vo.UserStatus -internal fun colorByStatus(resources: Resources, contact: Contact): Int { - if (contact.connectionStatus == ConnectionStatus.None) return ResourcesCompat.getColor( - resources, - R.color.statusOffline, - null - ) - return when (contact.status) { - UserStatus.None -> ResourcesCompat.getColor(resources, R.color.statusAvailable, null) - UserStatus.Away -> ResourcesCompat.getColor(resources, R.color.statusAway, null) - UserStatus.Busy -> ResourcesCompat.getColor(resources, R.color.statusBusy, null) - } -} +/** + * Function will return the color of the status of the input contact + * @param resources The resources of the app + * @param contact The contact for whom to retrieve the status color. + * @return The color int. + */ +internal fun colorByContactStatus(resources: Resources, contact: Contact) = + if (contact.connectionStatus == ConnectionStatus.None) + ResourcesCompat.getColor( + resources, + R.color.statusOffline, + null + ) + else colorFromStatus(resources, contact.status) -private fun getInitials(contact: Contact): String { - val segments = contact.name.split(" ") - if (segments.size == 1) return segments.first().take(1) - return segments.first().take(1) + segments[1][0] +/** + * Function will return the color of the status of the input user status + * @param resources The resources of the app + * @param status The user status for whom to return the corresponding color. + * @return The color int. + */ +internal fun colorFromStatus(resources: Resources, status: UserStatus) = when (status) { + UserStatus.None -> ResourcesCompat.getColor(resources, R.color.statusAvailable, null) + UserStatus.Away -> ResourcesCompat.getColor(resources, R.color.statusAway, null) + UserStatus.Busy -> ResourcesCompat.getColor(resources, R.color.statusBusy, null) } -private const val DEFAULT_AVATAR_SIZE_DP = 50 -internal fun setAvatarFromContact(imageView: ImageView, contact: Contact, sizeDp: Int = DEFAULT_AVATAR_SIZE_DP) = - if (contact.avatarUri.isNotEmpty()) { - imageView.setImageURI(Uri.parse(contact.avatarUri)) - } else { - val side = (sizeDp * imageView.resources.displayMetrics.density).toInt() - val bitmap = Bitmap.createBitmap(side, side, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - val rect = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat()) - val colors = imageView.resources.getIntArray(R.array.contactBackgrounds) - val backgroundPaint = Paint().apply { color = colors[abs(contact.publicKey.hashCode()).rem(colors.size)] } +/** + * Function will convert dp (Density Pixels) units to px (Pixels) units + * @param dp The dp units. + * @return The px units as Int. + */ +internal fun dpToPx(dp: Float, res: Resources): Int = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, res.displayMetrics).toInt() + +/** + * Function will return whether or not night mode is set. + * @param context The related context. + * @return Boolean indicating whether or not night mode is set. + */ +internal fun isNightMode(context: Context) = context.resources.configuration.uiMode + .and(Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + +/** + * Function will set a transparent background to an image button with a ripple with color according + * to whether or not night mode is set. + * @param context The related context. + * @param imageButton The ImageButton for whom to set the transparent background with ripple. + */ +internal fun setImageButtonRippleDayNight(context: Context, imageButton: ImageButton) = + if (isNightMode(context)) + setImageButtonRipple(imageButton, Color.argb(51, 255, 255, 255)) + else setImageButtonRipple(imageButton, Color.argb(31, 0, 0, 0)) - val textScale = sizeDp.toFloat() / DEFAULT_AVATAR_SIZE_DP - val textPaint = Paint().apply { - color = Color.WHITE - textSize = imageView.resources.getDimension(R.dimen.contact_avatar_placeholder_text) * textScale - textAlign = Paint.Align.CENTER - isAntiAlias = true - typeface = Typeface.create("sans-serif-light", Typeface.NORMAL) - } - val initials = getInitials(contact) - val textBounds = Rect() - textPaint.getTextBounds(initials, 0, initials.length, textBounds) - canvas.drawRoundRect(rect, rect.bottom, rect.right, backgroundPaint) - canvas.drawText(initials, rect.centerX(), rect.centerY() - textBounds.exactCenterY(), textPaint) - imageView.setImageBitmap(bitmap) +/** + * Function will set a transparent background to an image button with a ripple with the input color. + * @param imageButton The ImageButton for whom to set the transparent background with ripple. + * @param colorInt The color of the ripple. + */ +internal fun setImageButtonRipple(imageButton: ImageButton, colorInt: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val background = GradientDrawable() + background.shape = GradientDrawable.OVAL + background.setColor(0x0FFFFFF) + + background.cornerRadius = 10f + + val mask = GradientDrawable() + mask.shape = GradientDrawable.OVAL + mask.setColor(-0x1000000) + mask.cornerRadius = 5f + + val rippleColorLst = ColorStateList.valueOf(colorInt) + val ripple = RippleDrawable(rippleColorLst, background, mask) + imageButton.background = ripple } +} diff --git a/atox/src/main/kotlin/ui/addcontact/AddContactFragment.kt b/atox/src/main/kotlin/ui/addcontact/AddContactFragment.kt index c93ebdce..5486543c 100644 --- a/atox/src/main/kotlin/ui/addcontact/AddContactFragment.kt +++ b/atox/src/main/kotlin/ui/addcontact/AddContactFragment.kt @@ -61,7 +61,7 @@ class AddContactFragment : BaseFragment(FragmentAddCo contacts = it } - toolbar.setNavigationIcon(R.drawable.back) + toolbar.setNavigationIcon(R.drawable.ic_back) toolbar.setNavigationOnClickListener { WindowInsetsControllerCompat(requireActivity().window, view) .hide(WindowInsetsCompat.Type.ime()) diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index ab41738d..5ec35fc2 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -19,9 +19,9 @@ import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.FragmentCallBinding import ltd.evilcorp.atox.hasPermission import ltd.evilcorp.atox.requireStringArg +import ltd.evilcorp.atox.ui.AvatarMaker import ltd.evilcorp.atox.ui.BaseFragment import ltd.evilcorp.atox.ui.chat.CONTACT_PUBLIC_KEY -import ltd.evilcorp.atox.ui.setAvatarFromContact import ltd.evilcorp.atox.vmFactory import ltd.evilcorp.domain.feature.CallState import ltd.evilcorp.domain.tox.PublicKey @@ -52,7 +52,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl vm.setActiveContact(PublicKey(requireStringArg(CONTACT_PUBLIC_KEY))) vm.contact.observe(viewLifecycleOwner) { - setAvatarFromContact(callBackground, it, CALL_BACKGROUND_SIZE_DP) + AvatarMaker(it).setAvatar(callBackground, CALL_BACKGROUND_SIZE_DP) } endCall.setOnClickListener { diff --git a/atox/src/main/kotlin/ui/chat/ChatFragment.kt b/atox/src/main/kotlin/ui/chat/ChatFragment.kt index 3e8f2e3f..5edd9801 100644 --- a/atox/src/main/kotlin/ui/chat/ChatFragment.kt +++ b/atox/src/main/kotlin/ui/chat/ChatFragment.kt @@ -41,9 +41,9 @@ import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.FragmentChatBinding import ltd.evilcorp.atox.requireStringArg import ltd.evilcorp.atox.truncated +import ltd.evilcorp.atox.ui.AvatarMaker import ltd.evilcorp.atox.ui.BaseFragment -import ltd.evilcorp.atox.ui.colorByStatus -import ltd.evilcorp.atox.ui.setAvatarFromContact +import ltd.evilcorp.atox.ui.colorByContactStatus import ltd.evilcorp.atox.vmFactory import ltd.evilcorp.core.vo.ConnectionStatus import ltd.evilcorp.core.vo.FileTransfer @@ -135,7 +135,7 @@ class ChatFragment : BaseFragment(FragmentChatBinding::infl } ) - toolbar.setNavigationIcon(R.drawable.back) + toolbar.setNavigationIcon(R.drawable.ic_back) toolbar.setNavigationOnClickListener { WindowInsetsControllerCompat(requireActivity().window, view).hide(WindowInsetsCompat.Type.ime()) activity?.onBackPressed() @@ -189,8 +189,8 @@ class ChatFragment : BaseFragment(FragmentChatBinding::infl else -> DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(it.lastMessage) }.lowercase(Locale.getDefault()) - profileLayout.statusIndicator.setColorFilter(colorByStatus(resources, it)) - setAvatarFromContact(profileLayout.profileImage, it) + profileLayout.statusIndicator.setColorFilter(colorByContactStatus(resources, it)) + AvatarMaker(it).setAvatar(profileLayout.profileImage) if (it.draftMessage.isNotEmpty() && outgoingMessage.text.isEmpty()) { outgoingMessage.setText(it.draftMessage) diff --git a/atox/src/main/kotlin/ui/contact_profile/ContactProfileFragment.kt b/atox/src/main/kotlin/ui/contact_profile/ContactProfileFragment.kt index f1c01f5e..f0d75f0e 100644 --- a/atox/src/main/kotlin/ui/contact_profile/ContactProfileFragment.kt +++ b/atox/src/main/kotlin/ui/contact_profile/ContactProfileFragment.kt @@ -13,10 +13,10 @@ import androidx.fragment.app.viewModels import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.FragmentContactProfileBinding import ltd.evilcorp.atox.requireStringArg +import ltd.evilcorp.atox.ui.AvatarMaker import ltd.evilcorp.atox.ui.BaseFragment import ltd.evilcorp.atox.ui.chat.CONTACT_PUBLIC_KEY -import ltd.evilcorp.atox.ui.colorByStatus -import ltd.evilcorp.atox.ui.setAvatarFromContact +import ltd.evilcorp.atox.ui.colorByContactStatus import ltd.evilcorp.atox.vmFactory import ltd.evilcorp.domain.tox.PublicKey @@ -31,7 +31,7 @@ class ContactProfileFragment : BaseFragment(Fragm compat } - toolbar.setNavigationIcon(R.drawable.back) + toolbar.setNavigationIcon(R.drawable.ic_back) toolbar.setNavigationOnClickListener { activity?.onBackPressed() } @@ -41,8 +41,8 @@ class ContactProfileFragment : BaseFragment(Fragm contact.name = contact.name.ifEmpty { getString(R.string.contact_default_name) } headerMainText.text = contact.name - setAvatarFromContact(profileLayout.profileImage, contact) - profileLayout.statusIndicator.setColorFilter(colorByStatus(resources, contact)) + AvatarMaker(contact).setAvatar(profileLayout.profileImage) + profileLayout.statusIndicator.setColorFilter(colorByContactStatus(resources, contact)) contactPublicKey.text = contact.publicKey contactName.text = contact.name diff --git a/atox/src/main/kotlin/ui/contactlist/ContactAdapter.kt b/atox/src/main/kotlin/ui/contactlist/ContactAdapter.kt index 0e00e7e1..541b1791 100644 --- a/atox/src/main/kotlin/ui/contactlist/ContactAdapter.kt +++ b/atox/src/main/kotlin/ui/contactlist/ContactAdapter.kt @@ -16,8 +16,8 @@ import java.text.DateFormat import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.ContactListViewItemBinding import ltd.evilcorp.atox.databinding.FriendRequestItemBinding -import ltd.evilcorp.atox.ui.colorByStatus -import ltd.evilcorp.atox.ui.setAvatarFromContact +import ltd.evilcorp.atox.ui.AvatarMaker +import ltd.evilcorp.atox.ui.colorByContactStatus import ltd.evilcorp.core.vo.Contact import ltd.evilcorp.core.vo.FriendRequest @@ -103,7 +103,7 @@ class ContactAdapter( draftMessage.isNotEmpty() -> { vh.statusMessage.text = resources.getString(R.string.draft_message, draftMessage) vh.statusMessage.setTextColor( - ResourcesCompat.getColor(resources, R.color.colorAccent, null) + ResourcesCompat.getColor(resources, R.color.colorSecondary, null) ) } else -> { @@ -111,8 +111,8 @@ class ContactAdapter( vh.statusMessage.setTextColor(vh.lastMessage.currentTextColor) } } - vh.status.setColorFilter(colorByStatus(resources, this)) - setAvatarFromContact(vh.image, this) + vh.status.setColorFilter(colorByContactStatus(resources, this)) + AvatarMaker(this).setAvatar(vh.image) vh.unreadIndicator.visibility = if (hasUnreadMessages) { View.VISIBLE } else { diff --git a/atox/src/main/kotlin/ui/edit_text_value_dialog/EditTextValueDialog.kt b/atox/src/main/kotlin/ui/edit_text_value_dialog/EditTextValueDialog.kt new file mode 100644 index 00000000..8b58b1f5 --- /dev/null +++ b/atox/src/main/kotlin/ui/edit_text_value_dialog/EditTextValueDialog.kt @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2020 aTox contributors +// +// SPDX-License-Identifier: GPL-3.0-only + +package ltd.evilcorp.atox.ui.edit_text_value_dialog + +import android.os.Build +import android.os.Bundle +import android.text.InputFilter +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.fragment.app.viewModels +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import ltd.evilcorp.atox.databinding.EditTextValueDialogBinding +import ltd.evilcorp.atox.vmFactory + +class EditTextValueDialog() : BottomSheetDialogFragment() { + + private val vm: EditTextValueDialogViewModel by viewModels { vmFactory } + private var _binding: EditTextValueDialogBinding? = null + private val binding get() = _binding!! + + private var title: String? = null + private var hint: String? = null + private var defaultValue: String? = null + private var singleLine: Boolean = true + private var filters: Array? = null + private lateinit var setTextValueBlock: (String) -> Unit + + constructor( + title: String, + hint: String, + defaultValue: String? = null, + singleLine: Boolean = true, + filters: Array? = null, + setTextValueBlock: (String) -> Unit + ) : this() { + this.title = title + this.hint = hint + this.defaultValue = defaultValue + this.singleLine = singleLine + this.filters = filters + this.setTextValueBlock = setTextValueBlock + } + + companion object { + const val TAG = "EditTextValueDialog" + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = EditTextValueDialogBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) = binding.run { + super.onViewCreated(view, savedInstanceState) + + val editText: EditText? = textField.editText + + // Assigning values from constructor to view model + title?.run { + vm.title = this + hint?.run { vm.hint = this } + defaultValue?.run { + vm.defaultValue = this + vm.selectionStart = length + vm.selectionEnd = length + } + vm.singleLine = singleLine + filters?.run { vm.filters = this } + setTextValueBlock.run { vm.setTextValueBlock = this } + } + + // Assigning values to the views according to the given parameters + titleTextView.text = vm.title + textField.hint = vm.hint + vm.defaultValue?.run { + editText?.setText(this) + } + editText?.isSingleLine = vm.singleLine + vm.filters?.run { + editText?.filters = this + + // Displaying the max length as a counter + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + try { + (this.first { it is InputFilter.LengthFilter } as InputFilter.LengthFilter).run { + textField.isCounterEnabled = true + textField.counterMaxLength = max + } + } catch (e: NoSuchElementException) { + } + } + } + vm.selectionStart?.let { selectionStart -> + vm.selectionEnd?.let { selectionEnd -> + editText?.setSelection(selectionStart, selectionEnd) + } + } + + cancel.setOnClickListener { dismiss() } + save.setOnClickListener { + vm.setTextValueBlock(textField.editText?.text.toString()) + dismiss() + } + } + + override fun onResume() = binding.run { + textField.editText?.requestFocus() + super.onResume() + } + + override fun onSaveInstanceState(outState: Bundle) = binding.run { + val editText: EditText? = textField.editText + + vm.defaultValue = editText?.text.toString() + vm.selectionStart = editText?.selectionStart + vm.selectionEnd = editText?.selectionEnd + super.onSaveInstanceState(outState) + } +} diff --git a/atox/src/main/kotlin/ui/edit_text_value_dialog/EditTextValueDialogViewModel.kt b/atox/src/main/kotlin/ui/edit_text_value_dialog/EditTextValueDialogViewModel.kt new file mode 100644 index 00000000..9185891c --- /dev/null +++ b/atox/src/main/kotlin/ui/edit_text_value_dialog/EditTextValueDialogViewModel.kt @@ -0,0 +1,17 @@ +package ltd.evilcorp.atox.ui.edit_text_value_dialog + +import android.text.InputFilter +import androidx.lifecycle.ViewModel +import javax.inject.Inject + +class EditTextValueDialogViewModel @Inject constructor() : ViewModel() { + var title: String? = null + var hint: String? = null + var defaultValue: String? = null + var singleLine: Boolean = true + var filters: Array? = null + lateinit var setTextValueBlock: (String) -> Unit + + var selectionStart: Int? = null + var selectionEnd: Int? = null +} diff --git a/atox/src/main/kotlin/ui/edit_user_profile/EditUserProfileFragment.kt b/atox/src/main/kotlin/ui/edit_user_profile/EditUserProfileFragment.kt new file mode 100644 index 00000000..809f32d8 --- /dev/null +++ b/atox/src/main/kotlin/ui/edit_user_profile/EditUserProfileFragment.kt @@ -0,0 +1,147 @@ +package ltd.evilcorp.atox.ui.edit_user_profile + +import android.os.Build +import android.os.Bundle +import android.text.InputFilter +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.updatePadding +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.viewModels +import kotlin.math.min +import ltd.evilcorp.atox.R +import ltd.evilcorp.atox.databinding.FragmentEditUserProfileBinding +import ltd.evilcorp.atox.getColorStateList +import ltd.evilcorp.atox.ui.AvatarMaker +import ltd.evilcorp.atox.ui.BaseFragment +import ltd.evilcorp.atox.ui.SizeUnit +import ltd.evilcorp.atox.ui.edit_text_value_dialog.EditTextValueDialog +import ltd.evilcorp.atox.ui.isNightMode +import ltd.evilcorp.atox.vmFactory +import ltd.evilcorp.core.vo.UserStatus + +private const val TOX_MAX_NAME_LENGTH = 128 +private const val TOX_MAX_STATUS_MESSAGE_LENGTH = 1007 + +private const val AVATAR_IMAGE_TO_SCREEN_RATIO = 1f / 3 + +class EditUserProfileFragment : BaseFragment(FragmentEditUserProfileBinding::inflate) { + private val vm: EditUserProfileViewModel by viewModels { vmFactory } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) = binding.run { + super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(view) { _, compat -> + val insets = compat.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()) + toolbar.updatePadding(left = insets.left, top = insets.top) + mainSection.updatePadding(left = insets.left, right = insets.right, bottom = insets.bottom) + compat + } + + // Inflating views according to Day/Night theme + if (isNightMode(requireContext())) { + getColorStateList(R.color.box_stroke_color_night)?.run { + editStatus.setBoxStrokeColorStateList(this) + } + editStatus.defaultHintTextColor = getColorStateList(R.color.hint_text_color_night) + editStatus.setEndIconTintList(getColorStateList(R.color.trailing_icon_color_night)) + } + + toolbar.setNavigationIcon(R.drawable.ic_back) + toolbar.setNavigationOnClickListener { + WindowInsetsControllerCompat(requireActivity().window, view) + .hide(WindowInsetsCompat.Type.ime()) + activity?.onBackPressed() + } + + // Setting the adapter for edit status + val statusList = resources.getStringArray(R.array.status_list) + val adapter = StatusArrayAdapter(requireContext(), R.layout.edit_status_item, R.id.item_text, statusList) + editStatusText.setAdapter(adapter) + + // Setting the icon and the status according to the chosen status + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + editStatus.setStartIconTintList(null) + editStatusText.doOnTextChanged { text, _, _, _ -> + when (text.toString()) { + getString(R.string.status_available) -> { + editStatus.setStartIconDrawable(R.drawable.ic_available) + vm.setStatus(UserStatus.None) + } + getString(R.string.status_away) -> { + editStatus.setStartIconDrawable(R.drawable.ic_away) + vm.setStatus(UserStatus.Away) + } + getString(R.string.status_busy) -> { + editStatus.setStartIconDrawable(R.drawable.ic_busy) + vm.setStatus(UserStatus.Busy) + } + } + } + } else { + editStatusText.doOnTextChanged { text, _, _, _ -> + when (text.toString()) { + getString(R.string.status_available) -> { + editStatus.setStartIconTintList(getColorStateList(R.color.status_available_color_list)) + vm.setStatus(UserStatus.None) + } + getString(R.string.status_away) -> { + editStatus.setStartIconTintList(getColorStateList(R.color.status_away_color_list)) + vm.setStatus(UserStatus.Away) + } + getString(R.string.status_busy) -> { + editStatus.setStartIconTintList(getColorStateList(R.color.status_busy_color_list)) + vm.setStatus(UserStatus.Busy) + } + } + } + } + + // Getting the avatar image side value according to the screen resolution + val metrics = resources.displayMetrics + val side = (min(metrics.widthPixels, metrics.heightPixels) * AVATAR_IMAGE_TO_SCREEN_RATIO).toInt() + + vm.user.observe(viewLifecycleOwner) { user -> + if (vm.statusModifiedFromDropdown) { + vm.statusModifiedFromDropdown = false + } else { + AvatarMaker(user).setAvatar(avatarImage, side, SizeUnit.PX) + userName.text = user.name + editStatusText.setText( + when (user.status) { + UserStatus.None -> getString(R.string.status_available) + UserStatus.Away -> getString(R.string.status_away) + UserStatus.Busy -> getString(R.string.status_busy) + }, + false + ) + statusMessage.text = user.statusMessage + } + } + + editName.setOnClickListener { + EditTextValueDialog( + title = getString(R.string.edit_name), + hint = getString(R.string.name), + defaultValue = userName.text.toString(), + filters = arrayOf(InputFilter.LengthFilter(TOX_MAX_NAME_LENGTH)) + ) { + vm.setName(it) + }.show(requireActivity().supportFragmentManager, EditTextValueDialog.TAG) + } + + editStatusMessage.setOnClickListener { + EditTextValueDialog( + title = getString(R.string.edit_status_message), + hint = getString(R.string.status_message), + defaultValue = statusMessage.text.toString(), + singleLine = false, + filters = arrayOf(InputFilter.LengthFilter(TOX_MAX_STATUS_MESSAGE_LENGTH)) + ) { + vm.setStatusMessage(it) + }.show(requireActivity().supportFragmentManager, EditTextValueDialog.TAG) + } + } +} diff --git a/atox/src/main/kotlin/ui/edit_user_profile/EditUserProfileViewModel.kt b/atox/src/main/kotlin/ui/edit_user_profile/EditUserProfileViewModel.kt new file mode 100644 index 00000000..7037a6d4 --- /dev/null +++ b/atox/src/main/kotlin/ui/edit_user_profile/EditUserProfileViewModel.kt @@ -0,0 +1,26 @@ +package ltd.evilcorp.atox.ui.edit_user_profile + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import javax.inject.Inject +import ltd.evilcorp.core.vo.User +import ltd.evilcorp.core.vo.UserStatus +import ltd.evilcorp.domain.feature.UserManager +import ltd.evilcorp.domain.tox.Tox + +class EditUserProfileViewModel @Inject constructor( + private val userManager: UserManager, + private val tox: Tox +) : ViewModel() { + val publicKey by lazy { tox.publicKey } + val user: LiveData = userManager.get(publicKey).asLiveData() + var statusModifiedFromDropdown: Boolean = false + + fun setName(name: String) = userManager.setName(name) + fun setStatusMessage(statusMessage: String) = userManager.setStatusMessage(statusMessage) + fun setStatus(status: UserStatus) { + statusModifiedFromDropdown = true + userManager.setStatus(status) + } +} diff --git a/atox/src/main/kotlin/ui/edit_user_profile/StatusArrayAdapter.kt b/atox/src/main/kotlin/ui/edit_user_profile/StatusArrayAdapter.kt new file mode 100644 index 00000000..68bf3cdb --- /dev/null +++ b/atox/src/main/kotlin/ui/edit_user_profile/StatusArrayAdapter.kt @@ -0,0 +1,48 @@ +package ltd.evilcorp.atox.ui.edit_user_profile + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Filter +import android.widget.ImageView +import ltd.evilcorp.atox.R + +class StatusArrayAdapter( + context: Context, + resource: Int, + textViewResourceId: Int, + private val strings: Array, +) : ArrayAdapter(context, resource, textViewResourceId, strings) { + + private val statusFilter = StatusFilter() // This filter doesn't filter :) + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) + val imageView: ImageView = view.findViewById(R.id.ic_status_indicator) + + when (getItem(position)) { + context.getString(R.string.status_available) -> imageView.setImageResource(R.drawable.ic_available) + context.getString(R.string.status_away) -> imageView.setImageResource(R.drawable.ic_away) + context.getString(R.string.status_busy) -> imageView.setImageResource(R.drawable.ic_busy) + } + + return view + } + + override fun getFilter(): Filter { + return statusFilter + } + + inner class StatusFilter : Filter() { + override fun performFiltering(prefix: CharSequence): FilterResults { + val results = FilterResults() + results.values = strings + results.count = strings.size + + return results + } + + override fun publishResults(constraint: CharSequence, results: FilterResults) = Unit + } +} diff --git a/atox/src/main/kotlin/ui/friend_request/FriendRequestFragment.kt b/atox/src/main/kotlin/ui/friend_request/FriendRequestFragment.kt index 132fe32a..3f4a8304 100644 --- a/atox/src/main/kotlin/ui/friend_request/FriendRequestFragment.kt +++ b/atox/src/main/kotlin/ui/friend_request/FriendRequestFragment.kt @@ -33,7 +33,7 @@ class FriendRequestFragment : BaseFragment(Fragmen compat } - toolbar.setNavigationIcon(R.drawable.back) + toolbar.setNavigationIcon(R.drawable.ic_back) toolbar.setNavigationOnClickListener { activity?.onBackPressed() } diff --git a/atox/src/main/kotlin/ui/settings/SettingsFragment.kt b/atox/src/main/kotlin/ui/settings/SettingsFragment.kt index a4229676..01698aac 100644 --- a/atox/src/main/kotlin/ui/settings/SettingsFragment.kt +++ b/atox/src/main/kotlin/ui/settings/SettingsFragment.kt @@ -96,7 +96,7 @@ class SettingsFragment : BaseFragment(FragmentSettingsB } toolbar.apply { - setNavigationIcon(R.drawable.back) + setNavigationIcon(R.drawable.ic_back) setNavigationOnClickListener { WindowInsetsControllerCompat(requireActivity().window, view) .hide(WindowInsetsCompat.Type.ime()) diff --git a/atox/src/main/kotlin/ui/user_profile/UserProfileFragment.kt b/atox/src/main/kotlin/ui/user_profile/UserProfileFragment.kt index 99312bf6..6cbf20a5 100644 --- a/atox/src/main/kotlin/ui/user_profile/UserProfileFragment.kt +++ b/atox/src/main/kotlin/ui/user_profile/UserProfileFragment.kt @@ -7,83 +7,94 @@ package ltd.evilcorp.atox.ui.user_profile import android.content.ClipData import android.content.ClipboardManager import android.content.Intent -import android.content.res.Resources import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Color +import android.net.Uri import android.os.Bundle -import android.text.InputFilter -import android.util.TypedValue -import android.view.ContextMenu -import android.view.MenuItem +import android.util.Log import android.view.View -import android.widget.EditText import android.widget.ImageView import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import androidx.core.content.FileProvider import androidx.core.content.getSystemService -import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.alpha +import androidx.core.graphics.blue +import androidx.core.graphics.green +import androidx.core.graphics.red import androidx.core.graphics.scale import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.setPadding import androidx.core.view.updatePadding import androidx.fragment.app.viewModels +import androidx.lifecycle.viewModelScope +import androidx.navigation.fragment.findNavController import io.nayuki.qrcodegen.QrCode +import java.io.File +import java.io.FileOutputStream +import java.io.IOException import kotlin.math.min import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ltd.evilcorp.atox.BuildConfig import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.FragmentUserProfileBinding +import ltd.evilcorp.atox.getColor +import ltd.evilcorp.atox.ui.AvatarMaker import ltd.evilcorp.atox.ui.BaseFragment -import ltd.evilcorp.atox.ui.StatusDialog +import ltd.evilcorp.atox.ui.colorFromStatus +import ltd.evilcorp.atox.ui.dpToPx +import ltd.evilcorp.atox.ui.isNightMode +import ltd.evilcorp.atox.ui.setImageButtonRippleDayNight import ltd.evilcorp.atox.vmFactory -import ltd.evilcorp.core.vo.UserStatus - -private const val TOX_MAX_NAME_LENGTH = 128 -private const val TOX_MAX_STATUS_MESSAGE_LENGTH = 1007 private const val QR_CODE_TO_SCREEN_RATIO = 0.5f -private const val QR_CODE_DIALOG_PADDING = 16f // in dp - -private fun dpToPx(dp: Float, res: Resources): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, res.displayMetrics).toInt() +private const val QR_CODE_PADDING = 16f // in dp +private const val QR_CODE_SHARED_IMAGE_PADDING = 30f // in dp class UserProfileFragment : BaseFragment(FragmentUserProfileBinding::inflate) { private val vm: UserProfileViewModel by viewModels { vmFactory } - private lateinit var currentStatus: UserStatus - - private fun colorFromStatus(status: UserStatus) = when (status) { - UserStatus.None -> ResourcesCompat.getColor(resources, R.color.statusAvailable, null) - UserStatus.Away -> ResourcesCompat.getColor(resources, R.color.statusAway, null) - UserStatus.Busy -> ResourcesCompat.getColor(resources, R.color.statusBusy, null) - } override fun onViewCreated(view: View, savedInstanceState: Bundle?) = binding.run { ViewCompat.setOnApplyWindowInsetsListener(view) { _, compat -> val insets = compat.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()) - profileCollapsingToolbar.updatePadding(left = insets.left, right = insets.right) - profileToolbar.updatePadding(top = insets.top) - mainSection.updatePadding(left = insets.left, right = insets.right) + toolbar.updatePadding(left = insets.left, top = insets.top) + mainSection.updatePadding(left = insets.left, right = insets.right, bottom = insets.bottom) compat } - profileToolbar.apply { - setNavigationOnClickListener { - activity?.onBackPressed() - } - } - vm.user.observe(viewLifecycleOwner) { user -> - currentStatus = user.status - userName.text = user.name userStatusMessage.text = user.statusMessage - userStatus.setColorFilter(colorFromStatus(user.status)) + profileImageLayout.statusIndicator.setColorFilter(colorFromStatus(resources, user.status)) + AvatarMaker(user).setAvatar(profileImageLayout.profileImage) } - userToxId.text = vm.toxId.string() + // Inflating views according to Day/Night theme + if (isNightMode(requireContext())) { + createQrCode(getColor(R.color.pleasantWhite), Color.TRANSPARENT, imageView = toxIdQr) + } else { + createQrCode(Color.BLACK, Color.TRANSPARENT, imageView = toxIdQr) + } + setImageButtonRippleDayNight(requireContext(), copyToxId) + + toolbar.setNavigationIcon(R.drawable.ic_back) + toolbar.setNavigationOnClickListener { + WindowInsetsControllerCompat(requireActivity().window, view) + .hide(WindowInsetsCompat.Type.ime()) + activity?.onBackPressed() + } + + editProfile.setOnClickListener { + findNavController().navigate(R.id.action_userProfileFragment_to_editUserProfileFragment) + } - // TODO(robinlinden): This should open a nice dialog where you show the QR and have both share and copy buttons. - profileShareId.setOnClickListener { + userToxId.text = vm.toxId.string() + userToxId.setOnClickListener { val shareIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" @@ -91,97 +102,105 @@ class UserProfileFragment : BaseFragment(FragmentUse } startActivity(Intent.createChooser(shareIntent, getString(R.string.tox_id_share))) } - registerForContextMenu(profileShareId) - profileOptions.profileChangeNickname.setOnClickListener { - val nameEdit = EditText(requireContext()).apply { - text.append(binding.userName.text) - filters = arrayOf(InputFilter.LengthFilter(TOX_MAX_NAME_LENGTH)) - setSingleLine() - } - AlertDialog.Builder(requireContext()) - .setTitle(R.string.name) - .setView(nameEdit) - .setPositiveButton(R.string.update) { _, _ -> - vm.setName(nameEdit.text.toString()) - } - .setNegativeButton(R.string.cancel) { _, _ -> } - .show() + copyToxId.setOnClickListener { + val clipboard = requireActivity().getSystemService()!! + clipboard.setPrimaryClip(ClipData.newPlainText(getText(R.string.tox_id), vm.toxId.string())) + Toast.makeText(requireContext(), getText(R.string.copied), Toast.LENGTH_SHORT).show() } - profileOptions.profileChangeStatusText.setOnClickListener { - val statusMessageEdit = - EditText(requireContext()).apply { - text.append(binding.userStatusMessage.text) - filters = arrayOf(InputFilter.LengthFilter(TOX_MAX_STATUS_MESSAGE_LENGTH)) - } - AlertDialog.Builder(requireContext()) - .setTitle(R.string.status_message) - .setView(statusMessageEdit) - .setPositiveButton(R.string.update) { _, _ -> - vm.setStatusMessage(statusMessageEdit.text.toString()) + toxIdQr.setOnClickListener { + vm.viewModelScope.launch { + val qrImageUri = getQrForSharing("tox_id_qr_code") + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + clipData = ClipData.newRawUri(null, qrImageUri) + type = "image/png" + putExtra(Intent.EXTRA_STREAM, qrImageUri) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION } - .setNegativeButton(R.string.cancel) { _, _ -> } - .show() - } - - profileOptions.profileChangeStatus.setOnClickListener { - StatusDialog(requireContext(), currentStatus) { status -> vm.setStatus(status) }.show() - } - - // TODO(robinlinden): Remove hack. It's used to make sure we can scroll to the settings - // further down when in landscape orientation. This is only needed if the view is recreated - // while we're on this screen as Android changes the size of the contents of the NestedScrollView - // when that happens. - if (savedInstanceState != null) { - needsHacks.updatePadding(bottom = (150 * resources.displayMetrics.density).toInt()) - } - } - - override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) = binding.run { - super.onCreateContextMenu(menu, v, menuInfo) - when (v.id) { - R.id.profile_share_id -> requireActivity().menuInflater.inflate( - R.menu.user_profile_share_id_context_menu, - menu - ) - } - } - - override fun onContextItemSelected(item: MenuItem): Boolean = binding.run { - return when (item.itemId) { - R.id.copy -> { - val clipboard = requireActivity().getSystemService()!! - clipboard.setPrimaryClip(ClipData.newPlainText(getText(R.string.tox_id), vm.toxId.string())) - Toast.makeText(requireContext(), getText(R.string.copied), Toast.LENGTH_SHORT).show() - true - } - R.id.qr -> { - createQrCodeDialog().show() - true + startActivity(Intent.createChooser(shareIntent, getString(R.string.tox_id_share))) } - else -> super.onContextItemSelected(item) } } - private fun createQrCodeDialog(): AlertDialog { + /** + * Function will create a QR code for the Tox ID and assign it to the image view if specified. + * @param qrCodeColor The color of the QR code. + * @param backgroundColor The color of the background. + * @param paddingDp The padding to add after each side in the QR code bitmap. Will be painted with backgroundColor. + * @param imageView The image view for whom to assign the QR code bitmap. Can be null. + * @return The QR code bitmap with the specified padding. + */ + private fun createQrCode( + qrCodeColor: Int = Color.BLACK, + backgroundColor: Int = Color.WHITE, + paddingDp: Float = QR_CODE_PADDING, + imageView: ImageView? = null, + ): Bitmap { + // Creating the QR bitmap val qrData = QrCode.encodeText("tox:%s".format(vm.toxId.string()), QrCode.Ecc.LOW) - var bmp: Bitmap = Bitmap.createBitmap(qrData.size, qrData.size, Bitmap.Config.RGB_565) + var bmpQr: Bitmap = Bitmap.createBitmap(qrData.size, qrData.size, Bitmap.Config.ARGB_8888) + bmpQr.setHasAlpha(true) for (x in 0 until qrData.size) { for (y in 0 until qrData.size) { - bmp.setPixel(x, y, if (qrData.getModule(x, y)) Color.BLACK else Color.WHITE) + bmpQr.setPixel(x, y, if (qrData.getModule(x, y)) qrCodeColor else backgroundColor) } } + // Scaling the QR bitmap to be half of the screen's width val metrics = resources.displayMetrics val size = (min(metrics.widthPixels, metrics.heightPixels) * QR_CODE_TO_SCREEN_RATIO).roundToInt() - bmp = bmp.scale(size, size, false) - val qrCode = ImageView(requireContext()).apply { - setPadding(dpToPx(QR_CODE_DIALOG_PADDING, resources)) - setImageBitmap(bmp) + bmpQr = bmpQr.scale(size, size, false) + + // Adding a padding to the QR bitmap + val paddingPx = dpToPx(paddingDp, resources) + val bmpQrWithPadding = + Bitmap.createBitmap(bmpQr.width + 2 * paddingPx, bmpQr.height + 2 * paddingPx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmpQrWithPadding) + canvas.drawARGB(backgroundColor.alpha, backgroundColor.red, backgroundColor.green, backgroundColor.blue) + canvas.drawBitmap(bmpQr, paddingPx.toFloat(), paddingPx.toFloat(), null) + + imageView?.apply { + setPadding(paddingPx) + setImageBitmap(bmpQr) + } + + return bmpQrWithPadding + } + + /** + * Function will save the image with the input bitmap and name to the cache directory as a png format. + * Then it will return the Uri of the image. + * @param image The bitmap of the image. + * @param name Image file name. + * @return The Uri of the image or null. + */ + private fun saveImageForSharing(image: Bitmap, name: String): Uri? { + val imagesFolder = File(requireActivity().cacheDir, "images") + var uri: Uri? = null + try { + imagesFolder.mkdirs() + val file = File(imagesFolder, "$name.png") + val stream = FileOutputStream(file) + image.compress(Bitmap.CompressFormat.PNG, 90, stream) + stream.flush() + stream.close() + uri = FileProvider.getUriForFile(requireActivity(), "${BuildConfig.APPLICATION_ID}.fileprovider", file) + } catch (e: IOException) { + Log.d("corp.atox.debug", "IOException while trying to write file for sharing: " + e.message) + } + return uri + } + + /** + * Function will run in a different thread, create a new QR code for sharing and return the Uri. + * @param name The image name. + * @return The Uri for the QR code. + */ + private suspend fun getQrForSharing(name: String): Uri? { + return withContext(Dispatchers.IO) { + val bmp = createQrCode(paddingDp = QR_CODE_SHARED_IMAGE_PADDING) + saveImageForSharing(bmp, name) } - return AlertDialog.Builder(requireContext()) - .setTitle(R.string.tox_id) - .setView(qrCode) - .create() } } diff --git a/atox/src/main/res/anim/slide_in_left.xml b/atox/src/main/res/anim/slide_in_left.xml new file mode 100644 index 00000000..7bba0912 --- /dev/null +++ b/atox/src/main/res/anim/slide_in_left.xml @@ -0,0 +1,6 @@ + + diff --git a/atox/src/main/res/anim/slide_in_right.xml b/atox/src/main/res/anim/slide_in_right.xml index b2769790..1871adf3 100644 --- a/atox/src/main/res/anim/slide_in_right.xml +++ b/atox/src/main/res/anim/slide_in_right.xml @@ -2,5 +2,5 @@ diff --git a/atox/src/main/res/anim/slide_out_left.xml b/atox/src/main/res/anim/slide_out_left.xml new file mode 100644 index 00000000..e480fd59 --- /dev/null +++ b/atox/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,6 @@ + + diff --git a/atox/src/main/res/anim/slide_out_right.xml b/atox/src/main/res/anim/slide_out_right.xml index c970a130..28e919a3 100644 --- a/atox/src/main/res/anim/slide_out_right.xml +++ b/atox/src/main/res/anim/slide_out_right.xml @@ -2,5 +2,5 @@ diff --git a/atox/src/main/res/color/box_stroke_color_day.xml b/atox/src/main/res/color/box_stroke_color_day.xml new file mode 100644 index 00000000..28cadab8 --- /dev/null +++ b/atox/src/main/res/color/box_stroke_color_day.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/atox/src/main/res/color/box_stroke_color_night.xml b/atox/src/main/res/color/box_stroke_color_night.xml new file mode 100644 index 00000000..4a82eab6 --- /dev/null +++ b/atox/src/main/res/color/box_stroke_color_night.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/atox/src/main/res/color/hint_text_color_day.xml b/atox/src/main/res/color/hint_text_color_day.xml new file mode 100644 index 00000000..a8549898 --- /dev/null +++ b/atox/src/main/res/color/hint_text_color_day.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/atox/src/main/res/color/hint_text_color_night.xml b/atox/src/main/res/color/hint_text_color_night.xml new file mode 100644 index 00000000..c32a268a --- /dev/null +++ b/atox/src/main/res/color/hint_text_color_night.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/atox/src/main/res/color/status_available_color_list.xml b/atox/src/main/res/color/status_available_color_list.xml new file mode 100644 index 00000000..dba5b8da --- /dev/null +++ b/atox/src/main/res/color/status_available_color_list.xml @@ -0,0 +1,4 @@ + + + + diff --git a/atox/src/main/res/color/status_away_color_list.xml b/atox/src/main/res/color/status_away_color_list.xml new file mode 100644 index 00000000..50d1dbae --- /dev/null +++ b/atox/src/main/res/color/status_away_color_list.xml @@ -0,0 +1,4 @@ + + + + diff --git a/atox/src/main/res/color/status_busy_color_list.xml b/atox/src/main/res/color/status_busy_color_list.xml new file mode 100644 index 00000000..826ef5d0 --- /dev/null +++ b/atox/src/main/res/color/status_busy_color_list.xml @@ -0,0 +1,4 @@ + + + + diff --git a/atox/src/main/res/color/trailing_icon_color_day.xml b/atox/src/main/res/color/trailing_icon_color_day.xml new file mode 100644 index 00000000..9f631fb1 --- /dev/null +++ b/atox/src/main/res/color/trailing_icon_color_day.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/atox/src/main/res/color/trailing_icon_color_night.xml b/atox/src/main/res/color/trailing_icon_color_night.xml new file mode 100644 index 00000000..cbae2526 --- /dev/null +++ b/atox/src/main/res/color/trailing_icon_color_night.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/atox/src/main/res/drawable/attach_file.xml b/atox/src/main/res/drawable/ic_attach_file.xml similarity index 100% rename from atox/src/main/res/drawable/attach_file.xml rename to atox/src/main/res/drawable/ic_attach_file.xml diff --git a/atox/src/main/res/drawable/ic_available.xml b/atox/src/main/res/drawable/ic_available.xml new file mode 100644 index 00000000..3b0240d3 --- /dev/null +++ b/atox/src/main/res/drawable/ic_available.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/atox/src/main/res/drawable/ic_away.xml b/atox/src/main/res/drawable/ic_away.xml new file mode 100644 index 00000000..d6e632a3 --- /dev/null +++ b/atox/src/main/res/drawable/ic_away.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/atox/src/main/res/drawable/back.xml b/atox/src/main/res/drawable/ic_back.xml similarity index 89% rename from atox/src/main/res/drawable/back.xml rename to atox/src/main/res/drawable/ic_back.xml index 9f690964..0ee09c33 100644 --- a/atox/src/main/res/drawable/back.xml +++ b/atox/src/main/res/drawable/ic_back.xml @@ -4,6 +4,6 @@ android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0"> - diff --git a/atox/src/main/res/drawable/ic_busy.xml b/atox/src/main/res/drawable/ic_busy.xml new file mode 100644 index 00000000..8fddfd38 --- /dev/null +++ b/atox/src/main/res/drawable/ic_busy.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/atox/src/main/res/drawable/ic_copy.xml b/atox/src/main/res/drawable/ic_copy.xml new file mode 100644 index 00000000..3ede6153 --- /dev/null +++ b/atox/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/atox/src/main/res/drawable/ic_edit.xml b/atox/src/main/res/drawable/ic_edit.xml new file mode 100644 index 00000000..be60d088 --- /dev/null +++ b/atox/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/atox/src/main/res/drawable/ic_person.xml b/atox/src/main/res/drawable/ic_person.xml new file mode 100644 index 00000000..66a93840 --- /dev/null +++ b/atox/src/main/res/drawable/ic_person.xml @@ -0,0 +1,9 @@ + + + diff --git a/atox/src/main/res/drawable/send.xml b/atox/src/main/res/drawable/ic_send.xml similarity index 100% rename from atox/src/main/res/drawable/send.xml rename to atox/src/main/res/drawable/ic_send.xml diff --git a/atox/src/main/res/drawable/ic_status.xml b/atox/src/main/res/drawable/ic_status.xml new file mode 100644 index 00000000..1abe09a4 --- /dev/null +++ b/atox/src/main/res/drawable/ic_status.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/atox/src/main/res/drawable/ic_status_message.xml b/atox/src/main/res/drawable/ic_status_message.xml new file mode 100644 index 00000000..81da7238 --- /dev/null +++ b/atox/src/main/res/drawable/ic_status_message.xml @@ -0,0 +1,9 @@ + + + diff --git a/atox/src/main/res/drawable/side_nav_bar_background.xml b/atox/src/main/res/drawable/side_nav_bar_background.xml index 467ca417..853f574a 100644 --- a/atox/src/main/res/drawable/side_nav_bar_background.xml +++ b/atox/src/main/res/drawable/side_nav_bar_background.xml @@ -3,7 +3,7 @@ diff --git a/atox/src/main/res/layout/dialog_receive_share.xml b/atox/src/main/res/layout/dialog_receive_share.xml index 34980c82..698909f4 100644 --- a/atox/src/main/res/layout/dialog_receive_share.xml +++ b/atox/src/main/res/layout/dialog_receive_share.xml @@ -27,7 +27,7 @@ android:fontFamily="sans-serif-light" android:gravity="center" android:text="@string/receive_share_share_to" - android:textColor="@color/textWhiteColor" + android:textColor="@android:color/white" android:textSize="20sp" /> - - - - - - - - - - - - - - - - - - - - - - - -