지난 메인화면에 이은 게시판 기능을 구현하였다.
초기 디자인한 기능에서 설명 추가와 운동영상 기능은 삭제하였다.
구현하고자 하는 기능은 다음과 같다.
1. 제목을 키워드로하는 검색
2. 글 추가
3. 제목, 작성자, 사진, 글 내용 나타내기
4. 게시글에 대한 댓글 기능 구현
게시글 화면
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".BoardFragment">
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<ImageView
android:id="@+id/searchButton"
android:layout_width="54dp"
android:layout_height="68dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_search" />
</FrameLayout>
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="자유 게시판"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 실선을 추가하는 View -->
<View
android:id="@+id/line"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="8dp"
android:background="@android:color/darker_gray"
app:layout_constraintTop_toBottomOf="@id/textView" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/line"
app:layout_constraintBottom_toTopOf="@id/nav_view"
/>
<ImageView
android:id="@+id/contentWriteBtn"
android:layout_width="59dp"
android:layout_height="53dp"
android:src="@drawable/write_btn"
app:layout_constraintBottom_toBottomOf="@id/textView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/textView" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="?android:attr/windowBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:menu="@menu/bottom_navigation_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>
게시글 화면
검색기능
+ 이모티콘을 눌렀을 때
사진을 추가하면 미리보기를 나타내려고 하였으나 화면 레이아웃 구성에 어려움을 느껴 포기하였다.
글 추가 기능은 길어서 따로 작성하였다. BoardWriteActivity.kt
package com.example.bodygym
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.database.FirebaseDatabase
import com.google.firebase.database.DataSnapshot
import com.google.firebase.database.DatabaseError
import com.google.firebase.database.ValueEventListener
import com.google.firebase.storage.FirebaseStorage
import com.google.firebase.storage.StorageReference
import java.io.Serializable
import java.util.*
import android.Manifest
class BoardwriteActivity : AppCompatActivity() {
data class ContentModel(
val postId: String,
val title: String,
val content: String,
val writer: String,
val imageUrl : String? = null,
val videoUrl: String? = null // videoUrl 프로퍼티 추가
) : Serializable
private lateinit var auth: FirebaseAuth // FirebaseAuth 인스턴스 생성
private lateinit var storage: FirebaseStorage
private lateinit var storageReference: StorageReference
private var filePath: Uri? = null
private val PICK_IMAGE_REQUEST = 71
private val PICK_VIDEO_REQUEST = 72
private var fileType: String? = null
companion object {
private const val MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_content_write)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE)
}
auth = FirebaseAuth.getInstance() // FirebaseAuth 인스턴스 초기화
storage = FirebaseStorage.getInstance()
storageReference = storage.reference
val titleArea: EditText = findViewById(R.id.titleArea)
val contentArea: EditText = findViewById(R.id.contentArea)
val writeBtn: Button = findViewById(R.id.writeBtn)
val cancelBtn: Button = findViewById(R.id.cancelBtn) // '취소' 버튼에 대한 참조를 생성
val chooseBtn: Button = findViewById(R.id.chooseVideoBtn) // '동영상 선택' 버튼에 대한 참조를 생성
val chooseImageBtn: Button = findViewById(R.id.chooseImageBtn) // '사진 선택' 버튼에 대한 참조를 생성
val chooseVideoBtn: Button = findViewById(R.id.chooseVideoBtn) // '동영상 선택' 버튼에 대한 참조를 생성
chooseBtn.setOnClickListener {
chooseVideo()
}
writeBtn.setOnClickListener {
val title = titleArea.text.toString()
val content = contentArea.text.toString()
val userId = auth.currentUser?.uid // 현재 로그인한 사용자의 uid를 가져옵니다.
if (userId != null) {
fetchNickname(userId) { nickname ->
uploadFile { fileUrl -> // 파일 업로드
val ref = FirebaseDatabase.getInstance().getReference("posts")
val postId = ref.push().key // Firebase에서 자동으로 유일한 key를 생성합니다.
if (postId != null && nickname != null) {
val post = if(fileType == "image") { // fileType에 따라 imageUrl 또는 videoUrl에 파일 URL 저장
ContentModel(postId, title, content, nickname, imageUrl=fileUrl)
} else {
ContentModel(postId, title, content, nickname, videoUrl=fileUrl)
}
ref.child(postId).setValue(post).addOnCompleteListener {
val intent = Intent(this, HomeActivity::class.java)
startActivity(intent)
finish()
}
}
}
}
}
}
chooseImageBtn.setOnClickListener {
chooseImage()
}
chooseVideoBtn.setOnClickListener {
chooseVideo()
}
// '취소' 버튼에 클릭 리스너를 설정
cancelBtn.setOnClickListener {
finish() // 현재 액티비티를 종료
}
}
private fun chooseImage() {
val intent = Intent()
intent.type = "image/*"
intent.action = Intent.ACTION_GET_CONTENT
startActivityForResult(Intent.createChooser(intent, "Select Picture"), PICK_IMAGE_REQUEST)
}
private fun fetchNickname(userId: String, callback: (nickname: String?) -> Unit) {
val db = FirebaseDatabase.getInstance().getReference("Users") // Users가 사용자 정보를 저장하는 노드라고 가정
db.child(userId).child("nickname").addListenerForSingleValueEvent(object :
ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
val nickname = dataSnapshot.getValue(String::class.java)
callback(nickname) // 닉네임을 콜백 함수로 반환
}
override fun onCancelled(databaseError: DatabaseError) {
callback(null)
}
})
}
private fun chooseVideo() {
val intent = Intent()
intent.type = "video/*"
intent.action = Intent.ACTION_GET_CONTENT
startActivityForResult(Intent.createChooser(intent, "Select Video"), PICK_VIDEO_REQUEST)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if(requestCode == PICK_IMAGE_REQUEST && resultCode == Activity.RESULT_OK
&& data != null && data.data != null )
{
filePath = data.data
fileType = "image"
}
else if(requestCode == PICK_VIDEO_REQUEST && resultCode == Activity.RESULT_OK
&& data != null && data.data != null )
{
filePath = data.data
fileType = "video"
}
}
private fun uploadFile(callback: (fileUrl: String?) -> Unit) {
if(filePath != null && fileType != null) {
val ref = storageReference.child("$fileType/" + UUID.randomUUID().toString())
ref.putFile(filePath!!)
.addOnSuccessListener {
ref.downloadUrl.addOnSuccessListener {
callback(it.toString())
}
}
.addOnFailureListener {
callback(null)
}
} else {
callback(null)
}
}
}
게시글 세부사항
닉네임이 '관리자' 또는 '관장님'인 경우에 모든 글을 수정 및 삭제할 수 있게 하였다.
일반 회원의 경우 작성자와 본인의 닉네임이 같아야만 즉, 내가 작성한 글이여만 수정 및 삭제가 가능하다.
댓글 또한 동일하다.
이미지는 클릭하면 확대되는 것처럼 이미지가 커지고 나머지 부분은 블라인드 처리하였다.
글 추가를 제외한 기능들을 구현한 BoardFragment.kt
package com.example.bodygym
import SettingFragment
import android.content.ContentValues.TAG
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.firebase.database.*
data class Comment(
var id : String? = "",
var userId: String? = null,
var text: String? = null,
var timestamp: Long? = null,
var author: String? = null
)
data class Post(
var content: String? = null,
var imageUrl: String? = null,
var videoUrl : String ? = null,
var postId: String? = null,
var title: String? = null,
var writer: String? = null,
var timeStamp: Long? = null // 게시글 생성 시간
)
class Board {
private val database = FirebaseDatabase.getInstance()
private val myRef = database.getReference("posts")
fun getAllPosts(): DatabaseReference {
return myRef
}
// 기타 Post 관련 메서드들...
}
class BoardAdapter(private var posts: MutableList<Post>) : RecyclerView.Adapter<BoardAdapter.ViewHolder>(), ChildEventListener {
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view)
init {
FirebaseDatabase.getInstance().getReference("posts").addChildEventListener(this)
}
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
val newPost = snapshot.getValue(Post::class.java)?.apply {
postId = snapshot.key // Firebase 스냅샷의 키를 게시글 ID로 설정
}
newPost?.let {
posts.add(it)
notifyItemInserted(posts.size - 1)
}
}
override fun onChildRemoved(snapshot: DataSnapshot) {
// 데이터 삭제가 감지될 때의 동작을 여기에 작성합니다.
}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {
// 데이터 이동이 감지될 때의 동작을 여기에 작성합니다.
}
override fun onCancelled(error: DatabaseError) {
// 데이터 읽기가 취소될 때의 동작을 여기에 작성합니다.
Log.w(TAG, "loadPost:onCancelled", error.toException())
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.post_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val post = posts[position]
// 제목 설정
holder.itemView.findViewById<TextView>(R.id.detailTitleTextView).text = post.title
// 작성자 설정
holder.itemView.findViewById<TextView>(R.id.authorTextView).text = post.writer
// 클릭 리스너 설정
holder.itemView.setOnClickListener {
val context = holder.view.context
val intent = Intent(context, DetailActivity::class.java).apply {
putExtra("postid", post.postId) // 'postId'는 Post 모델의 고유 ID 속성을 나타냅니다.
}
context.startActivity(intent)
}
}
override fun getItemCount() = posts.size
fun updatePosts(newPosts: MutableList<Post>) {
this.posts.clear() // 기존의 데이터를 삭제합니다.
this.posts.addAll(newPosts) // 새로운 데이터를 추가합니다.
notifyDataSetChanged() // 데이터가 변경되었음을 어댑터에 알립니다.
}
fun filterPosts(query: String) {
val filteredPosts = posts.filter { it.title?.contains(query) ?: false } // 'title'을 기준으로 필터링하였습니다. 필요에 따라 변경 가능합니다.
this.posts.clear()
this.posts.addAll(filteredPosts)
notifyDataSetChanged()
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {
val changedPost = snapshot.getValue(Post::class.java)
changedPost?.let { post ->
val index = posts.indexOfFirst { it.postId == post.postId }
if (index != -1) {
posts[index] = post
notifyItemChanged(index) // 변경된 아이템에 대한 업데이트만 알립니다.
}
}
}
// onChildChanged, onChildRemoved, onChildMoved, onCancelled 메서드들도 구현해야 합니다.
}
class BoardFragment : Fragment() {
private lateinit var bottomNavigationView: BottomNavigationView
private lateinit var imageViewQr: ImageView
private val board = Board()
private lateinit var recyclerView: RecyclerView
private lateinit var writeBtn: ImageView
private lateinit var adapter: BoardAdapter
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.activity_content_list, container, false)
val searchButton = view.findViewById<ImageView>(R.id.searchButton)
searchButton.setOnClickListener {
val context = context
if (context != null) {
val builder = AlertDialog.Builder(context)
builder.setTitle("제목으로 게시물 검색")
val input = EditText(context)
builder.setView(input)
builder.setPositiveButton("검색") { dialog, _ ->
val searchQuery = input.text.toString()
adapter.filterPosts(searchQuery)
dialog.dismiss()
}
builder.setNegativeButton("취소") { dialog, _ -> dialog.cancel() }
builder.show()
} else {
Log.e(TAG, "Context is null")
}
}
recyclerView = view.findViewById(R.id.recyclerview)
writeBtn = view.findViewById(R.id.contentWriteBtn)
// 빈 리스트를 가진 아답터를 설정합니다.
adapter = BoardAdapter(mutableListOf())
recyclerView.adapter = adapter
recyclerView.layoutManager = LinearLayoutManager(context)
// 데이터베이스에서 모든 게시물을 읽어와서 리사이클러뷰에 표시합니다.
val postsRef = board.getAllPosts()
if (postsRef != null) {
postsRef.addValueEventListener(object : ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
Log.d(TAG, "onDataChange 호출됨, 데이터 개수: ${dataSnapshot.childrenCount}")
// dataSnapshot에서 받아온 데이터를 MutableList로 변환합니다.
val posts = dataSnapshot.children.mapNotNull { it.getValue(Post::class.java) }
// writer가 "관리자" 또는 "관장님"인 게시글과 그렇지 않은 게시글을 분리합니다.
val adminPosts = posts.filter { it.writer == "관리자" || it.writer == "관장님" }
val otherPosts = posts.filter { it.writer != "관리자" && it.writer != "관장님" }
// 그 외 게시글은 역순으로 정렬합니다.
val reversedOtherPosts = otherPosts.reversed()
// "관리자" 또는 "관장님"의 게시글을 앞에 두고, 그 외 게시글을 뒤에 붙여서 최종 게시글 목록을 만듭니다.
val sortedPosts = adminPosts + reversedOtherPosts
// 아답터의 데이터를 업데이트합니다.
adapter.updatePosts(sortedPosts.toMutableList())
}
override fun onCancelled(databaseError: DatabaseError) {
// 에러 로그를 출력합니다.
Log.w(TAG, "loadPost:onCancelled", databaseError.toException())
}
})
} else {
Log.e(TAG, "postsRef is null")
}
// 글쓰기 버튼 클릭 리스너 설정
writeBtn.setOnClickListener {
val context = context
if (context != null) {
val intent = Intent(context, BoardwriteActivity::class.java)
startActivity(intent)
} else {
Log.e(TAG, "Context is null")
}
}
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bottomNavigationView = view.findViewById(R.id.nav_view)
bottomNavigationView.setOnNavigationItemSelectedListener { item ->
when (item.itemId) {
R.id.navigation_home -> {
// 홈 화면 프래그먼트로 전환
val fragment = HomeFragment()
replaceFragment(fragment)
true
}
R.id.navigation_post -> {
// 게시글 프래그먼트로 전환
val fragment = BoardFragment()
replaceFragment(fragment)
true
}
R.id.navigation_chat -> {
// 채팅 프래그먼트로 전환
val fragment = ChatListFragment()
replaceFragment(fragment)
true
}
R.id.navigation_calendar -> {
// 캘린더 프래그먼트로 전환
val fragment = CalendarFragment()
replaceFragment(fragment)
true
}
R.id.navigation_settings -> {
val fragment = SettingFragment()
replaceFragment(fragment)
true
}
else -> false
}
}
}
private fun replaceFragment(fragment: Fragment) {
requireActivity().supportFragmentManager.beginTransaction().replace(R.id.container, fragment).commit()
}
}
저장되는 데이터 구조는 다음과 같다.
여기서 Images/ 폴더는 채팅방 사진 저장
image/ 폴더는 게시글 사진 저장이다.
'안드로이드 앱 개발' 카테고리의 다른 글
(8) 바디와이짐 커뮤니티 앱 개발 - 식단 운동일지 화면 (2) | 2024.09.20 |
---|---|
(7) 바디와이짐 커뮤니티 앱 개발 - 채팅 화면 (1) | 2024.09.20 |
(5) 바디와이짐 커뮤니티 앱 개발 - 메인 화면 (0) | 2024.09.20 |
(4) 바디와이짐 커뮤니티 앱 개발 - 회원가입 화면 (0) | 2024.09.20 |
(3) 바디와이짐 커뮤니티 앱 개발 - 홈 화면 (0) | 2024.09.20 |