Browse Source
[android] Add profile management (#3461)
[android] Add profile management (#3461)
There could be an issue with save files being wiped if updating from an older version, this is due to profiles being hard set on android previously but am not sure, needs testing Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3461 Reviewed-by: Lizzie <lizzie@eden-emu.dev> Reviewed-by: DraVee <dravee@eden-emu.dev> Co-authored-by: nekle <nekle@protonmail.com> Co-committed-by: nekle <nekle@protonmail.com>pull/2862/head
committed by
Maufeat
17 changed files with 1769 additions and 1 deletions
-
19src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
-
55src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FirmwareAvatarAdapter.kt
-
112src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/ProfileAdapter.kt
-
459src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EditUserDialogFragment.kt
-
11src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
-
190src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProfileManagerFragment.kt
-
21src/android/app/src/main/java/org/yuzu/yuzu_emu/model/UserProfile.kt
-
3src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt
-
386src/android/app/src/main/jni/native.cpp
-
9src/android/app/src/main/res/drawable/ic_account_circle.xml
-
39src/android/app/src/main/res/layout/dialog_firmware_avatar_picker.xml
-
226src/android/app/src/main/res/layout/fragment_edit_user_dialog.xml
-
49src/android/app/src/main/res/layout/fragment_profile_manager.xml
-
21src/android/app/src/main/res/layout/item_firmware_avatar.xml
-
125src/android/app/src/main/res/layout/list_item_profile.xml
-
16src/android/app/src/main/res/navigation/home_navigation.xml
-
29src/android/app/src/main/res/values/strings.xml
@ -0,0 +1,55 @@ |
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
|||
// SPDX-License-Identifier: GPL-3.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.adapters |
|||
|
|||
import android.graphics.Bitmap |
|||
import android.view.LayoutInflater |
|||
import android.view.ViewGroup |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import org.yuzu.yuzu_emu.databinding.ItemFirmwareAvatarBinding |
|||
|
|||
class FirmwareAvatarAdapter( |
|||
private val avatars: List<Bitmap>, |
|||
private val onAvatarSelected: (Bitmap) -> Unit |
|||
) : RecyclerView.Adapter<FirmwareAvatarAdapter.AvatarViewHolder>() { |
|||
|
|||
private var selectedPosition = -1 |
|||
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AvatarViewHolder { |
|||
val binding = ItemFirmwareAvatarBinding.inflate( |
|||
LayoutInflater.from(parent.context), |
|||
parent, |
|||
false |
|||
) |
|||
return AvatarViewHolder(binding) |
|||
} |
|||
|
|||
override fun onBindViewHolder(holder: AvatarViewHolder, position: Int) { |
|||
holder.bind(avatars[position], position == selectedPosition) |
|||
} |
|||
|
|||
override fun getItemCount(): Int = avatars.size |
|||
|
|||
inner class AvatarViewHolder( |
|||
private val binding: ItemFirmwareAvatarBinding |
|||
) : RecyclerView.ViewHolder(binding.root) { |
|||
|
|||
fun bind(avatar: Bitmap, isSelected: Boolean) { |
|||
binding.imageAvatar.setImageBitmap(avatar) |
|||
binding.root.isChecked = isSelected |
|||
|
|||
binding.root.setOnClickListener { |
|||
val previousSelected = selectedPosition |
|||
selectedPosition = bindingAdapterPosition |
|||
|
|||
if (previousSelected != -1) { |
|||
notifyItemChanged(previousSelected) |
|||
} |
|||
notifyItemChanged(selectedPosition) |
|||
|
|||
onAvatarSelected(avatar) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,112 @@ |
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
|||
// SPDX-License-Identifier: GPL-3.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.adapters |
|||
|
|||
import android.graphics.BitmapFactory |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.recyclerview.widget.AsyncListDiffer |
|||
import androidx.recyclerview.widget.DiffUtil |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import org.yuzu.yuzu_emu.R |
|||
import org.yuzu.yuzu_emu.databinding.ListItemProfileBinding |
|||
import org.yuzu.yuzu_emu.model.UserProfile |
|||
import java.io.File |
|||
import org.yuzu.yuzu_emu.NativeLibrary |
|||
|
|||
class ProfileAdapter( |
|||
private val onProfileClick: (UserProfile) -> Unit, |
|||
private val onEditClick: (UserProfile) -> Unit, |
|||
private val onDeleteClick: (UserProfile) -> Unit |
|||
) : RecyclerView.Adapter<ProfileAdapter.ProfileViewHolder>() { |
|||
|
|||
private var currentUserUUID: String = "" |
|||
|
|||
private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback<UserProfile>() { |
|||
override fun areItemsTheSame(oldItem: UserProfile, newItem: UserProfile): Boolean { |
|||
return oldItem.uuid == newItem.uuid |
|||
} |
|||
|
|||
override fun areContentsTheSame(oldItem: UserProfile, newItem: UserProfile): Boolean { |
|||
return oldItem == newItem |
|||
} |
|||
}) |
|||
|
|||
fun submitList(list: List<UserProfile>) { |
|||
differ.submitList(list) |
|||
} |
|||
|
|||
fun setCurrentUser(uuid: String) { |
|||
currentUserUUID = uuid |
|||
notifyDataSetChanged() |
|||
} |
|||
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder { |
|||
val binding = ListItemProfileBinding.inflate( |
|||
LayoutInflater.from(parent.context), |
|||
parent, |
|||
false |
|||
) |
|||
return ProfileViewHolder(binding) |
|||
} |
|||
|
|||
override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) { |
|||
holder.bind(differ.currentList[position]) |
|||
} |
|||
|
|||
override fun getItemCount(): Int = differ.currentList.size |
|||
|
|||
inner class ProfileViewHolder(private val binding: ListItemProfileBinding) : |
|||
RecyclerView.ViewHolder(binding.root) { |
|||
|
|||
fun bind(profile: UserProfile) { |
|||
binding.textUsername.text = profile.username |
|||
binding.textUuid.text = formatUUID(profile.uuid) |
|||
|
|||
val imageFile = File(profile.imagePath) |
|||
if (imageFile.exists()) { |
|||
val bitmap = BitmapFactory.decodeFile(profile.imagePath) |
|||
binding.imageAvatar.setImageBitmap(bitmap) |
|||
} else { |
|||
val jpegData = NativeLibrary.getDefaultAccountBackupJpeg() |
|||
val bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size) |
|||
binding.imageAvatar.setImageBitmap(bitmap) |
|||
} |
|||
|
|||
if (profile.uuid == currentUserUUID) { |
|||
binding.checkContainer.visibility = View.VISIBLE |
|||
} else { |
|||
binding.checkContainer.visibility = View.GONE |
|||
} |
|||
|
|||
binding.root.setOnClickListener { |
|||
onProfileClick(profile) |
|||
} |
|||
|
|||
binding.buttonEdit.setOnClickListener { |
|||
onEditClick(profile) |
|||
} |
|||
|
|||
binding.buttonDelete.setOnClickListener { |
|||
onDeleteClick(profile) |
|||
} |
|||
} |
|||
|
|||
private fun formatUUID(uuid: String): String { |
|||
if (uuid.length != 32) return uuid |
|||
return buildString { |
|||
append(uuid.substring(0, 8)) |
|||
append("-") |
|||
append(uuid.substring(8, 12)) |
|||
append("-") |
|||
append(uuid.substring(12, 16)) |
|||
append("-") |
|||
append(uuid.substring(16, 20)) |
|||
append("-") |
|||
append(uuid.substring(20, 32)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,459 @@ |
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
|||
// SPDX-License-Identifier: GPL-3.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.fragments |
|||
|
|||
import android.app.Activity |
|||
import android.content.Intent |
|||
import android.graphics.Bitmap |
|||
import android.graphics.BitmapFactory |
|||
import android.graphics.ImageDecoder |
|||
import android.net.Uri |
|||
import android.os.Build |
|||
import android.os.Bundle |
|||
import android.provider.MediaStore |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.activity.result.contract.ActivityResultContracts |
|||
import androidx.core.view.ViewCompat |
|||
import androidx.core.view.WindowInsetsCompat |
|||
import androidx.core.view.updatePadding |
|||
import androidx.core.widget.doAfterTextChanged |
|||
import androidx.fragment.app.Fragment |
|||
import androidx.fragment.app.activityViewModels |
|||
import androidx.lifecycle.lifecycleScope |
|||
import androidx.navigation.fragment.findNavController |
|||
import androidx.recyclerview.widget.GridLayoutManager |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder |
|||
import com.google.android.material.transition.MaterialSharedAxis |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.launch |
|||
import kotlinx.coroutines.withContext |
|||
import org.yuzu.yuzu_emu.NativeLibrary |
|||
import org.yuzu.yuzu_emu.R |
|||
import org.yuzu.yuzu_emu.adapters.FirmwareAvatarAdapter |
|||
import org.yuzu.yuzu_emu.databinding.FragmentEditUserDialogBinding |
|||
import org.yuzu.yuzu_emu.model.HomeViewModel |
|||
import org.yuzu.yuzu_emu.model.ProfileUtils |
|||
import org.yuzu.yuzu_emu.model.UserProfile |
|||
import java.io.File |
|||
import java.io.FileOutputStream |
|||
import androidx.core.graphics.scale |
|||
import androidx.core.graphics.createBitmap |
|||
|
|||
class EditUserDialogFragment : Fragment() { |
|||
private var _binding: FragmentEditUserDialogBinding? = null |
|||
private val binding get() = _binding!! |
|||
|
|||
private val homeViewModel: HomeViewModel by activityViewModels() |
|||
|
|||
private var currentUUID: String = "" |
|||
private var isEditMode = false |
|||
private var selectedImageUri: Uri? = null |
|||
private var selectedFirmwareAvatar: Bitmap? = null |
|||
private var hasCustomImage = false |
|||
private var revertedToDefault = false |
|||
|
|||
companion object { |
|||
private const val ARG_UUID = "uuid" |
|||
private const val ARG_USERNAME = "username" |
|||
|
|||
fun newInstance(profile: UserProfile?): EditUserDialogFragment { |
|||
val fragment = EditUserDialogFragment() |
|||
profile?.let { |
|||
val args = Bundle() |
|||
args.putString(ARG_UUID, it.uuid) |
|||
args.putString(ARG_USERNAME, it.username) |
|||
fragment.arguments = args |
|||
} |
|||
return fragment |
|||
} |
|||
} |
|||
|
|||
private val imagePickerLauncher = registerForActivityResult( |
|||
ActivityResultContracts.StartActivityForResult() |
|||
) { result -> |
|||
if (result.resultCode == Activity.RESULT_OK) { |
|||
result.data?.data?.let { uri -> |
|||
selectedImageUri = uri |
|||
loadImage(uri) |
|||
hasCustomImage = true |
|||
binding.buttonRevertImage.visibility = View.VISIBLE |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) |
|||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) |
|||
} |
|||
|
|||
override fun onCreateView( |
|||
inflater: LayoutInflater, |
|||
container: ViewGroup?, |
|||
savedInstanceState: Bundle? |
|||
): View { |
|||
_binding = FragmentEditUserDialogBinding.inflate(layoutInflater) |
|||
return binding.root |
|||
} |
|||
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
|||
super.onViewCreated(view, savedInstanceState) |
|||
homeViewModel.setStatusBarShadeVisibility(visible = false) |
|||
|
|||
val existingUUID = arguments?.getString(ARG_UUID) |
|||
val existingUsername = arguments?.getString(ARG_USERNAME) |
|||
|
|||
if (existingUUID != null && existingUsername != null) { |
|||
isEditMode = true |
|||
currentUUID = existingUUID |
|||
binding.toolbarNewUser.title = getString(R.string.profile_edit_user) |
|||
binding.editUsername.setText(existingUsername) |
|||
binding.textUuid.text = formatUUID(existingUUID) |
|||
binding.buttonGenerateUuid.visibility = View.GONE |
|||
|
|||
val imagePath = NativeLibrary.getUserImagePath(existingUUID) |
|||
val imageFile = File(imagePath) |
|||
if (imageFile.exists()) { |
|||
val bitmap = BitmapFactory.decodeFile(imagePath) |
|||
binding.imageUserAvatar.setImageBitmap(bitmap) |
|||
hasCustomImage = true |
|||
binding.buttonRevertImage.visibility = View.VISIBLE |
|||
} else { |
|||
loadDefaultAvatar() |
|||
} |
|||
} else { |
|||
isEditMode = false |
|||
currentUUID = ProfileUtils.generateRandomUUID() |
|||
binding.toolbarNewUser.title = getString(R.string.profile_new_user) |
|||
binding.textUuid.text = formatUUID(currentUUID) |
|||
loadDefaultAvatar() |
|||
} |
|||
|
|||
binding.toolbarNewUser.setNavigationOnClickListener { |
|||
findNavController().popBackStack() |
|||
} |
|||
|
|||
binding.editUsername.doAfterTextChanged { |
|||
validateInput() |
|||
} |
|||
|
|||
binding.buttonGenerateUuid.setOnClickListener { |
|||
currentUUID = ProfileUtils.generateRandomUUID() |
|||
binding.textUuid.text = formatUUID(currentUUID) |
|||
} |
|||
|
|||
binding.buttonSelectImage.setOnClickListener { |
|||
selectImage() |
|||
} |
|||
|
|||
binding.buttonRevertImage.setOnClickListener { |
|||
revertToDefaultImage() |
|||
} |
|||
|
|||
if (NativeLibrary.isFirmwareAvailable()) { |
|||
binding.buttonFirmwareAvatars.visibility = View.VISIBLE |
|||
binding.buttonFirmwareAvatars.setOnClickListener { |
|||
showFirmwareAvatarPicker() |
|||
} |
|||
} |
|||
|
|||
binding.buttonSave.setOnClickListener { |
|||
saveUser() |
|||
} |
|||
|
|||
binding.buttonCancel.setOnClickListener { |
|||
findNavController().popBackStack() |
|||
} |
|||
|
|||
validateInput() |
|||
setInsets() |
|||
} |
|||
|
|||
private fun showFirmwareAvatarPicker() { |
|||
val dialogView = LayoutInflater.from(requireContext()) |
|||
.inflate(R.layout.dialog_firmware_avatar_picker, null) |
|||
|
|||
val gridAvatars = dialogView.findViewById<RecyclerView>(R.id.grid_avatars) |
|||
val progressLoading = dialogView.findViewById<View>(R.id.progress_loading) |
|||
val textEmpty = dialogView.findViewById<View>(R.id.text_empty) |
|||
|
|||
val dialog = MaterialAlertDialogBuilder(requireContext()) |
|||
.setTitle(R.string.profile_firmware_avatars) |
|||
.setView(dialogView) |
|||
.setNegativeButton(android.R.string.cancel, null) |
|||
.create() |
|||
|
|||
dialog.show() |
|||
|
|||
viewLifecycleOwner.lifecycleScope.launch { |
|||
val avatars = withContext(Dispatchers.IO) { |
|||
loadFirmwareAvatars() |
|||
} |
|||
|
|||
if (avatars.isEmpty()) { |
|||
progressLoading.visibility = View.GONE |
|||
textEmpty.visibility = View.VISIBLE |
|||
} else { |
|||
progressLoading.visibility = View.GONE |
|||
gridAvatars.visibility = View.VISIBLE |
|||
|
|||
val adapter = FirmwareAvatarAdapter(avatars) { selectedAvatar -> |
|||
val scaledBitmap = selectedAvatar.scale(256, 256) |
|||
binding.imageUserAvatar.setImageBitmap(scaledBitmap) |
|||
selectedFirmwareAvatar = scaledBitmap |
|||
hasCustomImage = true |
|||
binding.buttonRevertImage.visibility = View.VISIBLE |
|||
dialog.dismiss() |
|||
} |
|||
|
|||
gridAvatars.apply { |
|||
layoutManager = GridLayoutManager(requireContext(), 4) |
|||
this.adapter = adapter |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun loadFirmwareAvatars(): List<Bitmap> { |
|||
val avatars = mutableListOf<Bitmap>() |
|||
val count = NativeLibrary.getFirmwareAvatarCount() |
|||
|
|||
for (i in 0 until count) { |
|||
try { |
|||
val imageData = NativeLibrary.getFirmwareAvatarImage(i) ?: continue |
|||
|
|||
val argbData = IntArray(256 * 256) |
|||
for (pixel in 0 until 256 * 256) { |
|||
val offset = pixel * 4 |
|||
val r = imageData[offset].toInt() and 0xFF |
|||
val g = imageData[offset + 1].toInt() and 0xFF |
|||
val b = imageData[offset + 2].toInt() and 0xFF |
|||
val a = imageData[offset + 3].toInt() and 0xFF |
|||
argbData[pixel] = (a shl 24) or (r shl 16) or (g shl 8) or b |
|||
} |
|||
|
|||
val bitmap = Bitmap.createBitmap(argbData, 256, 256, Bitmap.Config.ARGB_8888) |
|||
avatars.add(bitmap) |
|||
} catch (e: Exception) { |
|||
continue |
|||
} |
|||
} |
|||
|
|||
return avatars |
|||
} |
|||
|
|||
private fun formatUUID(uuid: String): String { |
|||
if (uuid.length != 32) return uuid |
|||
return buildString { |
|||
append(uuid.substring(0, 8)) |
|||
append("-") |
|||
append(uuid.substring(8, 12)) |
|||
append("-") |
|||
append(uuid.substring(12, 16)) |
|||
append("-") |
|||
append(uuid.substring(16, 20)) |
|||
append("-") |
|||
append(uuid.substring(20, 32)) |
|||
} |
|||
} |
|||
|
|||
private fun validateInput() { |
|||
val username = binding.editUsername.text.toString() |
|||
val isValid = username.isNotEmpty() && username.length <= 32 |
|||
binding.buttonSave.isEnabled = isValid |
|||
} |
|||
|
|||
private fun selectImage() { |
|||
val intent = Intent(Intent.ACTION_PICK).apply { |
|||
type = "image/*" |
|||
} |
|||
imagePickerLauncher.launch(intent) |
|||
} |
|||
|
|||
private fun loadImage(uri: Uri) { |
|||
try { |
|||
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { |
|||
val source = ImageDecoder.createSource(requireContext().contentResolver, uri) |
|||
ImageDecoder.decodeBitmap(source) { decoder, _, _ -> |
|||
decoder.setTargetSampleSize(1) |
|||
} |
|||
} else { |
|||
@Suppress("DEPRECATION") |
|||
MediaStore.Images.Media.getBitmap(requireContext().contentResolver, uri) |
|||
} |
|||
|
|||
val croppedBitmap = centerCropBitmap(bitmap, 256, 256) |
|||
binding.imageUserAvatar.setImageBitmap(croppedBitmap) |
|||
} catch (e: Exception) { |
|||
MaterialAlertDialogBuilder(requireContext()) |
|||
.setTitle(R.string.error) |
|||
.setMessage(getString(R.string.profile_image_load_error, e.message)) |
|||
.setPositiveButton(android.R.string.ok, null) |
|||
.show() |
|||
} |
|||
} |
|||
|
|||
private fun loadDefaultAvatar() { |
|||
val jpegData = NativeLibrary.getDefaultAccountBackupJpeg() |
|||
val bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size) |
|||
binding.imageUserAvatar.setImageBitmap(bitmap) |
|||
|
|||
hasCustomImage = false |
|||
binding.buttonRevertImage.visibility = View.GONE |
|||
} |
|||
|
|||
private fun revertToDefaultImage() { |
|||
selectedImageUri = null |
|||
selectedFirmwareAvatar = null |
|||
revertedToDefault = true |
|||
loadDefaultAvatar() |
|||
} |
|||
|
|||
private fun saveUser() { |
|||
val username = binding.editUsername.text.toString() |
|||
|
|||
if (isEditMode) { |
|||
if (NativeLibrary.updateUserUsername(currentUUID, username)) { |
|||
saveImageIfNeeded() |
|||
findNavController().popBackStack() |
|||
} else { |
|||
showError(getString(R.string.profile_update_failed)) |
|||
} |
|||
} else { |
|||
if (NativeLibrary.createUser(currentUUID, username)) { |
|||
saveImageIfNeeded() |
|||
findNavController().popBackStack() |
|||
} else { |
|||
showError(getString(R.string.profile_create_failed)) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun saveImageIfNeeded() { |
|||
if (revertedToDefault && isEditMode) { |
|||
val imagePath = NativeLibrary.getUserImagePath(currentUUID) |
|||
if (imagePath != null) { |
|||
val imageFile = File(imagePath) |
|||
if (imageFile.exists()) { |
|||
imageFile.delete() |
|||
} |
|||
} |
|||
|
|||
return |
|||
} |
|||
|
|||
if (!hasCustomImage) { |
|||
return |
|||
} |
|||
|
|||
try { |
|||
val bitmapToSave: Bitmap? = when { |
|||
selectedFirmwareAvatar != null -> selectedFirmwareAvatar |
|||
selectedImageUri != null -> { |
|||
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { |
|||
val source = ImageDecoder.createSource( |
|||
requireContext().contentResolver, |
|||
selectedImageUri!! |
|||
) |
|||
ImageDecoder.decodeBitmap(source) |
|||
} else { |
|||
@Suppress("DEPRECATION") |
|||
MediaStore.Images.Media.getBitmap( |
|||
requireContext().contentResolver, |
|||
selectedImageUri |
|||
) |
|||
} |
|||
centerCropBitmap(bitmap, 256, 256) |
|||
} |
|||
|
|||
else -> null |
|||
} |
|||
|
|||
if (bitmapToSave == null) { |
|||
return |
|||
} |
|||
|
|||
val tempFile = File(requireContext().cacheDir, "temp_avatar_${currentUUID}.jpg") |
|||
FileOutputStream(tempFile).use { out -> |
|||
bitmapToSave.compress(Bitmap.CompressFormat.JPEG, 100, out) |
|||
} |
|||
|
|||
NativeLibrary.saveUserImage(currentUUID, tempFile.absolutePath) |
|||
|
|||
tempFile.delete() |
|||
} catch (e: Exception) { |
|||
showError(getString(R.string.profile_image_save_error, e.message)) |
|||
} |
|||
} |
|||
|
|||
private fun centerCropBitmap(source: Bitmap, targetWidth: Int, targetHeight: Int): Bitmap { |
|||
val sourceWidth = source.width |
|||
val sourceHeight = source.height |
|||
|
|||
val scale = maxOf( |
|||
targetWidth.toFloat() / sourceWidth, |
|||
targetHeight.toFloat() / sourceHeight |
|||
) |
|||
|
|||
val scaledWidth = (sourceWidth * scale).toInt() |
|||
val scaledHeight = (sourceHeight * scale).toInt() |
|||
|
|||
val scaledBitmap = source.scale(scaledWidth, scaledHeight) |
|||
|
|||
val x = (scaledWidth - targetWidth) / 2 |
|||
val y = (scaledHeight - targetHeight) / 2 |
|||
|
|||
return Bitmap.createBitmap(scaledBitmap, x, y, targetWidth, targetHeight) |
|||
} |
|||
|
|||
private fun showError(message: String) { |
|||
MaterialAlertDialogBuilder(requireContext()) |
|||
.setTitle(R.string.error) |
|||
.setMessage(message) |
|||
.setPositiveButton(android.R.string.ok, null) |
|||
.show() |
|||
} |
|||
|
|||
private fun setInsets() = |
|||
ViewCompat.setOnApplyWindowInsetsListener( |
|||
binding.root |
|||
) { _: View, windowInsets: WindowInsetsCompat -> |
|||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) |
|||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) |
|||
|
|||
val leftInset = barInsets.left + cutoutInsets.left |
|||
val topInset = cutoutInsets.top |
|||
val rightInset = barInsets.right + cutoutInsets.right |
|||
val bottomInset = barInsets.bottom + cutoutInsets.bottom |
|||
|
|||
binding.appbar.updatePadding( |
|||
left = leftInset, |
|||
top = topInset, |
|||
right = rightInset |
|||
) |
|||
|
|||
binding.scrollContent.updatePadding( |
|||
left = leftInset, |
|||
right = rightInset |
|||
) |
|||
|
|||
binding.buttonContainer.updatePadding( |
|||
left = leftInset, |
|||
right = rightInset, |
|||
bottom = bottomInset |
|||
) |
|||
|
|||
windowInsets |
|||
} |
|||
|
|||
|
|||
|
|||
override fun onDestroyView() { |
|||
super.onDestroyView() |
|||
_binding = null |
|||
} |
|||
} |
|||
@ -0,0 +1,190 @@ |
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
|||
// SPDX-License-Identifier: GPL-3.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.fragments |
|||
|
|||
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.fragment.app.Fragment |
|||
import androidx.fragment.app.activityViewModels |
|||
import androidx.navigation.fragment.findNavController |
|||
import androidx.recyclerview.widget.LinearLayoutManager |
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder |
|||
import com.google.android.material.transition.MaterialSharedAxis |
|||
import org.yuzu.yuzu_emu.NativeLibrary |
|||
import org.yuzu.yuzu_emu.R |
|||
import org.yuzu.yuzu_emu.adapters.ProfileAdapter |
|||
import org.yuzu.yuzu_emu.databinding.FragmentProfileManagerBinding |
|||
import org.yuzu.yuzu_emu.model.HomeViewModel |
|||
import org.yuzu.yuzu_emu.model.UserProfile |
|||
import org.yuzu.yuzu_emu.utils.NativeConfig |
|||
|
|||
class ProfileManagerFragment : Fragment() { |
|||
private var _binding: FragmentProfileManagerBinding? = null |
|||
private val binding get() = _binding!! |
|||
|
|||
private val homeViewModel: HomeViewModel by activityViewModels() |
|||
private lateinit var profileAdapter: ProfileAdapter |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) |
|||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) |
|||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) |
|||
} |
|||
|
|||
override fun onCreateView( |
|||
inflater: LayoutInflater, |
|||
container: ViewGroup?, |
|||
savedInstanceState: Bundle? |
|||
): View { |
|||
_binding = FragmentProfileManagerBinding.inflate(layoutInflater) |
|||
return binding.root |
|||
} |
|||
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
|||
super.onViewCreated(view, savedInstanceState) |
|||
homeViewModel.setStatusBarShadeVisibility(visible = false) |
|||
|
|||
binding.toolbarProfiles.setNavigationOnClickListener { |
|||
findNavController().popBackStack() |
|||
} |
|||
|
|||
setupRecyclerView() |
|||
loadProfiles() |
|||
|
|||
binding.buttonAddUser.setOnClickListener { |
|||
if (NativeLibrary.canCreateUser()) { |
|||
findNavController().navigate(R.id.action_profileManagerFragment_to_newUserDialog) |
|||
} else { |
|||
MaterialAlertDialogBuilder(requireContext()) |
|||
.setTitle(R.string.profile_max_users_title) |
|||
.setMessage(R.string.profile_max_users_message) |
|||
.setPositiveButton(android.R.string.ok, null) |
|||
.show() |
|||
} |
|||
} |
|||
|
|||
setInsets() |
|||
} |
|||
|
|||
override fun onResume() { |
|||
super.onResume() |
|||
loadProfiles() |
|||
} |
|||
|
|||
private fun setupRecyclerView() { |
|||
profileAdapter = ProfileAdapter( |
|||
onProfileClick = { profile -> selectProfile(profile) }, |
|||
onEditClick = { profile -> editProfile(profile) }, |
|||
onDeleteClick = { profile -> confirmDeleteProfile(profile) } |
|||
) |
|||
binding.listProfiles.apply { |
|||
layoutManager = LinearLayoutManager(requireContext()) |
|||
adapter = profileAdapter |
|||
} |
|||
} |
|||
|
|||
private fun loadProfiles() { |
|||
val profiles = mutableListOf<UserProfile>() |
|||
val userUUIDs = NativeLibrary.getAllUsers() ?: emptyArray() |
|||
val currentUserUUID = NativeLibrary.getCurrentUser() |
|||
|
|||
for (uuid in userUUIDs) { |
|||
if (uuid.isNotEmpty()) { |
|||
val username = NativeLibrary.getUserUsername(uuid) |
|||
if (!username.isNullOrEmpty()) { |
|||
val imagePath = NativeLibrary.getUserImagePath(uuid) ?: "" |
|||
profiles.add(UserProfile(uuid, username, imagePath)) |
|||
} |
|||
} |
|||
} |
|||
|
|||
profileAdapter.submitList(profiles) |
|||
profileAdapter.setCurrentUser(currentUserUUID ?: "") |
|||
|
|||
binding.buttonAddUser.isEnabled = NativeLibrary.canCreateUser() |
|||
} |
|||
|
|||
private fun selectProfile(profile: UserProfile) { |
|||
if (NativeLibrary.setCurrentUser(profile.uuid)) { |
|||
loadProfiles() |
|||
} |
|||
} |
|||
|
|||
|
|||
private fun editProfile(profile: UserProfile) { |
|||
val bundle = Bundle().apply { |
|||
putString("uuid", profile.uuid) |
|||
putString("username", profile.username) |
|||
} |
|||
findNavController().navigate(R.id.action_profileManagerFragment_to_newUserDialog, bundle) |
|||
} |
|||
|
|||
private fun confirmDeleteProfile(profile: UserProfile) { |
|||
val currentUser = NativeLibrary.getCurrentUser() |
|||
val isCurrentUser = profile.uuid == currentUser |
|||
|
|||
MaterialAlertDialogBuilder(requireContext()) |
|||
.setTitle(R.string.profile_delete_confirm_title) |
|||
.setMessage( |
|||
if (isCurrentUser) { |
|||
getString(R.string.profile_delete_current_user_message, profile.username) |
|||
} else { |
|||
getString(R.string.profile_delete_confirm_message, profile.username) |
|||
} |
|||
) |
|||
.setPositiveButton(R.string.profile_delete) { _, _ -> |
|||
deleteProfile(profile) |
|||
} |
|||
.setNegativeButton(android.R.string.cancel, null) |
|||
.show() |
|||
} |
|||
|
|||
private fun deleteProfile(profile: UserProfile) { |
|||
val currentUser = NativeLibrary.getCurrentUser() |
|||
if (!currentUser.isNullOrEmpty() && profile.uuid == currentUser) { |
|||
val users = NativeLibrary.getAllUsers() ?: emptyArray() |
|||
for (uuid in users) { |
|||
if (uuid.isNotEmpty() && uuid != profile.uuid) { |
|||
NativeLibrary.setCurrentUser(uuid) |
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (NativeLibrary.removeUser(profile.uuid)) { |
|||
loadProfiles() |
|||
} |
|||
} |
|||
|
|||
private fun setInsets() { |
|||
ViewCompat.setOnApplyWindowInsetsListener( |
|||
binding.root |
|||
) { _: View, windowInsets: WindowInsetsCompat -> |
|||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) |
|||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) |
|||
|
|||
val leftInsets = barInsets.left + cutoutInsets.left |
|||
val rightInsets = barInsets.right + cutoutInsets.right |
|||
|
|||
val fabLayoutParams = binding.buttonAddUser.layoutParams as ViewGroup.MarginLayoutParams |
|||
fabLayoutParams.leftMargin = leftInsets + 24 |
|||
fabLayoutParams.rightMargin = rightInsets + 24 |
|||
fabLayoutParams.bottomMargin = barInsets.bottom + 24 |
|||
binding.buttonAddUser.layoutParams = fabLayoutParams |
|||
|
|||
windowInsets |
|||
} |
|||
} |
|||
|
|||
override fun onDestroyView() { |
|||
super.onDestroyView() |
|||
NativeConfig.saveGlobalConfig() |
|||
_binding = null |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project |
|||
// SPDX-License-Identifier: GPL-3.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.model |
|||
|
|||
import android.os.Parcelable |
|||
import kotlinx.parcelize.Parcelize |
|||
|
|||
@Parcelize |
|||
data class UserProfile( |
|||
val uuid: String, |
|||
val username: String, |
|||
val imagePath: String = "" |
|||
) : Parcelable |
|||
|
|||
object ProfileUtils { |
|||
fun generateRandomUUID(): String { |
|||
val uuid = java.util.UUID.randomUUID() |
|||
return uuid.toString().replace("-", "") |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:width="24dp" |
|||
android:height="24dp" |
|||
android:viewportWidth="24" |
|||
android:viewportHeight="24"> |
|||
<path |
|||
android:fillColor="?attr/colorControlNormal" |
|||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,5c1.66,0 3,1.34 3,3s-1.34,3 -3,3s-3,-1.34 -3,-3S10.34,5 12,5zM12,19.2c-2.5,0 -4.71,-1.28 -6,-3.22c0.03,-1.99 4,-3.08 6,-3.08c1.99,0 5.97,1.09 6,3.08C16.71,17.92 14.5,19.2 12,19.2z" /> |
|||
</vector> |
|||
@ -0,0 +1,39 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<LinearLayout |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="vertical" |
|||
android:padding="16dp"> |
|||
|
|||
<FrameLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="300dp"> |
|||
|
|||
<androidx.recyclerview.widget.RecyclerView |
|||
android:id="@+id/grid_avatars" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:clipToPadding="false" /> |
|||
|
|||
<ProgressBar |
|||
android:id="@+id/progress_loading" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_gravity="center" /> |
|||
|
|||
<TextView |
|||
android:id="@+id/text_empty" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_gravity="center" |
|||
android:text="@string/profile_firmware_avatars_unavailable" |
|||
android:textAppearance="?attr/textAppearanceBodyMedium" |
|||
android:textColor="?attr/colorOnSurfaceVariant" |
|||
android:gravity="center" |
|||
android:padding="16dp" |
|||
android:visibility="gone" /> |
|||
|
|||
</FrameLayout> |
|||
|
|||
</LinearLayout> |
|||
@ -0,0 +1,226 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<androidx.coordinatorlayout.widget.CoordinatorLayout |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:background="?attr/colorSurface"> |
|||
|
|||
<com.google.android.material.appbar.AppBarLayout |
|||
android:id="@+id/appbar" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content"> |
|||
|
|||
<com.google.android.material.appbar.MaterialToolbar |
|||
android:id="@+id/toolbar_new_user" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="?attr/actionBarSize" |
|||
app:title="@string/profile_new_user" |
|||
app:navigationIcon="@drawable/ic_back" |
|||
app:titleCentered="false" /> |
|||
|
|||
</com.google.android.material.appbar.AppBarLayout> |
|||
|
|||
<androidx.core.widget.NestedScrollView |
|||
android:id="@+id/scroll_content" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:clipToPadding="false" |
|||
android:paddingBottom="88dp" |
|||
app:layout_behavior="@string/appbar_scrolling_view_behavior"> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="vertical" |
|||
android:paddingHorizontal="24dp" |
|||
android:paddingVertical="16dp"> |
|||
|
|||
<com.google.android.material.card.MaterialCardView |
|||
android:layout_width="128dp" |
|||
android:layout_height="128dp" |
|||
android:layout_gravity="center_horizontal" |
|||
android:layout_marginBottom="24dp" |
|||
style="@style/Widget.Material3.CardView.Elevated" |
|||
app:cardCornerRadius="64dp" |
|||
app:cardElevation="4dp"> |
|||
|
|||
<ImageView |
|||
android:id="@+id/image_user_avatar" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:scaleType="centerCrop" |
|||
android:contentDescription="@string/profile_avatar" |
|||
tools:src="@drawable/ic_account_circle" /> |
|||
|
|||
</com.google.android.material.card.MaterialCardView> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="vertical" |
|||
android:gravity="center" |
|||
android:layout_marginBottom="24dp"> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="horizontal" |
|||
android:gravity="center"> |
|||
|
|||
<com.google.android.material.button.MaterialButton |
|||
android:id="@+id/button_select_image" |
|||
android:layout_width="0dp" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:text="@string/profile_select_image" |
|||
style="@style/Widget.Material3.Button.TonalButton" |
|||
app:icon="@drawable/ic_add" |
|||
android:layout_marginEnd="4dp" /> |
|||
|
|||
<com.google.android.material.button.MaterialButton |
|||
android:id="@+id/button_firmware_avatars" |
|||
android:layout_width="0dp" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:text="@string/profile_firmware_avatars" |
|||
style="@style/Widget.Material3.Button.TonalButton" |
|||
app:icon="@drawable/ic_account_circle" |
|||
android:layout_marginStart="4dp" |
|||
android:visibility="gone" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
<com.google.android.material.button.MaterialButton |
|||
android:id="@+id/button_revert_image" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="8dp" |
|||
android:text="@string/profile_revert_image" |
|||
style="@style/Widget.Material3.Button.TextButton" |
|||
android:visibility="gone" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
<com.google.android.material.textfield.TextInputLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginBottom="24dp" |
|||
android:hint="@string/profile_username"> |
|||
|
|||
<com.google.android.material.textfield.TextInputEditText |
|||
android:id="@+id/edit_username" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:inputType="text" |
|||
android:maxLength="32" |
|||
android:maxLines="1" |
|||
android:minHeight="48dp" /> |
|||
|
|||
</com.google.android.material.textfield.TextInputLayout> |
|||
|
|||
<com.google.android.material.card.MaterialCardView |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginBottom="16dp" |
|||
style="@style/Widget.Material3.CardView.Filled" |
|||
app:cardCornerRadius="16dp"> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="vertical" |
|||
android:padding="16dp"> |
|||
|
|||
<TextView |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="@string/profile_uuid" |
|||
android:textAppearance="?attr/textAppearanceLabelMedium" |
|||
android:textColor="?attr/colorOnSurfaceVariant" |
|||
android:layout_marginBottom="8dp" /> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="horizontal" |
|||
android:gravity="center_vertical"> |
|||
|
|||
<TextView |
|||
android:id="@+id/text_uuid" |
|||
android:layout_width="0dp" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:textAppearance="?attr/textAppearanceBodyMedium" |
|||
android:fontFamily="monospace" |
|||
android:textIsSelectable="true" |
|||
tools:text="12345678-1234-1234-1234-123456789012" /> |
|||
|
|||
<com.google.android.material.button.MaterialButton |
|||
android:id="@+id/button_generate_uuid" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:text="@string/profile_generate" |
|||
style="@style/Widget.Material3.Button.TextButton" |
|||
app:icon="@drawable/ic_refresh" |
|||
app:iconGravity="textStart" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
<TextView |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="@string/profile_uuid_description" |
|||
android:textAppearance="?attr/textAppearanceBodySmall" |
|||
android:textColor="?attr/colorOnSurfaceVariant" |
|||
android:layout_marginTop="8dp" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
</com.google.android.material.card.MaterialCardView> |
|||
|
|||
</LinearLayout> |
|||
|
|||
</androidx.core.widget.NestedScrollView> |
|||
|
|||
<com.google.android.material.card.MaterialCardView |
|||
android:id="@+id/button_layout" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_gravity="bottom" |
|||
style="@style/Widget.Material3.CardView.Elevated" |
|||
app:cardCornerRadius="0dp" |
|||
app:cardElevation="8dp"> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="match_parent" |
|||
android:id="@+id/button_container" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="horizontal" |
|||
android:padding="16dp" |
|||
android:paddingBottom="24dp"> |
|||
|
|||
<com.google.android.material.button.MaterialButton |
|||
android:id="@+id/button_cancel" |
|||
android:layout_width="0dp" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:layout_marginEnd="8dp" |
|||
android:text="@android:string/cancel" |
|||
style="@style/Widget.Material3.Button.TonalButton" /> |
|||
|
|||
<com.google.android.material.button.MaterialButton |
|||
android:id="@+id/button_save" |
|||
android:layout_width="0dp" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:layout_marginStart="8dp" |
|||
android:text="@string/save" |
|||
style="@style/Widget.Material3.Button" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
</com.google.android.material.card.MaterialCardView> |
|||
|
|||
</androidx.coordinatorlayout.widget.CoordinatorLayout> |
|||
@ -0,0 +1,49 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<androidx.coordinatorlayout.widget.CoordinatorLayout |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:background="?attr/colorSurface" |
|||
android:fitsSystemWindows="true"> |
|||
|
|||
<com.google.android.material.appbar.AppBarLayout |
|||
android:id="@+id/appbar" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:fitsSystemWindows="true"> |
|||
|
|||
<com.google.android.material.appbar.MaterialToolbar |
|||
android:id="@+id/toolbar_profiles" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="?attr/actionBarSize" |
|||
app:title="@string/profile_manager" |
|||
app:navigationIcon="@drawable/ic_back" |
|||
app:titleCentered="false" /> |
|||
|
|||
</com.google.android.material.appbar.AppBarLayout> |
|||
|
|||
<androidx.recyclerview.widget.RecyclerView |
|||
android:id="@+id/list_profiles" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:clipToPadding="false" |
|||
android:paddingTop="8dp" |
|||
android:paddingBottom="96dp" |
|||
app:layout_behavior="@string/appbar_scrolling_view_behavior" |
|||
tools:listitem="@layout/list_item_profile" /> |
|||
|
|||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton |
|||
android:id="@+id/button_add_user" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_gravity="bottom|end" |
|||
android:layout_margin="24dp" |
|||
android:text="@string/profile_add_user" |
|||
android:contentDescription="@string/profile_add_user" |
|||
app:icon="@drawable/ic_add" |
|||
app:iconGravity="start" |
|||
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" /> |
|||
|
|||
</androidx.coordinatorlayout.widget.CoordinatorLayout> |
|||
@ -0,0 +1,21 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<com.google.android.material.card.MaterialCardView |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_margin="4dp" |
|||
style="@style/Widget.Material3.CardView.Elevated" |
|||
app:cardCornerRadius="12dp" |
|||
android:clickable="true" |
|||
android:focusable="true" |
|||
android:checkable="true"> |
|||
|
|||
<ImageView |
|||
android:id="@+id/image_avatar" |
|||
android:layout_width="72dp" |
|||
android:layout_height="72dp" |
|||
android:scaleType="centerCrop" |
|||
android:contentDescription="@string/profile_avatar" /> |
|||
|
|||
</com.google.android.material.card.MaterialCardView> |
|||
@ -0,0 +1,125 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<com.google.android.material.card.MaterialCardView |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginHorizontal="16dp" |
|||
android:layout_marginVertical="6dp" |
|||
style="@style/Widget.Material3.CardView.Filled" |
|||
app:cardCornerRadius="16dp" |
|||
android:clickable="true" |
|||
android:focusable="true"> |
|||
|
|||
<androidx.constraintlayout.widget.ConstraintLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:padding="12dp"> |
|||
|
|||
<com.google.android.material.card.MaterialCardView |
|||
android:id="@+id/avatar_container" |
|||
android:layout_width="56dp" |
|||
android:layout_height="56dp" |
|||
style="@style/Widget.Material3.CardView.Elevated" |
|||
app:cardCornerRadius="28dp" |
|||
app:cardElevation="1dp" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintTop_toTopOf="parent" |
|||
app:layout_constraintBottom_toBottomOf="parent"> |
|||
|
|||
<ImageView |
|||
android:id="@+id/image_avatar" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:scaleType="centerCrop" |
|||
tools:src="@drawable/ic_account_circle" |
|||
android:contentDescription="@string/profile_avatar" /> |
|||
|
|||
</com.google.android.material.card.MaterialCardView> |
|||
|
|||
<com.google.android.material.card.MaterialCardView |
|||
android:id="@+id/check_container" |
|||
android:layout_width="20dp" |
|||
android:layout_height="20dp" |
|||
android:visibility="gone" |
|||
style="@style/Widget.Material3.CardView.Filled" |
|||
app:cardBackgroundColor="?attr/colorPrimary" |
|||
app:cardCornerRadius="10dp" |
|||
app:cardElevation="2dp" |
|||
app:layout_constraintEnd_toEndOf="@id/avatar_container" |
|||
app:layout_constraintBottom_toBottomOf="@id/avatar_container" |
|||
tools:visibility="visible"> |
|||
|
|||
<ImageView |
|||
android:id="@+id/icon_check" |
|||
android:layout_width="14dp" |
|||
android:layout_height="14dp" |
|||
android:layout_gravity="center" |
|||
android:src="@drawable/ic_check" |
|||
app:tint="?attr/colorOnPrimary" |
|||
android:contentDescription="@string/profile_current_user" /> |
|||
|
|||
</com.google.android.material.card.MaterialCardView> |
|||
|
|||
<LinearLayout |
|||
android:id="@+id/text_container" |
|||
android:layout_width="0dp" |
|||
android:layout_height="wrap_content" |
|||
android:orientation="vertical" |
|||
android:layout_marginStart="12dp" |
|||
android:layout_marginEnd="8dp" |
|||
app:layout_constraintStart_toEndOf="@id/avatar_container" |
|||
app:layout_constraintEnd_toStartOf="@id/button_edit" |
|||
app:layout_constraintTop_toTopOf="@id/avatar_container" |
|||
app:layout_constraintBottom_toBottomOf="@id/avatar_container"> |
|||
|
|||
<TextView |
|||
android:id="@+id/text_username" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:textAppearance="?attr/textAppearanceTitleMedium" |
|||
android:maxLines="1" |
|||
android:ellipsize="end" |
|||
tools:text="User Name" /> |
|||
|
|||
<TextView |
|||
android:id="@+id/text_uuid" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="2dp" |
|||
android:textAppearance="?attr/textAppearanceBodySmall" |
|||
android:maxLines="1" |
|||
android:ellipsize="middle" |
|||
android:textColor="?attr/colorOnSurfaceVariant" |
|||
tools:text="12345678-1234-1234-1234-123456789012" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
<com.google.android.material.button.MaterialButton |
|||
android:id="@+id/button_edit" |
|||
android:layout_width="40dp" |
|||
android:layout_height="40dp" |
|||
style="@style/Widget.Material3.Button.IconButton" |
|||
app:icon="@drawable/ic_edit" |
|||
app:iconTint="?attr/colorOnSurfaceVariant" |
|||
app:layout_constraintEnd_toStartOf="@id/button_delete" |
|||
app:layout_constraintTop_toTopOf="parent" |
|||
app:layout_constraintBottom_toBottomOf="parent" |
|||
android:contentDescription="@string/profile_edit" /> |
|||
|
|||
<com.google.android.material.button.MaterialButton |
|||
android:id="@+id/button_delete" |
|||
android:layout_width="40dp" |
|||
android:layout_height="40dp" |
|||
style="@style/Widget.Material3.Button.IconButton" |
|||
app:icon="@drawable/ic_delete" |
|||
app:iconTint="?attr/colorError" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintTop_toTopOf="parent" |
|||
app:layout_constraintBottom_toBottomOf="parent" |
|||
android:contentDescription="@string/profile_delete" /> |
|||
|
|||
</androidx.constraintlayout.widget.ConstraintLayout> |
|||
|
|||
</com.google.android.material.card.MaterialCardView> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue