Skip to content

shinyelee/my-solo-life

Repository files navigation

신예리의 개발전기 Develography

커뮤니티 앱 프로젝트

develography_cover

시작

  • 블로그를 활용한 커뮤니티 앱 프로젝트입니다.

개발

기간

  • 22.08.02. ~ 22.09.21.

목표

  • 코틀린으로 주요 기능을 구현합니다.
  • Jetpack과 Firebase를 사용합니다.

사용

  • Kotlin
  • Jetpack
  • Firebase

기능

1. 인증(Firebase Auth)

auth

  • 1.1. 로그인/로그아웃
    • 유효성 검사 후 로그인합니다.
    • 비회원으로 로그인하면 회원 가입 절차 없이 로그인하는 대신, 한 번 로그아웃하면 같은 계정으로 다시 로그인할 수 없습니다.
    • 로그아웃하면 IntroActivity로 이동합니다.
// FBAuth.kt

        // 현재 사용자 uid 받아옴
        fun getUid(): String {

            // .getInstance() -> 데이터베이스에 접근할 수 있는 진입점
            auth = FirebaseAuth.getInstance()

            // 현재 사용자 uid를 문자열로 반환
            return auth.currentUser?.uid.toString()

        }

        // 현재 시간 받아옴
        fun getTime(): String {

            // 캘린더 인스턴스 생성
            val currentDateTime = Calendar.getInstance().time

            // SimpleDateFormat() -> 사용자가 임의로 표기 형식 지정 가능
            // Locale.KOREA -> 지역설정 한국
            val dateFormat = SimpleDateFormat("yy-MM-dd HH:mm", Locale.KOREA).format(currentDateTime)

            // 원하는 포맷 및 한국어로 시간 반환
            return dateFormat

        }
// LoginActivity.kt

        // 로그인 버튼 클릭하면
        binding.loginBtn.setOnClickListener {

            // 로그인 조건 확인
            var emailCheck = false
            var pwCheck = false

            // 이메일, 비밀번호
            val emailTxt = binding.email.text.toString()
            val pwTxt = binding.pw.text.toString()

            // 이메일 정규식
            val emailPattern = Patterns.EMAIL_ADDRESS

            // 이메일 검사
            if(emailTxt.isEmpty()) {
                emailCheck = false
                binding.emailArea.error = "이메일주소를 입력하세요"
            } else if(emailPattern.matcher(emailTxt).matches()) {
                emailCheck = true
                binding.emailArea.error = null
            } else {
                emailCheck = false
                binding.emailArea.error = "올바른 이메일이 아닙니다"
            }

            // 비밀번호 검사
            if(pwTxt.isEmpty()) {
                pwCheck = false
                binding.pwArea.error = "비밀번호를 입력하세요"
            } else if (pwTxt.length<6) {
                pwCheck = false
                binding.pwArea.error = "최소 6자 이상 입력하세요"
            } else if (pwTxt.length>20) {
                pwCheck = false
                binding.pwArea.error = "20자 이하로 입력하세요"
            } else {
                pwCheck = true
                binding.pwArea.error = null
            }

            // 로그인 조건을 모두 만족하면
            if(emailCheck and pwCheck) {

                // 로그인
                auth.signInWithEmailAndPassword(emailTxt, pwTxt)
                    .addOnCompleteListener(this) { task ->

                        // 성공하면
                        if (task.isSuccessful) {

                            // 명시적 인텐트 -> 다른 액티비티 호출
                            val intent = Intent(this, MainActivity::class.java)

                            // 메인 액티비티 시작
                            startActivity(intent)

                            // 로그인 액티비티 종료
                            finish()

                        // 실패하면
                        } else {

                            // 메시지 띄움
                            Toast.makeText(this, "이메일과 비밀번호를 다시 확인하세요", Toast.LENGTH_LONG).show()

                        }

                    }

            // 조건 불만족하면
            } else {

                // 메시지 띄움
                Toast.makeText(this, "이메일과 비밀번호를 다시 확인하세요", Toast.LENGTH_LONG).show()

            }

        }
// IntroActivity.kt

        // 비회원 버튼 클릭하면
        binding.guestBtn.setOnClickListener {

            // 익명으로 로그인
            auth.signInAnonymously()
                .addOnCompleteListener(this) { task ->

                    // 성공하면
                    if (task.isSuccessful) {

                        // 메인 액티비티 시작
                        val intent = Intent(this, MainActivity::class.java)
                        startActivity(intent)

                        // 인트로 액티비티 종료
                        finish()

                    // 실패하면
                    } else {

                        // 토스트 메시지 띄움
                        Toast.makeText(this, "비회원 로그인에 실패했습니다", Toast.LENGTH_LONG).show()

                    }

                }

        }
// MainActivity.kt

        // 로그아웃 버튼
        alertDialog.findViewById<ConstraintLayout>(R.id.logout)?.setOnClickListener {

            // 로그아웃 실행
            Firebase.auth.signOut()

            // 로그아웃 확인 메시지지
            Toast.makeText(this, "로그아웃 되었습니다", Toast.LENGTH_SHORT).show()

            // 명시적 인텐트 -> 다른 액티비티 호출
            val intent = Intent(this, IntroActivity::class.java)

            // 인트로 액티비티 시작
            startActivity(intent)

            // 메인 액티비티 종료
            finish()

        }
  • 1.2. 회원가입
    • 유효성 검사 후 가입합니다.
// JoinActivity.kt

        // 회원가입 버튼 클릭하면
        binding.joinBtn.setOnClickListener {

            // 회원가입 조건 확인
            var emailCheck = false
            var pwCheck = false
            var pw2Check = false

            // 이메일, 비밀번호, 비밀번호 확인
            val emailTxt = binding.email.text.toString()
            val pwTxt = binding.pw.text.toString()
            val pw2Txt = binding.pw2.text.toString()

            // 이메일 정규식
            val emailPattern = Patterns.EMAIL_ADDRESS

            // 이메일 검사
            if(emailTxt.isEmpty()) {
                emailCheck = false
                binding.emailArea.error = "이메일주소를 입력하세요"
            } else if(emailPattern.matcher(emailTxt).matches()) {
                emailCheck = true
                binding.emailArea.error = null
            } else {
                emailCheck = false
                binding.emailArea.error = "올바른 이메일이 아닙니다"
            }

            // 비밀번호 검사
            if(pwTxt.isEmpty()) {
                pwCheck = false
                binding.pwArea.error = "비밀번호를 입력하세요"
            } else if (pwTxt.length<6) {
                pwCheck = false
                binding.pwArea.error = "최소 6자 이상 입력하세요"
            } else if (pwTxt.length>20) {
                pwCheck = false
                binding.pwArea.error = "20자 이하로 입력하세요"
            } else {
                pwCheck = true
                binding.pwArea.error = null
            }

            // 공란 검사
            if(emailTxt.isEmpty() || pwTxt.isEmpty() || pw2Txt.isEmpty()) {
                emailCheck = false
                pwCheck = false
                pw2Check = false
            }

            // 비밀번호 일치 검사
            if(pwTxt == pw2Txt && pwTxt.isNotEmpty() && pw2Txt.isNotEmpty()) {
                pw2Check = true
                binding.pw2Area.error = null
            } else if(pwTxt == pw2Txt && pwTxt.isEmpty() && pw2Txt.isEmpty()) {
                pw2Check = false
                binding.pw2Area.error = "비밀번호를 한 번 더 입력하세요"
            } else {
                pw2Check = false
                binding.pw2Area.error = "비밀번호가 일치하지 않습니다"
            }

            // 회원가입 조건을 모두 만족하면
            if (emailCheck and pwCheck and pw2Check) {

                // 회원가입
                auth.createUserWithEmailAndPassword(emailTxt, pwTxt)
                    .addOnCompleteListener(this) { task ->

                        // 성공하면
                        if (task.isSuccessful) {

                            // 명시적 인텐트 -> 다른 액티비티 호출
                            val intent = Intent(this, MainActivity::class.java)

                            // 메인 액티비티 시작
                            startActivity(intent)

                            // 조인 액티비티 종료
                            finish()

                        // 조건 만족해도
                        } else {

                            // 가입 불가능한 경우가 있음
                            Toast.makeText(this, "회원가입에 실패했습니다", Toast.LENGTH_LONG).show()

                        }

                    }

            // 조건을 만족하지 못하면
            } else {

                // 가입 불가
                Toast.makeText(this, "회원가입에 실패했습니다", Toast.LENGTH_LONG).show()

            }

        }

2. 웹뷰(Web View)

webview

  • 2.1. 블로그 탭

    • 컨텐츠 등록시 Glide로 썸네일을 지정합니다.
    • 하얀 하트 아이콘을 클릭하면 하트가 주황색으로 바뀌며 컨텐츠가 북마크 탭에 추가됩니다.
    • 주황 하트 아이콘을 클릭하면 하트가 하얀색으로 바뀌며 컨텐츠가 북마크 탭에서 삭제됩니다.
    • 컨텐츠를 카테고리별로 분류해 웹뷰로 보여줍니다.
    • 뒤로가기 버튼을 두 번 클릭하면 웹뷰 창을 닫습니다.
  • 2.2. 깃허브 탭

    • shinyelee 계정의 깃허브 홈페이지를 보여줍니다.
    • 화면 하단 내비게이션 바를 통해 다른 탭으로 이동합니다.
// FBRef.kt

        // 블로그 카테고리
        val androidStudio = database.getReference("android_studio")
        val kotlinSyntax = database.getReference("kotlin_syntax")
        val errorWarning = database.getReference("error_warning")
        val vcsGithub = database.getReference("vcs_github")
        val webInternet = database.getReference("web_internet")

        // 북마크 목록
        val bookmarkRef = database.getReference("bookmark_list")

        // 게시글
        val boardRef = database.getReference("board")

        // 댓글
        val commentRef = database.getReference("comment")
// ContentsModel.kt

// 블로그 컨텐츠에 대한 정보를 데이터 모델 형태로 묶음
data class ContentsModel (

    // 블로그 컨텐츠의 제목
    var title : String = "",

    // 블로그 컨텐츠의 썸네일 이미지 url
    var imageUrl : String = "",

    // 블로그 컨텐츠의 본문 url
    var webUrl : String = ""

)
// ContentsRVAdapter.kt

    // 뷰홀더 객체 생성 및 초기화
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContentsRVAdapter.Viewholder {

        // 레이아웃 인플레이터 -> 리사이클러뷰에서 뷰홀더 만들 때 반복적으로 사용
        val v = LayoutInflater.from(parent.context).inflate(R.layout.contents_rv_item, parent, false)

        // 아직 데이터는 들어가있지 않은 껍데기
        return Viewholder(v)

    }

    // 뷰홀더 객체와 데이터를 연결
    override fun onBindViewHolder(holder: ContentsRVAdapter.Viewholder, position: Int) {

        // 껍데기(뷰홀더의 레이아웃)에 출력할 내용물(아이템 목록, 아이템의 키 목록)을 넣어줌
        holder.bindItems(items[position], keyList[position])

    }

    // 아이템들의 총 개수 반환
    override fun getItemCount(): Int = items.size

    // 각 아이템에 데이터 넣어줌
    inner class Viewholder(itemView : View) : RecyclerView.ViewHolder(itemView) {

        // 데이터 매핑(아이템, 아이템의 키)
        fun bindItems(item: ContentsModel, key: String) {

            // 리사이클러뷰는 setOnItemClickListener 없음 -> 개발자가 직접 구현해야 함

            // 아이템뷰(아이템 영역)를 클릭하면
            itemView.setOnClickListener {

                // 명시적 인텐트 -> 다른 액티비티 호출
                val intent = Intent(context, ContentsShowActivity::class.java)

                // 해당 아이템의 본문 url을 전달
                intent.putExtra("url", item.webUrl)

                // 컨텐츠쇼 액티비티 시작(웹뷰)
                itemView.context.startActivity(intent)

            }

            // 아이템의 제목 -> titleArea에 넣음
            contentsTitle.text = item.title

            // 아이템의 썸네일 -> 글라이드로 썸네일 이미지의 url을 imageViewArea에 넣음
            Glide.with(context)
                .load(item.imageUrl)
                .into(imageViewArea)

        }

    }
// ContentsListActivity.kt

        // 컨텐츠모델 형식의 아이템(=컨텐츠=제목+썸네일+본문) 목록
        val items = ArrayList<ContentsModel>()

        // 각 아이템의 키(=아이디) 목록 -> 북마크에 필요
        val keyList = ArrayList<String>()

        // 리사이클러뷰 어댑터 연결(컨텍스트, 아이템 목록, 키 목록, 북마크 아이디 목록)
        rvAdapter = ContentsRVAdapter(baseContext, items, keyList, bookmarkIdList)

        // 파이어베이스
        val database = Firebase.database

        // 카테고리
        val category = intent.getStringExtra("category")

        // .getReference() -> 데이터베이스의 루트 폴더 주소 값을 반환
        // 카테고리에 해당하는 데이터를 파이어베이스에서 가져옴
        when (category) {
            "android_studio" -> {
                myRef = database.getReference("android_studio")
            }
            "kotlin_syntax" -> {
                myRef = database.getReference("kotlin_syntax")
            }
            "error_warning" -> {
                myRef = database.getReference("error_warning")
            }
            "vcs_github" -> {
                myRef = database.getReference("vcs_github")
            }
            "web_internet" -> {
                myRef = database.getReference("web_internet")
            }
        }

        // 데이터베이스에서 컨텐츠의 세부정보를 검색
        val postListener = object : ValueEventListener {

            // 데이터 스냅샷
            @SuppressLint("NotifyDataSetChanged")
            override fun onDataChange(dataSnapshot: DataSnapshot) {

                // 데이터 스냅샷 내 데이터모델 형식으로 저장된
                for(dataModel in dataSnapshot.children) {

                    // 아이템을 받아
                    val item = dataModel.getValue(ContentsModel::class.java)

                    // 아이템 목록에 넣음
                    items.add(item!!)

                    // 키 값은 아이템 키 목록에 넣음
                    keyList.add(dataModel.key.toString())

                }

                // 동기화(새로고침) -> 리스트 크기 및 아이템 변화를 어댑터에 알림
                rvAdapter.notifyDataSetChanged()

            }

            // 오류 나면
            override fun onCancelled(databaseError: DatabaseError) {

                // 로그
                Log.w(TAG, "loadPost:onCancelled", databaseError.toException())

            }

        }

        // 파이어베이스 내 데이터의 변화(추가)를 알려줌
        myRef.addValueEventListener(postListener)

        // 리사이클러뷰 어댑터 연결
        val rv : RecyclerView = binding.rv
        rv.adapter = rvAdapter

        // 그리드 레이아웃 매니저 -> 아이템을 격자 형태로 배치(2열)
        rv.layoutManager = GridLayoutManager(this, 2)

        // 북마크 정보를 가져옴
        getBookmarkData()

        // 뒤로가기 버튼 -> 컨텐츠리스트 액티비티 종료
        binding.backBtn.setOnClickListener {
            finish()
        }
// ContentsShowActivity.kt

        // 해당 아이템(컨텐츠) 본문의 url을 얻어와서
        val getUrl = intent.getStringExtra("url")

        // 웹뷰에 넣음
        binding.webView.loadUrl(getUrl.toString())
// BlogFragment.kt

        // 안드로이드 아이콘 클릭하면
        binding.androidIcon.setOnClickListener {

            // 명시적 인텐트 -> 다른 액티비티 호출
            val intent = Intent(context, ContentsListActivity::class.java)

            // android_studio 카테고리로 데이터 넘겨줌
            intent.putExtra("category", "android_studio")

            // 컨텐츠리스트 액티비티 시작
            startActivity(intent)

        }

        // 코틀린 아이콘 -> kotlin_syntax
        binding.kotlinIcon.setOnClickListener {
            val intent = Intent(context, ContentsListActivity::class.java)
            intent.putExtra("category", "kotlin_syntax")
            startActivity(intent)
        }

        // 중략
// WebFragment.kt

        // 웹뷰에 깃허브 링크 넣음
        binding.webView.loadUrl("https://github.com/shinyelee")

3. 게시판(CRUD)

board_write

  • 3.1. 게시글 CRUD
    • 게시글 작성시 Glide로 이미지를 추가합니다.
    • 게시글 쓰기/읽기/수정/삭제 가능합니다.
    • 내가 쓴 게시글만 수정/삭제 버튼을 보여줍니다.
// BoardModel.kt

// 게시글에 대한 정보를 데이터 모델 형태로 묶음
data class BoardModel (

    // 게시글 제목
    var title : String = "",

    // 게시글 본문
    var main : String = "",

    // 작성자 uid
    var uid : String = "",

    // 작성 시간
    var time : String = ""

)
// BoardFragment.kt

// 리스트뷰 어댑터 연결(게시글 목록)
        boardLVAdapter = BoardLVAdapter(boardList)

        // 리스트뷰 어댑터 연결
        val lv : ListView = binding.boardLV
        lv.adapter = boardLVAdapter

        // 모든 게시글 정보를 가져옴
        getBoardListData()

        // 파이어베이스의 게시글 키를 기반으로 게시글 데이터(=제목+본문+uid+시간) 받아옴
        lv.setOnItemClickListener { parent, view, position, id ->

            // 명시적 인텐트 -> 다른 액티비티 호출
            val intent = Intent(context, BoardReadActivity::class.java)

            // 글읽기 액티비티로 게시글의 키 값 전달
            intent.putExtra("key", boardKeyList[position])

            // 글읽기 액티비티 시작
            startActivity(intent)

        }

    // 모든 게시글 정보를 가져옴
    private fun getBoardListData() {

        // 데이터베이스에서 컨텐츠의 세부정보를 검색
        val postListener = object : ValueEventListener {

            // 데이터 스냅샷
            @SuppressLint("NotifyDataSetChanged")
            override fun onDataChange(dataSnapshot: DataSnapshot) {

                // 게시글 목록 비움
                // -> 저장/삭제 마다 데이터 누적돼 게시글 중복으로 저장되는 것 방지
                boardList.clear()

                // 데이터 스냅샷 내 데이터모델 형식으로 저장된
                for(dataModel in dataSnapshot.children) {

                    // 로그
                    Log.d(TAG, "getBoardListData $dataModel")

                    // 아이템(=게시글)
                    val item = dataModel.getValue(BoardModel::class.java)

                    // 게시글 목록에 아이템 넣음
                    boardList.add(item!!)

                    // 게시글 키 목록에 문자열 형식으로 변환한 키 넣음
                    boardKeyList.add(dataModel.key.toString())

                }
                // getPostData()와 달리 반복문임 -> 아이템'들'

                // 게시글 키 목록을 역순으로 출력
                boardKeyList.reverse()
                Log.d(TAG, "getBoardListData - boardKeyList $boardKeyList")

                // 게시글 목록도 역순 출력
                boardList.reverse()

                // 동기화(새로고침) -> 리스트 크기 및 아이템 변화를 어댑터에 알림
                boardLVAdapter.notifyDataSetChanged()

            }

            // 오류 나면
            override fun onCancelled(databaseError: DatabaseError) {

                // 로그
                Log.w(TAG, "loadPost:onCancelled", databaseError.toException())

            }

        }

        // 파이어베이스 내 데이터의 변화(추가)를 알려줌
        FBRef.boardRef.addValueEventListener(postListener)

    }
// BoardLVAdapter.kt

// boardList -> 아이템(=게시글=제목+본문+uid+시간) 목록
class BoardLVAdapter(val boardList : MutableList<BoardModel>) : BaseAdapter() {

    // 아이템 총 개수 반환
    override fun getCount(): Int = boardList.size

    // 아이템 반환
    override fun getItem(position: Int): Any = boardList[position]

    // 아이템의 아이디 반환
    override fun getItemId(position: Int): Long = position.toLong()

    // 아이템을 표시할 뷰 반환
    @SuppressLint("ViewHolder")
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {

        var view = convertView

        // 레이아웃 인플레이터 -> 리사이클러뷰에서 뷰홀더 만들 때 반복적으로 사용
        view = LayoutInflater.from(parent?.context).inflate(R.layout.board_lv_item, parent, false)

        // 각 아이템뷰의 제목/본문/시간 영역에
        val title = view?.findViewById<TextView>(R.id.titleArea)
        val time = view?.findViewById<TextView>(R.id.timeArea)

        // 제목, 시간 넣음
        title!!.text = boardList[position].title
        time!!.text = boardList[position].time

        // 현재 사용자가 작성한 글만 따로 표시하기 위해
        val myBoardBadge = view?.findViewById<TextView>(R.id.myBoardBadge)

        // 게시글 작성자의 uid와 현재 사용자의 uid가 일치하면 배지가 보이도록 처리
        myBoardBadge?.isVisible = boardList[position].uid.equals(FBAuth.getUid())

        // 뷰 반환
        return view!!

    }

}
// BoardWriteActivity.kt

    // 작성한 글을 등록
    private fun setBoard(key: String) {

        // 게시글의 데이터(제목, 본문, uid, 시간)
        val title = binding.titleArea.text.toString()
        val main = binding.mainArea.text.toString()
        val uid = FBAuth.getUid()
        val time = FBAuth.getTime()

        // 키 값 하위에 데이터 넣음
        FBRef.boardRef
            .child(key)
            .setValue(BoardModel(title, main, uid, time))

        // 카메라 아이콘을 클릭했다면 이미지 업로드
        if (isImageUpload == true) {

            // 이미지 파일명을 아무렇게나 설정하면 해당 게시글과 매칭하기 어려움
            // -> 키 값과 똑같이 설정하면 해결
            imageUpload("$key.png")

        }
        // 카메라 아이콘을 클릭하지 않음 -> 이미지를 업로드하지 않음

        // 등록 확인 메시지 띄움
        Toast.makeText(this, "게시글이 등록되었습니다", Toast.LENGTH_SHORT).show()

        // 글쓰기 액티비티 종료
        finish()

    }

    // 게시글에 이미지 첨부
    private fun imageUpload(key: String) {

        // Cloud Storage에 파일을 업로드하려면
        val storage = Firebase.storage

        // -> 우선 파일 이름을 포함하여 파일의 전체 경로를 가리키는 참조를 만듦
        val storageRef = storage.reference

        // 임의의 게시글 하나와 그 게시글 내 이미지 하나를 쉽게 매칭하려면
        // -> DB 내 게시글 키 값과 첨부한 이미지 이름이 똑같으면 됨
        val testRef = storageRef.child(key)

        // 적절한 참조를 만들었으면
        binding.imageArea.isDrawingCacheEnabled = true
        binding.imageArea.buildDrawingCache()
        val bitmap = (binding.imageArea.drawable as BitmapDrawable).bitmap
        val baos = ByteArrayOutputStream()
        bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos)
        val data = baos.toByteArray()

        // -> put어쩌고() 메서드를 호출하여 Cloud Storage에 파일을 업로드
        var uploadTask = testRef.putBytes(data)
        uploadTask.addOnFailureListener {
            Log.d(TAG, "imageUpload() failed")
        }.addOnSuccessListener { taskSnapshot ->
            // 성공
        }

    }

    // startActivityForResult와 세트
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if(resultCode == RESULT_OK && requestCode == 100) {
            binding.imageArea.setImageURI(data?.data)
        }
    }
// BoardReadActivity.kt

        // 게시판 프래그먼트에서 게시글의 키 값을 받아옴
        key = intent.getStringExtra("key").toString()

        // 게시글 키 값을 바탕으로 게시글 하나의 정보를 가져옴
        getBoardData(key)
        getImageData(key)
        getCommentListData(key)

        // 게시글 설정 버튼
        binding.boardSettingBtn.setOnClickListener {

            // 명시적 인텐트 -> 다른 액티비티 호출
            val intent = Intent(this, BoardEditActivity::class.java)

            // 키 값을 바탕으로 게시글 받아옴
            intent.putExtra("key", key)

            // 글수정 액티비티 시작
            startActivity(intent)

        }

    // 게시글에 첨부된 이미지 정보를 가져옴
    private fun getImageData(key: String) {

        // 이미지 파일 경로
        val storageReference = Firebase.storage.reference.child("$key.png")

        // 이미지 넣을 곳
        val imgDown = binding.imageArea

        // 글라이드로 이미지 다운로드
        storageReference.downloadUrl.addOnCompleteListener( { task ->

            // 이미지 첨부
            if(task.isSuccessful) {
                Glide.with(this)
                    .load(task.result)
                    .into(imgDown)
            // 첨부 이미지 없으면 imageArea 안 보이게 처리
            } else {
                binding.imageArea.isVisible = false
            }

        })

    }

    // 게시글 하나의 정보를 가져옴
    private fun getBoardData(key: String) {

        // 데이터베이스에서 컨텐츠의 세부정보를 검색
        val postListener = object : ValueEventListener {

            // 데이터 스냅샷
            @SuppressLint("NotifyDataSetChanged")
            override fun onDataChange(dataSnapshot: DataSnapshot) {

                // 예외 처리
                try {

                    // 데이터 스냅샷 내 데이터모델 형식으로 저장된 아이템(=게시글)
                    val item = dataSnapshot.getValue(BoardModel::class.java)

                    // 제목, 시간, 본문 해당 영역에 넣음
                    binding.titleArea.text = item!!.title
                    binding.timeArea.text = item.time
                    binding.mainArea.text = item.main

                    // 게시글 작성자와 현재 사용자의 uid를 비교해
                    val writerUid = item.uid
                    val myUid = FBAuth.getUid()

                    // 작성자가 사용자면 수정 버튼 보임
                    binding.boardSettingBtn.isVisible = writerUid.equals(myUid)

                // 오류 나면
                } catch (e: Exception) {

                    // 로그
                    Log.d(TAG, "getBoardData 확인")

                }

            }
            // getBoardListData()와 달리 반복문이 아님 -> '단일' 아이템

            // 오류 나면
            override fun onCancelled(databaseError: DatabaseError) {

                // 로그
                Log.w(TAG, "loadPost:onCancelled", databaseError.toException())

            }

        }

        // 파이어베이스 내 데이터의 변화(추가)를 알려줌
        FBRef.boardRef.child(key).addValueEventListener(postListener)

    }
// BoardEditActivity.kt

        // 글읽기 프래그먼트에서 게시글의 키 값을 받아옴
        key = intent.getStringExtra("key").toString()

        // 키 값을 바탕으로 게시글 하나의 정보를 가져옴
        getBoardData(key)

        // 키 값을 바탕으로 게시글에 첨부된 이미지 정보를 가져옴
        getImageData(key)

        // 수정하기 버튼 -> 키 값을 바탕으로 불러온 게시글 수정
        binding.boardEditBtn.setOnClickListener { editBoardData(key) }

        // 삭제하기 버튼 -> 키 값을 바탕으로 불러온 댓글 삭제
        binding.boardDeleteBtn.setOnClickListener { deleteBoardData(key) }

    // 게시글을 삭제
    private fun deleteBoardData(key: String) {

        // 게시글 삭제
        FBRef.boardRef.child(key).removeValue()

        // 삭제 확인 메시지
        Toast.makeText(this, "게시글이 삭제되었습니다", Toast.LENGTH_SHORT).show()

        // 게시글수정 액티비티 종료
        finish()

    }

    // 게시글을 수정
    private fun editBoardData(key: String) {

        // 수정한 값으로 업데이트
        FBRef.boardRef.child(key).setValue(BoardModel(

            // 제목 및 본문은 직접 수정한 내용으로,
            binding.titleArea.text.toString(),
            binding.mainArea.text.toString(),

            // uid와 시간은 자동 설정됨
            FBAuth.getUid(),
            FBAuth.getTime()

        ))

        // 수정 확인 메시지
        Toast.makeText(this, "게시글이 수정되었습니다", Toast.LENGTH_SHORT).show()

        // 글수정 액티비티 종료
        finish()

   }

    // 게시글에 첨부된 이미지 정보를 가져옴
    private fun getImageData(key: String) {

        // 중복코드 생략

    }

    // 게시글 하나의 정보를 가져옴
    private fun getBoardData(key: String) {

        // 중복코드 생략
        
    }

board_comment

  • 3.2. 댓글 CRUD
    • 댓글 쓰기/읽기/수정/삭제 가능합니다(22.08.31. 업데이트).
    • 내가 쓴 댓글만 수정/삭제 버튼 버튼을 보여줍니다(22.08.26. 업데이트).
// CommentModel.kt

// 게시글에 대한 정보를 데이터 모델 형태로 묶음
data class CommentModel (

    // 댓글 내용
    var main : String = "",

    // 작성자 uid
    var uid : String = "",

    // 작성 시간
    var time : String = ""

)
// BoardReadActivity.kt

        // 리스트뷰 어댑터 연결(댓글 목록)
        commentLVAdapter = CommentLVAdapter(commentList)
        val cLV : ListView = binding.commentLV
        cLV.adapter = commentLVAdapter

        // 댓글 목록(리스트뷰)
        cLV.setOnTouchListener(object : View.OnTouchListener {

            // 리스트뷰를 터치했을 때
            @SuppressLint("ClickableViewAccessibility")
            override fun onTouch(p0: View?, p1: MotionEvent?): Boolean {

                // 스크롤뷰(화면 전체)의 터치 이벤트를 막으면 -> 리스트뷰(댓글 영역)의 스크롤뷰가 작동함
                binding.boardReadSV.requestDisallowInterceptTouchEvent(true)
                return false

            }

        })

        // 게시판 프래그먼트에서 게시글의 키 값을 받아옴
        key = intent.getStringExtra("key").toString()

        // 게시글 키 값을 바탕으로 게시글 하나의 정보를 가져옴
        getBoardData(key)
        getImageData(key)
        getCommentListData(key)

        // 게시글 설정 버튼
        binding.boardSettingBtn.setOnClickListener {

            // 명시적 인텐트 -> 다른 액티비티 호출
            val intent = Intent(this, BoardEditActivity::class.java)

            // 키 값을 바탕으로 게시글 받아옴
            intent.putExtra("key", key)

            // 글수정 액티비티 시작
            startActivity(intent)

        }

        // 파이어베이스의 댓글 키를 기반으로 댓글 데이터(=본문+uid+시간) 받아옴
        cLV.setOnItemClickListener { parent, view, position, id ->

            // 명시적 인텐트 -> 다른 액티비티 호출
            val intent = Intent(baseContext, CommentEditActivity::class.java)

            // 댓글수정 액티비티로 댓글의 키 값 전달
            intent.putExtra("key", key)

            // 댓글수정 액티비티로 댓글의 키 값 전달
            intent.putExtra("commentKey", commentKeyList[position])

            // 댓글수정 액티비티 시작
            startActivity(intent)

        }

        // 댓글쓰기 버튼
        binding.commentBtn.setOnClickListener {

            // -> 작성한 댓글을 등록
            setComment(key)

        }

    }

    // 댓글 목록 정보 가져옴
    private fun getCommentListData(key: String) {

        // 데이터베이스에서 컨텐츠의 세부정보를 검색
        val postListener = object : ValueEventListener {

            // 데이터 스냅샷
            @SuppressLint("NotifyDataSetChanged")
            override fun onDataChange(dataSnapshot: DataSnapshot) {

                // 댓글 목록 비움
                // -> 저장/삭제 마다 데이터 누적돼 게시글 중복으로 저장되는 것 방지
                commentList.clear()

                // 데이터 스냅샷 내 데이터모델 형식으로 저장된
                for(dataModel in dataSnapshot.children) {

                    // 로그
                    Log.d(TAG, dataModel.toString())

                    // 아이템(=댓글)
                    val item = dataModel.getValue(CommentModel::class.java)

                    // 댓글 목록에 아이템 넣음
                    commentList.add(item!!)

                    // 댓글 키 목록에 문자열 형식으로 변환한 키 넣음
                    commentKeyList.add(dataModel.key.toString())

                }
                // 반복문임 -> 아이템'들'

                // 댓글 키 목록을 출력
                commentKeyList

                // 댓글 목록도 출력
                commentList

                // 댓글 헤더에 댓글 개수 출력
                binding.commentCountText.text = commentList.count().toString()

                // 동기화(새로고침) -> 리스트 크기 및 아이템 변화를 어댑터에 알림
                commentLVAdapter.notifyDataSetChanged()

            }

            // 오류 나면
            override fun onCancelled(databaseError: DatabaseError) {

                // 로그
                Log.w(TAG, "loadPost:onCancelled", databaseError.toException())

            }

        }

        // 파이어베이스 내 데이터의 변화(추가)를 알려줌
        FBRef.commentRef.child(key).addValueEventListener(postListener)

    }

    // 작성한 댓글을 등록
    private fun setComment(key: String) {

        // 댓글의 데이터(본문, uid, 시간)
        val main = binding.commentMainArea.text.toString()
        val uid = FBAuth.getUid()
        val time = FBAuth.getTime()

        // 키 값 하위에 데이터 넣음
        FBRef.commentRef
            .child(key)
            .push()
            .setValue(CommentModel(main, uid, time))

        // 등록 확인 메시지 띄움
        Toast.makeText(this, "댓글이 등록되었습니다", Toast.LENGTH_SHORT).show()

        // 댓글 입력란 비움
        binding.commentMainArea.text = null

    }
// CommentLVAdapter.kt

    // 아이템 총 개수 반환
    override fun getCount(): Int = commentList.size

    // 아이템 반환
    override fun getItem(position: Int): Any = commentList[position]

    // 아이템의 아이디 반환
    override fun getItemId(position: Int): Long = position.toLong()

    // 아이템을 표시할 뷰 반환
    @SuppressLint("ViewHolder", "ClickableViewAccessibility")
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {

        var view = convertView

        // 레이아웃 인플레이터 -> 리사이클러뷰에서 뷰홀더 만들 때 반복적으로 사용
        view = LayoutInflater.from(parent?.context).inflate(R.layout.comment_lv_item, parent, false)

        // 각 아이템뷰의 본문, 시간 영역에
        val commentMain = view?.findViewById<TextView>(R.id.commentMainArea)
        val commentTime = view?.findViewById<TextView>(R.id.commentTimeArea)

        // 본문, 시간 넣음
        commentMain!!.text = commentList[position].main
        commentTime!!.text = commentList[position].time

        // 댓글 작성자의 uid와 현재 사용자의 uid가 일치하면 댓글 배지가 보이도록 처리
        val myCommentBadge = view?.findViewById<TextView>(R.id.myCommentBadge)
        myCommentBadge?.isVisible = commentList[position].uid.equals(FBAuth.getUid())

        // 댓글 세팅 버튼도 동일함
        val commentSettingBtn = view?.findViewById<ImageView>(R.id.commentSettingBtn)
        commentSettingBtn?.isVisible = commentList[position].uid.equals(FBAuth.getUid())

        // 댓글 작성자의 uid와 현재 사용자의 uid가 다르면 터치 막음
        val commentLVItem = view?.findViewById<LinearLayout>(R.id.commentLVItemLayout)
        if(commentList[position].uid != FBAuth.getUid()) {
            commentLVItem?.setOnTouchListener(View.OnTouchListener { v, event -> true })
        }

        // 뷰 반환
        return view!!

    }
// CommentEditActivity.kt

        // 게시판 프래그먼트에서 게시글의 키 값을 받아옴
        key = intent.getStringExtra("key").toString()

        // 글읽기 액티비티에서 댓글의 키 값을 받아옴
        commentKey = intent.getStringExtra("commentKey").toString()

        // 댓글 키 값을 바탕으로 댓글 하나의 정보를 가져옴
        getCommentData(key, commentKey)

        // 수정하기 버튼 -> 키 값을 바탕으로 불러온 댓글 수정
        binding.commentEditBtn.setOnClickListener { editCommentData(key, commentKey) }

        // 삭제하기 버튼 -> 키 값을 바탕으로 불러온 댓글 삭제
        binding.commentDeleteBtn.setOnClickListener { deleteCommentData(key, commentKey) }

    // 댓글을 삭제
    private fun deleteCommentData(key: String, commentKey: String) {

        // 댓글 삭제
        FBRef.commentRef.child(key).child(commentKey).removeValue()

        // 삭제 확인 메시지
        Toast.makeText(this, "댓글이 삭제되었습니다", Toast.LENGTH_SHORT).show()

        // 댓글수정 액티비티 종료
        finish()

    }

    // 댓글을 수정
    private fun editCommentData(key: String, commentKey: String) {

        // 수정한 값으로 업데이트
        FBRef.commentRef.child(key).child(commentKey).setValue(CommentModel(

            // 제목 및 본문은 직접 수정한 내용으로,
            binding.commentMainArea.text.toString(),

            // uid와 시간은 자동 설정됨
            FBAuth.getUid(),
            FBAuth.getTime()

        ))

        // 수정 확인 메시지
        Toast.makeText(this, "댓글이 수정되었습니다", Toast.LENGTH_SHORT).show()

        // 댓글수정 액티비티 종료
        finish()

    }

    // 댓글 하나의 정보를 가져옴
    private fun getCommentData(key: String, commentKey: String) {

        // 데이터베이스에서 컨텐츠의 세부정보를 검색
        val postListener = object : ValueEventListener {

            // 데이터 스냅샷
            @SuppressLint("NotifyDataSetChanged")
            override fun onDataChange(dataSnapshot: DataSnapshot) {

                // 예외 처리
                try {

                    // 데이터 스냅샷 내 데이터모델 형식으로 저장된 아이템(=게시글)
                    val item = dataSnapshot.getValue(CommentModel::class.java)

                    // 본문 해당 영역에 넣음(작성자 및 시간은 직접 수정하지 않음)
                    binding.commentMainArea.setText(item?.main)
                    // textView -> .text
                    // editText -> .setText(집어넣을 데이터)

                    // 오류 나면
                } catch (e: Exception) {

                    // 로그
                    Log.e(TAG, "getBoardData 확인")

                }

            }
            // getCommentListData()와 달리 반복문이 아님 -> '단일' 아이템

            // 오류 나면
            override fun onCancelled(databaseError: DatabaseError) {

                // 로그
                Log.w(TAG, "loadPost:onCancelled", databaseError.toException())

            }

        }

        // 파이어베이스 내 데이터의 변화(추가)를 알려줌
        FBRef.commentRef.child(key).child(commentKey).addValueEventListener(postListener)

    }

4. 북마크

bookmark

  • 하얀 하트 아이콘을 클릭하면 하트가 주황색으로 바뀌며 컨텐츠가 북마크 탭에 추가됩니다.
  • 주황 하트 아이콘을 클릭하면 하트가 하얀색으로 바뀌며 컨텐츠가 북마크 탭에서 삭제됩니다.
// ContentsRVAdapter.kt

            // 각 아이템뷰의 제목/썸네일/북마크(하트) 영역
            val contentsTitle = itemView.findViewById<TextView>(R.id.titleArea)
            val imageViewArea = itemView.findViewById<ImageView>(R.id.imageArea)
            val bookmarkArea = itemView.findViewById<ImageView>(R.id.bookmarkArea)

            // -> 북마크아이디리스트(=북마크된 아이템의 키 목록)에 화면에 표시된 아이템의 키 정보가 포함되면
            if(bookmarkIdList.contains(key)) {

                // 해당 아이템의 하트 -> 주황색
                bookmarkArea.setImageResource(R.drawable.bookmark56)

            // 포함되지 않으면
            } else {

                // 하트 -> 하얀색
                bookmarkArea.setImageResource(R.drawable.bookmark56w)

            }

            // 하트 클릭하면
            bookmarkArea.setOnClickListener {

                // bookmark_list 하위에 사용자 uid별로 나눠 게시글의 키 값을 저장

                // 이미 북마크 된 상태
                if(bookmarkIdList.contains(key)) {

                    // -> 북마크 삭제
                    FBRef.bookmarkRef
                        .child(FBAuth.getUid())
                        .child(key)
                        .removeValue()

                // 아직 북마크 안 된 상태
                } else {

                    // -> 북마크 저장
                    FBRef.bookmarkRef
                        .child(FBAuth.getUid())
                        .child(key)
                        .setValue(BookmarkModel(true))

                }

            }
// ContentsListActiviti.kt

    // 북마크 정보를 가져옴
    private fun getBookmarkData() {

        // 데이터베이스에서 컨텐츠의 세부정보를 검색
        val postListener = object : ValueEventListener {

            // 데이터 스냅샷
            @SuppressLint("NotifyDataSetChanged")
            override fun onDataChange(dataSnapshot: DataSnapshot) {

                // .clear() -> 북마크 아이디 목록 비움(저장/삭제 마다 데이터 누적되는 것 방지)
                bookmarkIdList.clear()

                // 데이터 스냅샷 내 데이터모델 형식으로 저장된
                for(dataModel in dataSnapshot.children) {

                    // 북마크 아이디 목록에 아이템의 키 값을 넣음
                    bookmarkIdList.add(dataModel.key.toString())

                }

                // 동기화(새로고침) -> 리스트 크기 및 아이템 변화를 어댑터에 알림
                rvAdapter.notifyDataSetChanged()

            }

            // 오류 나면
            override fun onCancelled(databaseError: DatabaseError) {

                // 로그
                Log.w(TAG, "loadPost:onCancelled", databaseError.toException())

            }

        }

        // 파이어베이스 내 데이터 변화(추가)를 알려줌
        FBRef.bookmarkRef.child(FBAuth.getUid()).addValueEventListener(postListener)

    }
// BookmarkModel.kt

// 컨텐츠의 북마크 여부를 데이터 모델 형태로 묶음
data class BookmarkModel (

    // boolean 값이 true -> 북마크 된 컨텐츠
    val bookmarkOn : Boolean? = null

)
// BookmarkRVAdapter.kt

    // 뷰홀더 객체 생성 및 초기화
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookmarkRVAdapter.Viewholder {

        // 레이아웃 인플레이터 -> 리사이클러뷰에서 뷰홀더 만들 때 반복적으로 사용
        val v = LayoutInflater.from(parent.context).inflate(R.layout.contents_rv_item, parent, false)

        // 아직 데이터는 들어가있지 않은 껍데기
        return Viewholder(v)

    }

    // 뷰홀더 객체와 데이터를 연결
    override fun onBindViewHolder(holder: BookmarkRVAdapter.Viewholder, position: Int) {

        // 껍데기(뷰홀더의 레이아웃)에 출력할 내용물(아이템 목록, 아이템의 키 목록)을 넣어줌
        holder.bindItems(items[position], keyList[position])

    }

    // 아이템들의 총 개수 반환
    override fun getItemCount(): Int = items.size

    // 각 아이템에 데이터 넣어줌
    inner class Viewholder(itemView : View) : RecyclerView.ViewHolder(itemView) {

        // 데이터 매핑(아이템, 아이템의 키)
        fun bindItems(item: ContentsModel, key: String) {

            // 리사이클러뷰는 setOnItemClickListener 없음 -> 개발자가 직접 구현해야 함

            // 아이템뷰(아이템 영역)를 클릭하면
            itemView.setOnClickListener {

                // 명시적 인텐트 -> 다른 액티비티 호출
                val intent = Intent(context, ContentsShowActivity::class.java)

                // 해당 아이템의 본문 url을 전달
                intent.putExtra("url", item.webUrl)

                // 컨텐츠쇼 액티비티 시작(웹뷰)
                itemView.context.startActivity(intent)

            }

            // 각 아이템뷰의 제목/썸네일/북마크(하트) 영역
            val contentsTitle = itemView.findViewById<TextView>(R.id.titleArea)
            val imageViewArea = itemView.findViewById<ImageView>(R.id.imageArea)
            val bookmarkArea = itemView.findViewById<ImageView>(R.id.bookmarkArea)

            // -> 북마크아이디리스트(=북마크된 아이템의 키 목록)에 화면에 표시된 아이템의 키 정보가 포함되면
            if(bookmarkIdList.contains(key)) {

                // 해당 아이템의 하트 -> 주황색
                bookmarkArea.setImageResource(R.drawable.bookmark56)

            // 포함되지 않으면
            } else {

                // 하트 -> 하얀색
                bookmarkArea.setImageResource(R.drawable.bookmark56w)

            }

            // 하트 클릭하면
            bookmarkArea.setOnClickListener {

                // bookmark_list 하위에 사용자 uid별로 나눠 게시글의 키 값을 저장

                // 이미 북마크 된 상태
                if(bookmarkIdList.contains(key)) {

                    // -> 북마크 삭제
                    FBRef.bookmarkRef
                        .child(FBAuth.getUid())
                        .child(key)
                        .removeValue()

                // 아직 북마크 안 된 상태
                } else {

                    // -> 북마크 저장
                    FBRef.bookmarkRef
                        .child(FBAuth.getUid())
                        .child(key)
                        .setValue(BookmarkModel(true))

                }

            }

            // 아이템의 제목 -> titleArea에 넣음
            contentsTitle.text = item.title

            // 아이템의 썸네일 -> 글라이드로 썸네일 이미지의 url을 imageViewArea에 넣음
            Glide.with(context)
                .load(item.imageUrl)
                .into(imageViewArea)

        }

    }
// BookmarkFragment.kt

    // 블로그 탭의 모든 컨텐츠 가져옴
    private fun getBlogData() {

        // 데이터베이스에서 게시물의 세부정보를 검색
        val postListener = object : ValueEventListener {

            // 데이터 스냅샷
            @SuppressLint("NotifyDataSetChanged")
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                
                // 데이터 스냅샷 내 데이터모델 형식으로 저장된
                for(dataModel in dataSnapshot.children) {

                    // 모든 컨텐츠(키, 본문 url, 썸네일 url, 제목) 출력
                    Log.d(TAG, dataModel.toString())

                    // 아이템을 받아
                    val item = dataModel.getValue(ContentsModel::class.java)

                    // 북마크 아이디 목록에 키가 포함(북마크 저장)된 경우만
                    if(bookmarkIdList.contains(dataModel.key.toString())) {

                        // 아이템을 아이템 목록에 넣음
                        items.add(item!!)

                        // 키 값은 아이템 키 목록에 넣음
                        keyList.add(dataModel.key.toString())

                    }

                }

                // 동기화(새로고침) -> 리스트 크기 및 아이템 변화를 어댑터에 알림
                rvAdapter.notifyDataSetChanged()

            }

            // 오류 나면
            override fun onCancelled(databaseError: DatabaseError) {

                // 로그
                Log.w(ContentValues.TAG, "loadPost:onCancelled", databaseError.toException())

            }

        }

        // 파이어베이스 내 카테고리별 컨텐츠 데이터의 변화(추가)를 알려줌
        FBRef.androidStudio.addValueEventListener(postListener)
        FBRef.kotlinSyntax.addValueEventListener(postListener)
        FBRef.errorWarning.addValueEventListener(postListener)
        FBRef.vcsGithub.addValueEventListener(postListener)
        FBRef.webInternet.addValueEventListener(postListener)

    }

DB 설계(Firebase Realtime Database)

db_all

  • DB 전체

contents_table contents_table2

  • android_studio, bookmark_list, error_warning, kotlin_syntax, vcs_github, web_internet(카테고리별 컨텐츠)

board_table

  • board(게시판)

comment_table

  • comment(댓글)

bookmark_table

  • bookmark_list(북마크)

피드백

문제점

  1. 댓글 수정/삭제 불가.
  2. 댓글 개수 미구현.
  3. 스크롤뷰 내 리스트뷰 삽입으로 인한 댓글 출력 오류.

개선점

  1. 댓글 수정/삭제 기능 추가(22.08.31. 업데이트).
  2. 게시글 내부에 댓글 개수 표시(22.09.21. 업데이트). 추후 게시글 목록에서도 댓글 개수가 보이도록 할 것.
  3. 댓글 스크롤 오류 수정(22.09.28. 업데이트).

저작권

이 프로젝트는 MIT 라이센스에 따라 라이센스가 부여됩니다. 자세한 내용은 LICENSE.md 파일을 참조하십시오.


레퍼런스

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages