Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 모험 기록 상세 페이지 뷰 구현 #494

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
520a766
feat: OpenLetter 매퍼 생성
krrong Oct 16, 2023
85b8ee8
Merge branch 'dev_android' of https://github.com/woowacourse-teams/20…
krrong Oct 16, 2023
a8912df
feat: AdventureDetailViewModel 구현
krrong Oct 16, 2023
04cc1a9
test: AdventureDetailViewModel 테스트 작성
krrong Oct 16, 2023
82c3344
feat: LetterThrowable 추가
krrong Oct 16, 2023
81c7c24
feat: 읽은 편지, 등록한 편지를 읽을 수 있는 뷰페이저 구현
krrong Oct 16, 2023
96c762f
feat: 뷰페이저, 탭레이아웃 연결
krrong Oct 16, 2023
f05ca63
feat: radius를 파일명에 맞게 변경
krrong Oct 16, 2023
aa544f9
refactor: 뷰 마진, 배경 수정
krrong Oct 16, 2023
3e30f2b
refactor: text를 tools로 변경
krrong Oct 16, 2023
33f7425
refactor: 마진 추가
krrong Oct 16, 2023
22781e5
feat: 읽은 쪽지, 등록한 쪽지를 flow로 combine
krrong Oct 17, 2023
97e8478
feat: 모험 기록 상세 페이지로 이동하는 기능 구현
krrong Oct 17, 2023
f81a799
feat: 뒤로가기 기능 구현
krrong Oct 17, 2023
4c5d696
feat: 넘겨준 게임 id를 가져오는 기능 구현
krrong Oct 17, 2023
01c0d09
feat: 게임 id로 게임 결과를 가져와 보여주는 기능 구현
krrong Oct 17, 2023
4df8471
refactor: string resource화
krrong Oct 17, 2023
94fced6
refactor: string 변경
krrong Oct 17, 2023
0c29e97
feat: 등록된 쪽지가 없는 경우 쪽지가 없다는 내용을 보여주는 기능 구현
krrong Oct 17, 2023
9b2c5fd
feat: 이벤트가 없는 스낵바 확장함수 추가
krrong Oct 17, 2023
c7e7589
feat: 예상 가능한 예외 핸들링
krrong Oct 17, 2023
0e9a035
refactor: repeatOnStarted 확장함수 생성
krrong Oct 17, 2023
3fac1bc
feat: 액티비티 추가
krrong Oct 17, 2023
6d0c438
test: 테스트 수정
krrong Oct 17, 2023
5d6abee
refactor: 디폴트 객체 생성
krrong Oct 17, 2023
8c8c88c
Merge branch 'dev_android' of https://github.com/woowacourse-teams/20…
krrong Oct 17, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@
android:name=".presentation.setting.SettingActivity"
android:screenOrientation="portrait"
android:exported="false" />
<activity
android:name=".presentation.adventuredetail.AdventureDetailActivity"
android:screenOrientation="portrait"
android:exported="false" />
<activity
android:name="com.kakao.sdk.auth.AuthCodeHandlerActivity"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.now.naaga.data.mapper

import com.now.domain.model.letter.OpenLetter
import com.now.naaga.data.remote.dto.OpenLetterDto

fun OpenLetterDto.toDomain(): OpenLetter {
return OpenLetter(
id = id,
player = player.toDomain(),
coordinate = coordinateDto.toDomain(),
message = message,
registerDate = registerDate,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ class DefaultLetterRepository(
}

override suspend fun fetchLetterLogs(gameId: Long, logType: LogType): List<OpenLetter> {
TODO("Not yet implemented")
return letterService.getInGameLetters(gameId, logType.name).getValueOrThrow().map { it.toDomain() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ sealed class DataThrowable(val code: Int, message: String) : Throwable(message)
// http 응답코드 500번대, body가 null일 때의 에러
class IllegalStateThrowable : DataThrowable(ILLEGAL_STATE_THROWABLE_CODE, ILLEGAL_STATE_THROWABLE_MESSAGE)

// 700번대 쪽지 관련 에러
class LetterThrowable(code: Int, message: String) : DataThrowable(code, message)

// IO Exception 일 경우의 예외
class NetworkThrowable : DataThrowable(NETWORK_THROWABLE_CODE, NETWORK_THROWABLE_MESSAGE)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.now.naaga.presentation.adventuredetail

import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.bumptech.glide.Glide
import com.google.android.material.tabs.TabLayoutMediator
import com.now.domain.model.AdventureResult
import com.now.naaga.R
import com.now.naaga.data.firebase.analytics.AnalyticsDelegate
import com.now.naaga.data.firebase.analytics.DefaultAnalyticsDelegate
import com.now.naaga.databinding.ActivityAdventureDetailBinding
import com.now.naaga.presentation.adventuredetail.viewpager.ViewPagerAdapter
import com.now.naaga.presentation.uimodel.model.OpenLetterUiModel
import com.now.naaga.util.extension.repeatOnStarted
import com.now.naaga.util.extension.showSnackbarWithEvent
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class AdventureDetailActivity : AppCompatActivity(), AnalyticsDelegate by DefaultAnalyticsDelegate() {
private lateinit var binding: ActivityAdventureDetailBinding
private val viewModel: AdventureDetailViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAdventureDetailBinding.inflate(layoutInflater)
setContentView(binding.root)

val gameId = intent.getLongExtra(KET_GAME_ID, 0L)

initView(gameId)
setClickListeners()
subscribe()
}

private fun initView(gameId: Long) {
viewModel.fetchReadLetter(gameId)
viewModel.fetchWriteLetter(gameId)
viewModel.fetchAdventureResult(gameId)
}

private fun setClickListeners() {
binding.ivAdventureDetailBack.setOnClickListener { finish() }
}

private fun subscribe() {
repeatOnStarted {
viewModel.uiState.collect { adventureDetailUiState ->
when (adventureDetailUiState) {
is AdventureDetailUiState.Loading, is AdventureDetailUiState.Error -> Unit
is AdventureDetailUiState.Success -> initView(adventureDetailUiState)
}
}
}
repeatOnStarted {
viewModel.throwableFlow.collect { event ->
when (event) {
is AdventureDetailViewModel.Event.NetworkExceptionEvent -> showReRequestSnackbar()
is AdventureDetailViewModel.Event.LetterExceptionEvent -> showReRequestSnackbar()
is AdventureDetailViewModel.Event.GameExceptionEvent -> showReRequestSnackbar()
}
}
}
}

private fun showReRequestSnackbar() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2]

Suggested change
private fun showReRequestSnackbar() {
private fun showRequestSnackbar() {

오타가 있어요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오타...는 아닙니다..🦖
재요청 == Re(재)Request(요청)
의 느낌으로 작성한건데... showRequestSnackbar로 변경할까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅋㅋㅋㅋㅋㅋ오타가 아니군요!? 앗차차~~~
showRetrySnackbar는 어떤가요?ㅋㅋㅋㅋ구리면 바로showReRequestSnackbar로 유지합시다!ㅋㅋㅋ
크롱 말 듣고 다시 보니 showReRequestSnackbar 네이밍도 메서드가 머선일을 하는지 잘 보이는 것 같고 좋네요👍

binding.root.showSnackbarWithEvent(
message = getString(R.string.snackbar_action_re_request_message),
actionTitle = getString(R.string.snackbar_action__re_request_title),
) { finish() }
}

private fun initView(adventureDetailUiState: AdventureDetailUiState.Success) {
initViewPager(adventureDetailUiState.readLetters, adventureDetailUiState.writeLetters)
initImage(adventureDetailUiState.adventureResult)
}

private fun initViewPager(readLetters: List<OpenLetterUiModel>, writeLetters: List<OpenLetterUiModel>) {
binding.vpAdventureDetail.adapter = ViewPagerAdapter(listOf(readLetters, writeLetters))

TabLayoutMediator(binding.tlAdventureDetail, binding.vpAdventureDetail) { tab, position ->
when (position) {
0 -> tab.text = getString(R.string.adventure_detail_read_letter)
1 -> tab.text = getString(R.string.adventure_detail_write_letter)
}
}.attach()
}

private fun initImage(adventureResult: AdventureResult) {
Glide.with(binding.ivAdventureDetailPhoto)
.load(adventureResult.destination.image)
.error(R.drawable.ic_none_photo)
.into(binding.ivAdventureDetailPhoto)
}

companion object {
private const val KET_GAME_ID = "GAME_ID"

fun getIntentWithId(context: Context, gameId: Long): Intent {
return Intent(context, AdventureDetailActivity::class.java).apply {
putExtra(KET_GAME_ID, gameId)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.now.naaga.presentation.adventuredetail

import com.now.domain.model.AdventureResult
import com.now.naaga.presentation.uimodel.model.OpenLetterUiModel

sealed interface AdventureDetailUiState {
object Loading : AdventureDetailUiState

data class Success(
val readLetters: List<OpenLetterUiModel>,
val writeLetters: List<OpenLetterUiModel>,
val adventureResult: AdventureResult,
) : AdventureDetailUiState

object Error : AdventureDetailUiState
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.now.naaga.presentation.adventuredetail

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.now.domain.model.AdventureResult
import com.now.domain.model.letter.OpenLetter
import com.now.domain.model.type.LogType
import com.now.domain.repository.AdventureRepository
import com.now.domain.repository.LetterRepository
import com.now.naaga.data.throwable.DataThrowable
import com.now.naaga.presentation.uimodel.mapper.toUiModel
import com.now.naaga.presentation.uimodel.model.OpenLetterUiModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject

@HiltViewModel
class AdventureDetailViewModel @Inject constructor(
private val letterRepository: LetterRepository,
private val adventureRepository: AdventureRepository,
) : ViewModel() {
private val readLettersFlow = MutableSharedFlow<List<OpenLetter>>()

private val writeLettersFlow = MutableSharedFlow<List<OpenLetter>>()

private val adventureFlow = MutableSharedFlow<AdventureResult>()

private val _uiState: MutableStateFlow<AdventureDetailUiState> = MutableStateFlow(AdventureDetailUiState.Loading)
val uiState: StateFlow<AdventureDetailUiState> = _uiState.asStateFlow()

private val _throwableFlow = MutableSharedFlow<Event>()
val throwableFlow: SharedFlow<Event> = _throwableFlow.asSharedFlow()

init {
viewModelScope.launch {
combine(readLettersFlow, writeLettersFlow, adventureFlow) { readLetters, writeLetters, adventureResult ->
AdventureDetailUiState.Success(
readLetters = getOpenLetterUiModels(readLetters),
writeLetters = getOpenLetterUiModels(writeLetters),
adventureResult = adventureResult,
)
}.collectLatest { _uiState.value = it }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collectLatest 가 어떤 일을 하는 아이인가요? 최종적으로 값이 모여졌을 때 value에 넣어주도록 처리해주는 아이인가요??!?!!? 진짜 모름

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collect를 통해 emit한 값을 수집하여 사용할 수 있습니다.
collectLatest 함수는 새로운 값이 방출될 때마다 현재 진행 중인 작업을 취소하고, 가장 최신의 값을 사용하여 새로운 작업을 시작합니다.
다음의 코드를 실행시켜보면 조금 더 쉽게 와닿을 수도 있을 것 같아요!

fun main() = runBlocking {
    val flow = flow {
        emit(1)
        delay(1000)
        emit(2)
        delay(1000)
        emit(3)
    }

    flow.collectLatest { value ->
        println("Received: $value")
        delay(3000)
    }
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호... 예제 코드까지 주시다니.. 👍
해당 코드 실행 시켜봤는데 이해가 좀 가기 시작했어요! flow 관련해서 좀더 공부해보고 싶어지네요ㅎㅎㅎㅎ

}
}

private fun getOpenLetterUiModels(letters: List<OpenLetter>): List<OpenLetterUiModel> {
if (letters.isEmpty()) return listOf(OpenLetterUiModel.DEFAULT_OPEN_LETTER)
return letters.map { it.toUiModel() }
}

fun fetchReadLetter(gameId: Long) {
viewModelScope.launch {
runCatching {
letterRepository.fetchLetterLogs(gameId, LogType.READ)
}.onSuccess {
readLettersFlow.emit(it)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호..... init 에서 컴바인을 해주면 여기선 success가 될 때 emit만 해주면 되는군요..?
크롱의 flow 강의 기다립니다🤩

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

init 블록에서 readLettersFlow, writeLettersFlow, adventureFlowcombine하고
collectLatest를 통해 가장 최신의 값을 사용하여 AdventureDeetailuiState.Success를 반환합니다.

emit을 해야 collect가 가능합니다!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 ~~~~~~~~~ combine된 애들이 emit을 통해 값이 불러와지고 이 아이들이 다 들어오게 되면 collectLatest를 통해 받아오는거군요
오호 대박 이해완!

}.onFailure {
setThrowable(it)
}
}
}

fun fetchWriteLetter(gameId: Long) {
viewModelScope.launch {
runCatching {
letterRepository.fetchLetterLogs(gameId, LogType.WRITE)
}.onSuccess {
writeLettersFlow.emit(it)
}.onFailure {
setThrowable(it)
}
}
}

fun fetchAdventureResult(gameId: Long) {
viewModelScope.launch {
runCatching {
adventureRepository.fetchAdventureResult(gameId)
}.onSuccess {
adventureFlow.emit(it)
}.onFailure {
setThrowable(it)
}
}
}

private fun setThrowable(throwable: Throwable) {
when (throwable) {
is IOException -> throwable(Event.NetworkExceptionEvent(throwable))
is DataThrowable.LetterThrowable -> throwable(Event.LetterExceptionEvent(throwable))
is DataThrowable.GameThrowable -> throwable(Event.GameExceptionEvent(throwable))
}
}

private fun throwable(event: Event) {
viewModelScope.launch {
_throwableFlow.emit(event)
}
}

sealed class Event {
data class NetworkExceptionEvent(val throwable: Throwable) : Event()
data class LetterExceptionEvent(val throwable: Throwable) : Event()
data class GameExceptionEvent(val throwable: Throwable) : Event()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.now.naaga.presentation.adventuredetail.recyclerview

import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.now.naaga.presentation.uimodel.model.OpenLetterUiModel

class LetterAdapter(private val letters: List<OpenLetterUiModel>) : RecyclerView.Adapter<LetterViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LetterViewHolder {
return LetterViewHolder(parent)
}

override fun onBindViewHolder(holder: LetterViewHolder, position: Int) {
holder.bind(letters[position])
}

override fun getItemCount(): Int {
return letters.size
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.now.naaga.presentation.adventuredetail.recyclerview

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.now.naaga.R
import com.now.naaga.databinding.ItemLetterBinding
import com.now.naaga.presentation.uimodel.model.OpenLetterUiModel

class LetterViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.item_letter, parent, false),
) {
private val binding: ItemLetterBinding = ItemLetterBinding.bind(itemView)

fun bind(letter: OpenLetterUiModel) {
binding.tvItemLetterNickname.text = letter.nickname
binding.tvItemLetterRegisterDate.text = letter.registerDate
binding.tvItemLetter.text = letter.message
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.now.naaga.presentation.adventuredetail.viewpager

import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.now.naaga.presentation.uimodel.model.OpenLetterUiModel

class ViewPagerAdapter(private val data: List<List<OpenLetterUiModel>>) : RecyclerView.Adapter<ViewPagerHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewPagerHolder {
return ViewPagerHolder(parent)
}

override fun onBindViewHolder(holder: ViewPagerHolder, position: Int) {
holder.bind(data[position])
}

override fun getItemCount(): Int {
return data.size
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.now.naaga.presentation.adventuredetail.viewpager

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.now.naaga.R
import com.now.naaga.databinding.ItemViewPagerBinding
import com.now.naaga.presentation.adventuredetail.recyclerview.LetterAdapter
import com.now.naaga.presentation.uimodel.model.OpenLetterUiModel

class ViewPagerHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.item_view_pager, parent, false),
) {
private val binding: ItemViewPagerBinding = ItemViewPagerBinding.bind(itemView)

fun bind(data: List<OpenLetterUiModel>) {
binding.rvItemViewPager.adapter = LetterAdapter(data)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.now.domain.model.AdventureResult
import com.now.naaga.R
import com.now.naaga.data.throwable.DataThrowable
import com.now.naaga.databinding.ActivityAdventureHistoryBinding
import com.now.naaga.presentation.adventuredetail.AdventureDetailActivity
import com.now.naaga.presentation.adventurehistory.recyclerview.AdventureHistoryAdapter
import com.now.naaga.util.extension.showToast
import dagger.hilt.android.AndroidEntryPoint
Expand All @@ -17,7 +18,7 @@ import dagger.hilt.android.AndroidEntryPoint
class AdventureHistoryActivity : AppCompatActivity() {
private lateinit var binding: ActivityAdventureHistoryBinding
private val viewModel: AdventureHistoryViewModel by viewModels()
private val historyAdapter = AdventureHistoryAdapter()
private val historyAdapter = AdventureHistoryAdapter(::navigateDetail)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand Down Expand Up @@ -58,6 +59,11 @@ class AdventureHistoryActivity : AppCompatActivity() {
historyAdapter.submitList(adventureResults)
}

private fun navigateDetail(gameId: Long) {
val intent = AdventureDetailActivity.getIntentWithId(this, gameId)
startActivity(intent)
}

companion object {
fun getIntent(context: Context): Intent {
return Intent(context, AdventureHistoryActivity::class.java)
Expand Down
Loading