완성 화면
주요 기능
사용 기술
build.gradle(Module)
plugins {
id 'com.google.gms.google-services'
}
dependencies {
implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation platform('com.google.firebase:firebase-bom:29.0.3')
implementation 'com.google.firebase:firebase-auth-ktx'
implementation 'com.google.firebase:firebase-database-ktx'
implementation 'com.google.firebase:firebase-storage-ktx'
}
build.gradle(Project)
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.3.14'
}
}
plugins {
id 'com.google.gms.google-services' version '4.3.13' apply false
}
layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
tools:context=".MainActivity">
<FrameLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:id="@+id/fragmentContainer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/bottomNavi"
app:layout_constraintTop_toTopOf="parent"/>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:id="@+id/bottomNavi"
app:itemIconTint="@drawable/selector_menu_color"
app:itemRippleColor="@null"
app:itemTextColor="@drawable/selector_menu_color"
app:menu="@menu/bottom_navi"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
layout/fragment_home.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout
android:id="@+id/toolbarLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/used_items"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<View
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbarLayout"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_cc" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/articleRecyclerView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbarLayout"
android:layout_width="0dp"
android:layout_height="0dp" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addFloatingButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:backgroundTint="@color/orange"
android:src="@drawable/ic_baseline_add_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:tint="@color/white" />
</androidx.constraintlayout.widget.ConstraintLayout>
layout/fragment_chat_list.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">
<LinearLayout
android:id="@+id/toolbarLayout"
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
android:gravity="center_vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/chatting_room_list"
android:textColor="@color/black"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_cc"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbarLayout" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatListRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbarLayout" />
</androidx.constraintlayout.widget.ConstraintLayout>
layout/fragment_my_page.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
android:padding="30dp">
<EditText
android:id="@+id/emailEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="200dp"
android:hint="@string/id"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/passwordEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/password"
android:inputType="textPassword"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/emailEditText" />
<Button
android:id="@+id/signUpButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
android:backgroundTint="@color/orange"
android:enabled="false"
android:text="@string/sign_up"
app:layout_constraintEnd_toStartOf="@+id/signInOutButton"
app:layout_constraintTop_toBottomOf="@+id/passwordEditText" />
<Button
android:id="@+id/signInOutButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:backgroundTint="@color/orange"
android:enabled="false"
android:text="@string/login"
app:layout_constraintEnd_toEndOf="@+id/passwordEditText"
app:layout_constraintTop_toBottomOf="@+id/passwordEditText" />
</androidx.constraintlayout.widget.ConstraintLayout>
layout/activity_add_article.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
tools:context=".home.AddArticleActivity">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
style="?attr/toolbarWithArrowButtonStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:elevation="0dp"
android:gravity="center"
app:layout_constraintBottom_toTopOf="@id/toolbarUnderLineView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/add_item"
android:textColor="@color/black"
android:textSize="16sp"
android:textStyle="bold" />
</androidx.appcompat.widget.Toolbar>
<View
android:id="@+id/toolbarUnderLineView"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_cc"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar" />
<Button
android:id="@+id/imageAddButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:backgroundTint="@color/orange"
android:text="@string/add_image"
app:layout_constraintEnd_toEndOf="@id/photoImageView"
app:layout_constraintStart_toStartOf="@id/photoImageView"
app:layout_constraintTop_toBottomOf="@id/photoImageView" />
<ImageView
android:id="@+id/photoImageView"
android:layout_width="250dp"
android:layout_height="250dp"
android:layout_marginTop="20dp"
app:layout_constraintBottom_toTopOf="@id/imageAddButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbarUnderLineView" />
<EditText
android:id="@+id/titleEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="20dp"
android:hint="@string/title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/imageAddButton" />
<EditText
android:id="@+id/priceEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="20dp"
android:hint="@string/price"
android:inputType="numberDecimal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleEditText" />
<Button
android:id="@+id/submitButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:backgroundTint="@color/orange"
android:text="@string/add"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:elevation="10dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
layout/item_article.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="wrap_content">
<ImageView
android:id="@+id/thumbnailImageView"
android:layout_width="110dp"
android:layout_height="110dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/titleTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:maxLines="2"
android:textColor="@color/black"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/thumbnailImageView"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/dateTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/titleTextView"
app:layout_constraintTop_toBottomOf="@id/titleTextView" />
<TextView
android:id="@+id/priceTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginEnd="16dp"
android:textColor="@color/black"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/dateTextView" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_cc"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
layout/activity_chat_room.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
tools:context=".chatlist.chatdetail.ChatRoomActivity">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
style="?attr/toolbarWithArrowButtonStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:elevation="0dp"
android:gravity="center"
app:layout_constraintBottom_toTopOf="@id/toolbarUnderLineView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/chattingNameTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/chat_room"
android:textColor="@color/black"
android:textSize="16sp"
android:textStyle="bold" />
</androidx.appcompat.widget.Toolbar>
<View
android:id="@+id/toolbarUnderLineView"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="@color/gray_cc"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatRecyclerView"
android:padding="10dp"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/messageEditText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbarUnderLineView" />
<EditText
android:id="@+id/messageEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginHorizontal="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/sendButton"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/sendButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/orange"
android:text="@string/send"
android:layout_marginEnd="10dp"
android:layout_marginBottom="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
layout/item_chat.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:padding="10dp">
<TextView
android:id="@+id/senderTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="닉네임" />
<TextView
android:id="@+id/messageTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="15sp"
android:layout_marginTop="5dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/senderTextView"
app:layout_constraintTop_toBottomOf="@id/senderTextView"
tools:text="MESSAGE" />
</androidx.constraintlayout.widget.ConstraintLayout>
layout/item_chat_list.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">
<TextView
android:id="@+id/chatRoomTitleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:paddingStart="10dp"
android:textColor="@color/black"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="채팅방 이름" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="10dp"
android:background="@color/gray_cc"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chatRoomTitleTextView" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity
class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
initViews()
}
private fun initViews() = with(binding) {
bottomNavi.setOnItemSelectedListener(this@MainActivity)
showFragment(HomeFragment(), HomeFragment.TAG)
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.home -> {
showFragment(HomeFragment.newInstance(), HomeFragment.TAG)
true
}
R.id.chatList -> {
showFragment(ChatListFragment.newInstance(), ChatListFragment.TAG)
true
}
R.id.myPage -> {
showFragment(MyPageFragment.newInstance(), MyPageFragment.TAG)
true
}
else -> false
}
}
private fun showFragment(fragment: Fragment, tag: String) {
val findFragment = supportFragmentManager.findFragmentByTag(tag)
supportFragmentManager.fragments.forEach { fm ->
supportFragmentManager.beginTransaction().hide(fm).commitAllowingStateLoss()
}
findFragment?.let {
supportFragmentManager.beginTransaction().show(it).commitAllowingStateLoss()
} ?: kotlin.run {
supportFragmentManager.beginTransaction()
.add(R.id.fragmentContainer, fragment, tag)
.commitAllowingStateLoss()
}
}
}
HomeFragment
class MyPageFragment : Fragment(R.layout.fragment_my_page) {
companion object {
fun newInstance() = MyPageFragment()
const val TAG = "MyPageFragment"
}
}
ChatListFragment와 MyPageFragment에도 동일하게 코드 적용
상품 등록 페이지 구현
ArticleModel
data class ArticleModel(
val sellerId: String,
val title: String,
val createdAt: Long,
val price: String,
val imageUrl: String
) {
constructor(): this("", "", 0, "", "")
}
Firebase Realtime Database 그대로 모델 클래스를 사용하기 위해 빈 생성자가 필수로 존재해야 한다.
ArticleAdapter
class ArticleAdapter : ListAdapter<ArticleModel, ArticleAdapter.ViewHolder>(diffUtil) {
inner class ViewHolder(private val binding: ItemArticleBinding): RecyclerView.ViewHolder(binding.root) {
@SuppressLint("SimpleDateFormat")
fun bind(articleModel: ArticleModel) {
val format = SimpleDateFormat("MM월 dd일")
val date = Date(articleModel.createdAt)
binding.titleTextView.text = articleModel.title
binding.dateTextView.text = format.format(date).toString() // Long 형을 date 형식으로 변환
binding.priceTextView.text = articleModel.price
if (articleModel.imageUrl.isNotEmpty()) {
Glide.with(binding.thumbnailImageView)
.load(articleModel.imageUrl)
.into(binding.thumbnailImageView)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ItemArticleBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(currentList[position])
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<ArticleModel>() {
override fun areItemsTheSame(oldItem: ArticleModel, newItem: ArticleModel): Boolean {
return oldItem.createdAt == newItem.createdAt
}
override fun areContentsTheSame(oldItem: ArticleModel, newItem: ArticleModel): Boolean {
return oldItem == newItem
}
}
}
}
HomeFragment
class HomeFragment : Fragment(R.layout.fragment_home) {
companion object {
fun newInstance() = HomeFragment()
const val TAG = "HomeFragment"
}
// 전역변수는 nullable 하기 때문에 onViewCreate() 안에서는 절대적으로 null이 들어오지
// 못하게 하기 위해 임시로 지역변수로 설정
private var binding: FragmentHomeBinding? = null
private lateinit var adapter: ArticleAdapter
@RequiresApi(Build.VERSION_CODES.M)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentHomeBinding = FragmentHomeBinding.bind(view)
binding = fragmentHomeBinding
adapter = ArticleAdapter()
fragmentHomeBinding.articleRecyclerView.layoutManager = LinearLayoutManager(context)
fragmentHomeBinding.articleRecyclerView.adapter = adapter
}
}
onCreateView()를 통해 반환된 뷰 객체는 onViewCreate()의 파라미터로 전달된다. 이 시점부터는 Fragment 뷰의 생명주기가 INITIALIZED 상태로 업데이트 되기 때문에 뷰의 초기값을 설정해주거나, recyclerView에 사용될 어댑터 셋팅은 onViewCreate()에 해주는 것이 적절하다.
Firebase Realtime Database를 이용해 상품 등록
HomeFragment
class HomeFragment : Fragment(R.layout.fragment_home) {
private lateinit var adapter: ArticleAdapter
private lateinit var articleDB: DatabaseReference
private val articleList = mutableListOf<ArticleModel>()
private val listener = object : ChildEventListener {
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
val articleModel = snapshot.getValue(ArticleModel::class.java)
articleModel ?: return
articleList.add(articleModel)
adapter.submitList(articleList)
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onChildRemoved(snapshot: DataSnapshot) {}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onCancelled(error: DatabaseError) {}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
articleList.clear()
userDB = Firebase.database.reference.child(DB_USERS)
articleDB = Firebase.database.reference.child(DB_ARTICLES)
}
fragmentHomeBinding.addFloatingButton.setOnClickListener {
context?.let {
val intent = Intent(it, AddArticleActivity::class.java)
startActivity(intent)
}
}
articleDB.addChildEventListener(listener)
@SuppressLint("NotifyDataSetChanged")
override fun onResume() {
super.onResume()
adapter.notifyDataSetChanged()
}
override fun onDestroy() {
super.onDestroy()
articleDB.removeEventListener(listener)
}
}
Fragment의 경우 재사용되기 때문에 addChildEventListener가 onViewCreated() 될 때마다 중복으로 붙여질 가능성이 있다. 따라서 이벤트 리스너를 전역으로 설정해두고 onViewCreated() 될 때마다 attach 해주고, 뷰가 destroy 될 때마다 remove 해주도록 하였다.
AddArticleActivity
class AddArticleActivity : AppCompatActivity() {
private val binding by lazy { ActivityAddArticleBinding.inflate(layoutInflater) }
private val articleDB: DatabaseReference by lazy {
Firebase.database.reference.child(DB_ARTICLES)
}
private val auth: FirebaseAuth by lazy { Firebase.auth }
private var selectedUri: Uri? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
initViews()
}
@SuppressLint("ShowToast")
private fun initViews() = with(binding) {
toolbar.setNavigationOnClickListener { finish() }
imageAddButton.setOnClickListener {
when {
ContextCompat.checkSelfPermission(
this@AddArticleActivity,
android.Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED -> {
startContentProvider()
}
shouldShowRequestPermissionRationale(android.Manifest.permission.READ_EXTERNAL_STORAGE) -> {
showPermissionContextPopup()
}
else -> {
requestPermissions(
arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE),
1010
)
}
}
}
submitButton.setOnClickListener {
val title = titleEditText.text.toString()
val price = priceEditText.text.toString()
val sellerId = auth.currentUser?.uid.orEmpty()
progressBar.isVisible = true
val model = ArticleModel(sellerId, title, System.currentTimeMillis(), "$price 원", "")
articleDB.push().setValue(model)
finish()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
1010 -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startContentProvider()
} else {
Toast.makeText(this, "권한을 거부하셨습니다.", Toast.LENGTH_SHORT).show()
}
}
}
private fun startContentProvider() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "image/*"
startActivityForResult(intent, 2020)
}
// startActivityForResult 에서 나온 결과를 onActivity 에서 받음
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != Activity.RESULT_OK) {
return
}
when (requestCode) {
2020 -> {
val uri = data?.data
if (uri != null) {
binding.photoImageView.setImageURI(uri)
selectedUri = uri
} else {
Toast.makeText(this, "사진을 가져오지 못했습니다.", Toast.LENGTH_SHORT).show()
}
}
else -> {
Toast.makeText(this, "사진을 가져오지 못했습니다.", Toast.LENGTH_SHORT).show()
}
}
}
private fun showPermissionContextPopup() {
AlertDialog.Builder(this)
.setTitle("권한이 필요합니다.")
.setMessage("사진을 가져오기 위해 권한이 필요합니다.\n권한을 허용하시겠습니까?")
.setPositiveButton("허용") { _, _ ->
requestPermissions(arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE), 1010)
}
.create()
.show()
}
}
권한 설정 구현, model을 만들고 push()를 통해 아이템을 만들고 setValue()를 통해 아이템을 모델에 넣어준다. 이렇게 하면 임의의 키 값 안에 articleModel이 들어가게 된다.
Manifest
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.INTERNET"/>
Firebase Storage를 이용해 사진 업로드
AddArticleActivity
class AddArticleActivity : AppCompatActivity() {
private val storage: FirebaseStorage by lazy { Firebase.storage }
private var selectedUri: Uri? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
initViews()
}
private fun initVews() = with(binding) {
submitButton.setOnClickListener {
if (selectedUri != null) {
val photoUri = selectedUri ?: return@setOnClickListener
uploadPhoto(photoUri,
// 성공하면 업로드한 uri를 가져와 uploadArticel()에 넣어줌
successHandler = { uri ->
uploadArticle(sellerId, title, price, uri)
},
errorHandler = {
Toast.makeText(this@AddArticleActivity, "사진 업로드에 실패했습니다.", Toast.LENGTH_SHORT).show()
progressBar.isVisible = false
})
} else {
uploadArticle(sellerId, title, price, "")
}
}
}
private fun uploadPhoto(uri: Uri, successHandler: (String) -> Unit, errorHandler: () -> Unit) {
val fileName = "${System.currentTimeMillis()}.png"
storage.reference.child("article/photo").child(fileName)
.putFile(uri)
.addOnCompleteListener {
if (it.isSuccessful) {
storage.reference.child("article/photo").child(fileName)
.downloadUrl
.addOnSuccessListener { uri ->
successHandler(uri.toString())
}.addOnFailureListener {
errorHandler()
}
} else {
errorHandler()
}
}
}
private fun uploadArticle(sellerId: String, title: String, price: String, imageUrl: String) {
val model = ArticleModel(sellerId, title, System.currentTimeMillis(), "$price 원", imageUrl)
articleDB.push().setValue(model)
binding.progressBar.isVisible = false
finish()
}
}
앞서 setValue()를 통해 아이템에 모델을 넣어주었던 것을 uploadArticle() 함수에 따로 빼주고, 빈 값으로 두었던 imageUrl을 추가해주었다.
MyPageFragment
class MyPageFragment : Fragment(R.layout.fragment_my_page) {
companion object {
fun newInstance() = MyPageFragment()
const val TAG = "MyPageFragment"
}
private var binding: FragmentMyPageBinding? = null
private val auth: FirebaseAuth by lazy { Firebase.auth }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentMyPageBinding = FragmentMyPageBinding.bind(view)
binding = fragmentMyPageBinding
fragmentMyPageBinding.emailEditText.addTextChangedListener {
binding?.let { binding ->
val enable =
binding.emailEditText.text.isNotEmpty() && binding.passwordEditText.text.isNotEmpty()
binding.signInOutButton.isEnabled = enable
binding.signUpButton.isEnabled = enable
}
}
fragmentMyPageBinding.passwordEditText.addTextChangedListener {
binding?.let { binding ->
val enable =
binding.emailEditText.text.isNotEmpty() && binding.passwordEditText.text.isNotEmpty()
binding.signInOutButton.isEnabled = enable
binding.signUpButton.isEnabled = enable
}
}
// 로그인 & 로그아웃
fragmentMyPageBinding.signInOutButton.setOnClickListener {
binding?.let { binding ->
val email = binding.emailEditText.text.toString()
val password = binding.passwordEditText.text.toString()
if (auth.currentUser == null) {
auth.signInWithEmailAndPassword(email, password)
.addOnCompleteListener(requireActivity()) { task ->
if (task.isSuccessful) {
successSignIn()
Toast.makeText(context, "로그인 되셨습니다.", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(
context,
"로그인에 실패했습니다. 이메일 또는 비밀번호를 확인해주세요.",
Toast.LENGTH_SHORT
).show()
}
}
} else {
auth.signOut()
binding.emailEditText.text.clear()
binding.emailEditText.isEnabled = true
binding.passwordEditText.text.clear()
binding.passwordEditText.isEnabled = true
binding.signInOutButton.text = "로그인"
binding.signInOutButton.isEnabled = true
binding.signUpButton.isEnabled = false
}
}
}
}
private fun successSignIn() {
if (auth.currentUser == null) {
Toast.makeText(context, "로그인에 실패했습니다. 다시 시도해주세요.", Toast.LENGTH_SHORT).show()
return
}
binding?.emailEditText?.isEnabled = false
binding?.passwordEditText?.isEnabled = false
binding?.signUpButton?.isEnabled = false
binding?.signInOutButton?.text = "로그아웃"
}
// 실제로 로그인이 풀렸을 경우를 대비해 onStart()에서 확인하고 예외처리 해줌
override fun onStart() {
super.onStart()
if (auth.currentUser == null) {
binding?.let { binding ->
binding.emailEditText.text.clear()
binding.passwordEditText.text.clear()
binding.emailEditText.isEnabled = true
binding.passwordEditText.isEnabled = true
binding.signInOutButton.text = "로그인"
binding.signInOutButton.isEnabled = false
binding.signUpButton.isEnabled = false
}
} else {
binding?.let { binding ->
binding.emailEditText.setText(auth.currentUser?.email)
binding.passwordEditText.setText("***********")
binding.emailEditText.isEnabled = false
binding.passwordEditText.isEnabled = false
binding.signInOutButton.text = "로그아웃"
binding.signInOutButton.isEnabled = true
binding.signUpButton.isEnabled = false
}
}
}
}
회원 기반 이용 (로그인 기능 보완)
HomeFragment
// 플로팅 버튼(상품 등록)
fragmentHomeBinding.addFloatingButton.setOnClickListener {
context?.let {
if (auth.currentUser != null) {
val intent = Intent(it, AddArticleActivity::class.java)
startActivity(intent)
} else {
Snackbar.make(view, "로그인 후 사용하실 수 있습니다.", Snackbar.LENGTH_SHORT).show()
}
}
}
상품 선택시 채팅방 생성
ArticleAdapter
class ArticleAdapter(val onItemClicked: (ArticleModel) -> Unit)
fun bind(articleModel: ArticleModel) {
binding.root.setOnClickListener {
onItemClicked(articleModel)
}
}
recyclerView에서는 setOnClickListener 기능을 지원하지 않기 때문에 어댑터 클래스 생성시 리턴 람다식을 추가해 임의로 구현해주었다.
ChatListItem
data class ChatListItem(
val buyerId: String,
val sellerId: String,
val itemTitle: String,
val key: Long
) {
constructor(): this("", "", "", 0)
}
HomeFragment
class HomeFragment : Fragment(R.layout.fragment_home) {
private lateinit var userDB: DatabaseReference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentHomeBinding = FragmentHomeBinding.bind(view)
binding = fragmentHomeBinding
articleList.clear()
userDB = Firebase.database.reference.child(DB_USERS)
articleDB = Firebase.database.reference.child(DB_ARTICLES)
adapter = ArticleAdapter(onItemClicked = { articleModel ->
if (auth.currentUser != null) {
if (auth.currentUser?.uid != articleModel.sellerId) {
val chatRoom = ChatListItem(
buyerId = auth.currentUser?.uid.orEmpty(),
sellerId = articleModel.sellerId,
itemTitle = articleModel.title,
key = System.currentTimeMillis()
)
userDB.child(auth.currentUser?.uid.orEmpty())
.child(CHILD_CHAT)
.push()
.setValue(chatRoom)
userDB.child(articleModel.sellerId)
.child(CHILD_CHAT)
.push()
.setValue(chatRoom)
Snackbar.make(view, "채팅방이 생성되었습니다. 채팅탭에서 확인해주세요.", Snackbar.LENGTH_LONG).show()
} else {
Snackbar.make(view, "본인이 등록한 아이템입니다.", Snackbar.LENGTH_LONG).show()
}
} else {
Snackbar.make(view, "로그인 후 사용해주세요.", Snackbar.LENGTH_LONG).show()
}
})
}
상품을 등록한 사람의 sellefId와 그 상품을 등록하지 않은 sellerId가 같지 않다면 채팅방을 열 수 있게 하고, sellerId가 일치하면 스낵바를 띄워주었다.
채팅 리스트 구현
ChatListAdapter
class ChatListAdapter(val onItemClicked: (ChatListItem) -> Unit) :
ListAdapter<ChatListItem, ChatListAdapter.ViewHolder>(diffUtil) {
inner class ViewHolder(private val binding: ItemChatListBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(chatListItem: ChatListItem) {
binding.root.setOnClickListener {
onItemClicked(chatListItem)
}
binding.chatRoomTitleTextView.text = chatListItem.itemTitle
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemChatListBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(currentList[position])
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<ChatListItem>() {
override fun areItemsTheSame(oldItem: ChatListItem, newItem: ChatListItem): Boolean {
return oldItem.key == newItem.key
}
override fun areContentsTheSame(oldItem: ChatListItem, newItem: ChatListItem): Boolean {
return oldItem == newItem
}
}
}
}
ChatListFragment
class ChatListFragment : Fragment(R.layout.fragment_chat_list) {
companion object {
fun newInstance() = ChatListFragment()
const val TAG = "ChatListFragment"
}
private var binding: FragmentChatListBinding? = null
private lateinit var chatListAdapter: ChatListAdapter
private val chatRoomList = mutableListOf<ChatListItem>()
private val auth: FirebaseAuth by lazy { Firebase.auth }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentChatListBinding = FragmentChatListBinding.bind(view)
binding = fragmentChatListBinding
chatListAdapter = ChatListAdapter(onItemClicked = { chatRoom ->
// 채팅방으로 이동
context?.let {
val intent = Intent(it, ChatRoomActivity::class.java)
intent.putExtra("chatKey", chatRoom.key)
startActivity(intent)
}
})
chatRoomList.clear()
fragmentChatListBinding.chatListRecyclerView.adapter = chatListAdapter
fragmentChatListBinding.chatListRecyclerView.layoutManager = LinearLayoutManager(context)
if (auth.currentUser == null) {
return
}
val chatDB = Firebase.database.reference.child(DB_USERS).child(auth.currentUser!!.uid)
.child(CHILD_CHAT)
chatDB.addListenerForSingleValueEvent(object : ValueEventListener {
@SuppressLint("NotifyDataSetChanged")
override fun onDataChange(snapshot: DataSnapshot) {
snapshot.children.forEach {
val model = it.getValue(ChatListItem::class.java)
model ?: return
chatRoomList.add(model)
}
chatListAdapter.submitList(chatRoomList)
chatListAdapter.notifyDataSetChanged()
}
override fun onCancelled(error: DatabaseError) {}
})
}
@SuppressLint("NotifyDataSetChanged")
override fun onResume() {
super.onResume()
chatListAdapter.notifyDataSetChanged()
}
}
채팅 페이지 구현
ChatItem
data class ChatItem(
val senderId: String,
val message: String
) {
constructor(): this("", "")
}
ChatItemAdapter
class ChatItemAdapter() : ListAdapter<ChatItem, ChatItemAdapter.ChatITemViewHolder>(diffUtil) {
inner class ChatITemViewHolder(private val binding: ItemChatBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(chatItem: ChatItem) {
binding.senderTextView.text = chatItem.senderId
binding.messageTextView.text = chatItem.message
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatITemViewHolder {
return ChatITemViewHolder(
ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onBindViewHolder(holder: ChatITemViewHolder, position: Int) {
holder.bind(currentList[position])
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<ChatItem>() {
override fun areItemsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean {
return oldItem == newItem
}
}
}
}
ChatRoomActivity
class ChatRoomActivity : AppCompatActivity() {
private val binding by lazy { ActivityChatRoomBinding.inflate(layoutInflater) }
private val auth: FirebaseAuth by lazy { Firebase.auth }
private val chatList = mutableListOf<ChatItem>()
private val adapter = ChatItemAdapter()
private var chatDB: DatabaseReference? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
val chatKey = intent.getLongExtra("chatKey", -1)
chatDB = Firebase.database.reference.child(DB_CHATS).child("$chatKey")
chatDB?.addChildEventListener(object : ChildEventListener {
@SuppressLint("NotifyDataSetChanged")
override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
val chatItem = snapshot.getValue(ChatItem::class.java)
chatItem ?: return
chatList.add(chatItem)
adapter.submitList(chatList)
adapter.notifyDataSetChanged()
}
override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onChildRemoved(snapshot: DataSnapshot) {}
override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
override fun onCancelled(error: DatabaseError) {}
})
binding.chatRecyclerView.adapter = adapter
binding.chatRecyclerView.layoutManager = LinearLayoutManager(this)
binding.toolbar.setNavigationOnClickListener { finish() }
binding.sendButton.setOnClickListener {
val chatItem = ChatItem(
senderId = auth.currentUser!!.uid,
message = binding.messageEditText.text.toString()
)
chatDB?.push()?.setValue(chatItem)
}
}
}