iT邦幫忙

2021 iThome 鐵人賽

DAY 8
0
Mobile Development

30天建立寵物約散App-Android新手篇系列 第 8

【Day8】EditProfileFragment X Storage上傳照片

在昨天的Profile頁面中,我們可以看到有照片的訊息,那我們今天主要要來做的就是~ 把手機相簿裡面的照片傳上去Firebase的 Storage,並且要轉換成下次可以download的格式!

長成這樣!
https://ithelp.ithome.com.tw/upload/images/20210923/20138017A0MJTVYjyR.png

1.建立EditProfileFragment

繼承BaseFragment,順便把databinding設定好

2.接下來來看layout

dimen

<dimen name="edit_image_margin_top">50dp</dimen>

新增 string

	<string name="hint_enter_your_area">請輸入您的居住區域</string>
    <string name="toolbar_title_edit_profile">修改資料</string>
    <string name="man">男性</string>
    <string name="female">女性</string>
	<string name="edit_user_detail_successful">修改資料完成!</string>
	<string name="update_user_profile_successful">上傳圖片成功!</string>
    <string name="hint_select_your_image">請選取照片!</string>

layout一樣直接貼

<?xml version="1.0" encoding="utf-8"?>


<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto">




    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".ui.fragment.EditProfileFragment">


        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar_edit_profile_fragment"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:background="@color/light_pewter_blue">

            <TextView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:text="@string/toolbar_title_edit_profile"
                android:textSize="@dimen/toolbar_textSize"
                android:gravity="center"
                android:textStyle="bold"
                android:textColor="@color/white">

            </TextView>

        </androidx.appcompat.widget.Toolbar>

        <ImageView
            android:id="@+id/iv_edit_profile_image"
            android:layout_width="@dimen/user_image_width_height"
            android:layout_height="@dimen/user_image_width_height"
            app:layout_constraintTop_toBottomOf="@id/toolbar_edit_profile_fragment"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginTop="@dimen/edit_image_margin_top">

        </ImageView>

        <ImageView
            android:id="@+id/iv_edit_profile_camera"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_baseline_photo_camera_24"
            app:layout_constraintBottom_toBottomOf="@id/iv_edit_profile_image"
            app:layout_constraintEnd_toEndOf="@id/iv_edit_profile_image">

        </ImageView>

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/tip_edit_profile_fragment_name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginStart="@dimen/tip_margin_start_end"
            android:layout_marginEnd="@dimen/tip_margin_start_end"
            android:layout_marginTop="@dimen/tip_margin_top_bottom"
            app:layout_constraintTop_toBottomOf="@id/iv_edit_profile_camera"
            style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">

            <com.example.petsmatchingapp.utils.JFEditText
                android:id="@+id/ed_edit_profile_name"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:padding="@dimen/edText_padding"
                android:textSize="@dimen/edText_textSize"
                android:hint="@string/hint_enter_your_name"/>

        </com.google.android.material.textfield.TextInputLayout>



        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/tip_edit_fragment_area"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginStart="@dimen/tip_margin_start_end"
            android:layout_marginEnd="@dimen/tip_margin_start_end"
            android:layout_marginTop="@dimen/tip_margin_top_bottom"
            app:layout_constraintTop_toBottomOf="@id/tip_edit_profile_fragment_name"
            style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">

            <com.example.petsmatchingapp.utils.JFEditText
                android:id="@+id/ed_edit_area"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:padding="@dimen/edText_padding"
                android:textSize="@dimen/edText_textSize"
                android:hint="@string/hint_enter_your_area">

            </com.example.petsmatchingapp.utils.JFEditText>

        </com.google.android.material.textfield.TextInputLayout>


        <RadioGroup
            android:id="@+id/radiogp_edit_profile_fragment"
            android:layout_width="match_parent"
            android:layout_height="40dp"
            app:layout_constraintTop_toBottomOf="@id/tip_edit_fragment_area"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginStart="@dimen/tip_margin_start_end"
            android:layout_marginEnd="@dimen/tip_margin_start_end"
            android:orientation="horizontal"
            android:layout_marginTop="20dp">

            <com.example.petsmatchingapp.utils.JFRadioButton
                android:id="@+id/rb_man"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:button="@null"
                android:text="@string/man"
                android:layout_marginEnd="3dp"
                android:gravity="center"
                android:textColor="@color/white"
                android:background="@drawable/radio_button_background">

            </com.example.petsmatchingapp.utils.JFRadioButton>


            <com.example.petsmatchingapp.utils.JFRadioButton
                android:id="@+id/rb_female"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:layout_marginEnd="3dp"
                android:button="@null"
                android:text="@string/female"
                android:gravity="center"
                android:textColor="@color/white"
                android:background="@drawable/radio_button_background">

            </com.example.petsmatchingapp.utils.JFRadioButton>

        </RadioGroup>




        <com.example.petsmatchingapp.utils.JFButton
            android:id="@+id/btn_edit"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/tip_margin_start_end"
            android:layout_marginEnd="@dimen/tip_margin_start_end"
            app:layout_constraintTop_toBottomOf="@id/radiogp_edit_profile_fragment"
            android:text="@string/edit"
            android:background="@drawable/button_background"
            android:foreground="?attr/selectableItemBackground"
            android:textColor="@android:color/white"
            android:layout_marginTop="20dp">

        </com.example.petsmatchingapp.utils.JFButton>


    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

好的,接下來我們會看到有幾個紅字,沒關係,我們來解決它!

先從最簡單的開始,我們發現我們需要一個相機的圖像,所以我們透過new → vector asset,來新增一個camera的圖像。

再過來,我們有一個RadioButton,首先我們透過之前自定義的方式,來新增我們的JFRadioButton!

可能會有人好奇,為什麼我們已經有了Button,為什麼要新增RadioButton,因為RadioButton是單選選項,我們等等也會新增按下按鈕之後的樣式,好讓我們使用者更能夠知道自己現在是按哪一個按鈕!

class JFRadioButton(context: Context, attributeSet: AttributeSet): AppCompatRadioButton(context,attributeSet) {

    init {
        applyFont()
    }

    private fun applyFont() {
        val typeface: Typeface = Typeface.createFromAsset(context.assets,"jfopenhuninn.ttf")
        setTypeface(typeface)
    }
}

好的! 接下來我們還有一個需要解決的就是background的問題
https://ithelp.ithome.com.tw/upload/images/20210923/201380175ijdo1P38K.png

我們需要當今天使用者點下某個Radiobutton之後,能夠有樣式的改變,不然我們不知道自己點了什麼就尷尬了~

所以我們就跟之前做button的background一樣,我們要去 new一個drawable,但是這次我們不用 shape,我們在Root element的時候我們選擇 selector

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">

	//當按鈕被按得時候
    <item android:state_checked="true">
        <shape android:shape="rectangle">
            //圓形角度
			<corners android:radius="10dp"/>
            //漸層
			<gradient android:startColor="@color/light_pewter_blue"
             android:endColor="@color/pewter_blue"/>

			//邊框顏色
           <stroke android:color="@color/status_color"
                   //邊框寬度
				   android:width="1dp">

           </stroke>
        </shape>

    </item>


    <item android:state_checked="false">
        <shape android:shape="rectangle">
            <corners android:radius="10dp"/>
            <gradient android:startColor="@color/light_pewter_blue"
                      android:endColor="@color/pewter_blue"/>

        </shape>

    </item>


</selector>

3.新增User的欄位

如果dataclass裡面的資料,後來發現還需要新增某些欄位呢? 那之前的資料怎麼辦呢? 沒關係,我們透過update的功能,就可以不用讓user重新辦帳號了!

直接到 User,並且新增一個欄位,並給他初始值

data class User(
    val id: String = "",
    val name: String = "",
    val email: String = "",
    val password: String = "",
    val image: String = "",
    val gender: String = "",
	//新增這行
    val area: String =  "",
    val profileCompleted: Boolean = false
)

4.觀察livedata,並且顯示

跟昨天的文章一樣,我們透過從 Firestore拿到的資料,並且存在livedata,我們去觀察這個livedata是否有資料,如果有的話,就顯示在EditText欄位

首先

private val accountViewModel: AccountViewModel by sharedViewModel()

來 onCreateViwe新增觀察

accountViewModel.userDetail.observe(viewLifecycleOwner, Observer {
			//傳入url跟imageView
            Constant.loadUserImage(it.image,binding.ivEditProfileImage)
		    //因為我們初始值是"",所以我們要判定假如它不是初始值才需要顯示
			if (it.area != ""){
                binding.edEditArea.setText(it.area)
            }
            binding.edEditProfileName.setText(it.name)
如果userDetail裡面的資料是男生,我們就讓男生的RadioButton亮起來,反之亦然
            if (it.gender == Constant.MAN){
                binding.rbMan.isChecked = true
            }else{
                binding.rbFemale.isChecked = true
            }

        })

發現我們有紅字,所以我們要到 Constant來新增

新增在Constant,可以幫助我們在update資料時,跟拿資料時,不會拼字拼錯。減少一些不必要的錯誤。

const val MAN: String = "man"
const val FEMALE: String = "female"

5.從ProfileFragment導航到 EditProfileFragment

到 mobile_nav來新增我們剛創好的Fragment,並且連連看從ProfileFragment→EditProfileFragment,及EditProfileFragment→ProfileFragment

再過來回到ProfileFragment,我們需要新增點擊事件! 我們在 onClick裡面新增

binding.btnProfileFragmentGoEdit ->{
findNavController().navigate(R.id.action_profileFragment_to_editProfileFragment)
}

別忘了要在 onCreateView新增

binding.btnProfileFragmentGoEdit.setOnClickListener(this)

好的,那我們接下來就可以看到我們的資料啦,曾先生,以及因為現在area跟image沒有資料,所以我們都是預設值。

https://ithelp.ithome.com.tw/upload/images/20210923/20138017WmL528c9Ch.png

6.update資料

接下來我們要用AccountViewModel來update資料,我們一樣到AccountViewModel來新增以下的funtion

//傳入 HashMap,HashMap 可以讓我們根據key來塞入值
fun updateUserDetailToFireStore(mHashMap: HashMap<String,Any>, fragment: EditProfileFragment ){

        getCurrentUID()?.let {
            FirebaseFirestore.getInstance().collection(Constant.USER)
                .document(it)
                    .update(mHashMap)
                    .addOnSuccessListener {
                        fragment.editUserDetailSuccessful()
                    }
                    .addOnFailureListener {
                        fragment.editUserDetailFail(it.toString())
                    }
        }

    }

我們發現有幾個紅字,讓我們來解決它,我們回到 EditProfileFragement,並新增以下

fun editUserDetailSuccessful(){
hideDialog()
showSnackBar(resources.getString(R.string.edit_user_detail_successful),false)
//這邊記得要在拿一次UserDetail,不然我們的livedata不會更新,也就不會顯示新的資料
accountViewModel.getUserDetail()
}

fun editUserDetailFail(e: String){
hideDialog()
showSnackBar(e,false)
}

好的! 接下來我們首先要先完成上傳自己相簿的照片

步驟是,首先先把圖片傳到Storage,然後再把它改成改成下載的格式,再把它傳入Firestore

7.上傳照片

7.1.權限開啟

想當然耳,開啟我們相簿是非常隱私的事情,如果我們帥氣的自拍照不需要我們同意就可以讀取,那是多麼恐怖的一件事啊?

邀請權限會是長這樣

https://ithelp.ithome.com.tw/upload/images/20210923/20138017zGSred64Vu.png

我們先去 manifest的新增以下權限

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

那我們還是需要讓user去同意,所以我們設定當今天按下 camera的圖片時,會先確認是否有權限,如果有才會執行去拿相簿,沒有就詢問

private fun checkPermission(){

		//check是否有權限,有的話就進相簿
        if (ContextCompat.checkSelfPermission(requireActivity(),android.Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED){
        resultLauncher.launch(
         Intent(Intent.ACTION_PICK,MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
            )
        }else{
			/沒的話就去確認requestPermission
            requestPermission()
        }
    }

我們發現有幾個地方是紅字,所以我們要來解決它

我們先來新增要求權限

private fun requestPermission(){
        ActivityCompat.requestPermissions(requireActivity(), arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE),Constant.REQUEST_CODE_READ)
    }
  • 第一個參數我們丟activity
  • 第二個參數我們丟permission,也就是我們在manifest剛剛放的權限
  • 第三個參數我們丟requestCode,這個是讓我們可以在override onPermissionReques這個funtion時候,可以判別的requestCode,因為這個是需要Int型態,所以我們要在Constant新增一組
//這個是值是可以自訂的
const val REQUEST_CODE_READ = 1001

7.2.launch 讀取相簿的Intent

目前onActivityResult已經棄用了,所以我們要更改成,首先我們在 class 下面先新增註冊一個ActivityResult,再到想要發出 Intent的地方,透過這個resultLauncher來啟動Intent。(也就是會在剛剛的checkPermission() 裡面)


//大致上概念是跟onActivityResult一樣,得到的值會是ActivityResult,因為我們Launch的是相簿的Intent,所以我們拿到的是Uri 

private val resultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){ uri ->
        if (uri.resultCode == Activity.RESULT_OK){
			//一樣透過data.data去拿到值
            val selectedUri = uri.data?.data
            if (selectedUri != null){
				//選完圖片後,直接顯示在我們的畫面上
                Constant.loadUserImage(selectedUri,binding.ivEditProfileImage)
				//傳入Storage
        accountViewModel.saveImageToFireStorage(requireActivity(),this,selectedUri)
            }
        }
    }

7.3.傳入Storage

確認是否有implement FireStorage

implementation 'com.google.firebase:firebase-storage-ktx'

來到AccountViewModel,新增

//傳入acitivty,讓我們等等可以拿到檔案的的格式
fun saveImageToFireStorage(activity: Activity, fragment: EditProfileFragment,uri: Uri){

//這邊我們要先拿一個 Storage的Reference,並且後面的child我們把它指定成,user_image + 時間 + 檔案的type
 val sdf: StorageReference = FirebaseStorage.getInstance().reference.child(Constant.USER_IMAGE + System.currentTimeMillis() + Constant.getFileExtension(activity, uri))
        //直接用 putFile就可以上傳檔案了
		sdf.putFile(uri)
                .addOnSuccessListener {
//上傳成功後可以拿到 UploadTask.TaskSnapshot,我們要把它改成可以下載的Url
                    it.metadata?.reference?.downloadUrl
//成功後,我們再把它傳入到 fragment,並且一起upda
                        ?.addOnSuccessListener {
                            fragment.saveImageSuccessful(it)
                        }
                        ?.addOnFailureListener { 
                            fragment.saveImageFail(it.toString())
                        }
                }
                .addOnFailureListener {
                    fragment.editUserDetailFail(it.toString())
                }

    }

再過來去 Constant 新增以下


//指定 child的時候,新增敘述讓我們了解是user照片,還是之後會上傳的pet照片。
const val USER_IMAGE: String = "user_image"

//利用 MimeTypeMap去拿出圖片uri的格式
fun getFileExtension(activity: Activity, uri: Uri): String?{
return MimeTypeMap.getSingleton().getExtensionFromMimeType(activity.contentResolver.getType(uri))
    

回到EditProfileFragment新增以下

fun saveImageSuccessful(uri: Uri){
showSnackBar(resources.getString(R.string.update_user_profile_successful),false)
//把它指定到我們的 mUri
mUri = uri.toString()

}

fun saveImageFail(e: String){
showSnackBar(e,true)
}

我們在class 下方新增以下,讓我們可以賦值,一開始是null,然後再 saveImageSuccessful() 被呼叫時,我們把它賦予新值

var mUri: String? = null

7.4.確認資料格式

private fun validDataForm(): Boolean{
        return when{

            TextUtils.isEmpty(binding.edEditProfileName.text.toString().trim())  -> {
            showSnackBar(resources.getString(R.string.hint_enter_your_name),true)
                return false
            }
            TextUtils.isEmpty(binding.edEditArea.text.toString().trim()) -> {
             showSnackBar(resources.getString(R.string.hint_enter_your_area),true)
                return false
            }
//我這邊是設定一定要有選取照片,不然不給過,但是實際時可以自己決定要不要有圖片
 mUri.isNullOrBlank() && accountViewModel.userDetail.value?.image == "" -> {
 showSnackBar(resources.getString(R.string.hint_select_your_image),true)
                return false
            }
            else -> true
            
        }
        
    }

接下來我們來Constant來新增以下,好讓我們不會有寫錯字導致沒有match成功的可能性

	const val NAME: String = "name"
    const val AREA: String = "area"
    const val GENDER: String = "gender"
    const val IMAGE: String = "image"
    const val PROFILE_COMPLETED: String = "profileCompleted"

7.5.上傳到 Firestore!

回到onClick

override fun onClick(v: View?) {
       when(v){
           binding.ivEditProfileCamera ->{
				//check我們的相簿讀取權限
               checkPermission()
           }
					
           binding.btnEdit ->{
				//確認格式
               if (validDataForm()){
                   showDialog(resources.getString(R.string.please_wait))
					//我們透過key對value的方式來更新資料
                   val mHashMap = HashMap<String,Any>()
                   mHashMap[Constant.NAME] = binding.edEditProfileName.text.toString().trim()
                   //確認當今天mUri不是null時才把它放進 HashMap
					mUri?.let {
                       mHashMap[Constant.IMAGE] = it
                   }
                   var gender = ""
                  //判斷user點選的RadioButton
				if (binding.rbMan.isChecked){
                       gender = Constant.MAN
                   }else{
                       gender = Constant.FEMALE
                   }
									
                   mHashMap[Constant.GENDER] = gender
				   //把Profile_completed改成 true	
                   mHashMap[Constant.PROFILE_COMPLETED] = true
                       mHashMap[Constant.AREA]= binding.edEditArea.text.toString().trim()
                   accountViewModel.updateUserDetailToFireStore(mHashMap,this)

               }
           }
       }
    }

到 onCreateView新增以下

		binding.btnEdit.setOnClickListener(this)
        binding.ivEditProfileCamera.setOnClickListener(this)

新增返回鍵就結束這一回合!

binding.toolbarEditProfileFragment.setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
binding.toolbarEditProfileFragment.setNavigationOnClickListener { 
            requireActivity().onBackPressed()
        }

成功如下啦!!

Day8.editProfile.finsih
慘了,自己打完之後覺得有點害羞0.0

上傳圖片可以先去 Storage確認一下
https://ithelp.ithome.com.tw/upload/images/20210923/20138017s8VvvXatU1.png

來看個 FireStore
https://ithelp.ithome.com.tw/upload/images/20210923/20138017WSAZFbkalU.png


上一篇
【Day7】BottomNavigation X ProfileFragment
下一篇
【Day9】AddInvitationFragment(上)
系列文
30天建立寵物約散App-Android新手篇30

尚未有邦友留言

立即登入留言