App 개발 일지/Nutri Capture

Nutri Capture 프론트엔드 - 채팅UI 구조 잡기

interfacer_han 2024. 11. 14. 00:01

#1 개요

이전 게시글에서 채팅 UI 도입을 위해 가상 테이블 DayMealView를 만들었다. 백엔드 작업이 끝났으므로, Day 테이블 및 Meal 테이블을 참조하던 프론트가 이제는 DayMealView 테이블을 참조하도록 만들어야 한다.

 

#2 코드 - 백엔드 깨알 변경 사항

#2-1 DAO에서 getAllDayMeals(limit: Int) 선언

...

@Dao
interface MainDAO {
    ...

    @Query("SELECT * FROM DayMealView LIMIT :limit")
    suspend fun getAllDayMeals(limit: Int): List<DayMealView>

    ...
}

존재하는 모든 레코드를 가져오는 getAllDayMeals()의 메소드 오버로딩이다. getAllDayMeals() 바로 아래에 선언한다.

 

#2-2 Repository에서도 선언

...

class MainRepository(private val dao: MainDAO) {
    ...

    suspend fun getAllDayMeals(limit: Int): List<DayMealView> {
        return dao.getAllDayMeals(limit)
    }

    ...
}

DAO에서 선언한 함수를 참조하는 함수다.

 

#3 코드 - View에서 표시할 State 변경

#3-1 NutrientScreenState

// package com.example.nutri_capture_new.nutrient

import androidx.compose.runtime.snapshots.SnapshotStateList
import com.example.nutri_capture_new.db.DayMealView

data class NutrientScreenState(
    val dayMeals: SnapshotStateList<DayMealView>
)

가상 테이블 DayMealView의 각 아이템이 담긴 리스트만을 선언한다.

 

#3-2 ViewModel의 내부 프로퍼티

...

class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
    // (1) 화면 표시용 State
    private val _nutrientScreenState = mutableStateOf(
        NutrientScreenState(
            dayMeals = SnapshotStateList()
        )
    )
    val nutrientScreenState: State<NutrientScreenState>
        get() = _nutrientScreenState

    ...
}

#3-1 변경 사항에 맞춰 ViewModel를 수정한다.

 

#3-3 InitializeState 이벤트 변경

...

class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
    ...

    // (4) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            is NutrientViewModelEvent.InitializeState -> {
                viewModelScope.launch {
                    _nutrientScreenState.value.dayMeals.apply {
                        clear()
                        addAll(repository.getAllDayMeals(10))
                    }
                }
                _isInitialized.value = true
            }

            ...
        }
    }

    // (5) DayMealView 작동 확인용 로그
    ...
}

ViewModel.onEvent()의 분기 중 하나인 NutrientViewModelEvent.InitializeState를 변경한다.

 

#3-4 LoadMoreItemsAfterLastDate 이벤트 이름 및 동작 변경

...

class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
    ...

    // (4) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            ...

            is NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal -> {
                viewModelScope.launch {
                    val lastDayMeal = _nutrientScreenState.value.dayMeals.last()
                    _nutrientScreenState.value.dayMeals.addAll(
                        repository.getNextDayMealsAfter(lastDayMeal, 10)
                    )
                }
            }

            ...
        }
    }

    // (5) DayMealView 작동 확인용 로그
    ...
}

LoadMoreItemsAfterLastDate의 이름을 LoadMoreItemsAfterLastDayMeal로 바꾼다 (당연하겠지만 변수명 클릭 후 Shift + F6 눌러서 프로젝트 내의 모든 LoadMoreItemsAfterLastDate를 일괄적으로 변경한다). 또, 이벤트 내용도 위와 같이 변경한다.

 

#3-5 InsertMeal 이벤트 변경

...

class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
    ...

    // (4) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            ...

            is NutrientViewModelEvent.InsertMeal -> {
                viewModelScope.launch {
                    var insertedMealId = -1L
                    insertedMealId = repository.insertMeal(event.meal, event.date)
                    if (insertedMealId != -1L) {
                        val insertedDayMeal = repository.getDayMeal(insertedMealId)
                        val index =
                            findIndexToInsert(_nutrientScreenState.value.dayMeals, insertedDayMeal)
                        _nutrientScreenState.value.dayMeals.add(index, insertedDayMeal)
                    }
                }
            }

            ...
        }
    }

    // (5) DayMealView 작동 확인용 로그
    ...
}

private fun findIndexToInsert(list: SnapshotStateList<DayMealView>, newItem: DayMealView): Int {
    for(i: Int in list.indices) {
        if(newItem < list[i]) {
            return i
        }
    }
    // 삽입할 위치가 없다면 맨 끝(list.size)에 삽입
    return list.size
}

원래 if (insertedMealId != -0L) { ... } 였다. 삽입 실패 시 반환되는 값은 0이 아니라 -1므로 위와 같이 수정했다.

 

findIndexToInsert는 굉장히 단순한 삽입 알고리즘이다. 나중에 리스트의 길이가 길어지면 많은 자원을 먹게될 것이다. 지금은 넘어가겠지만 추후 이진 탐색을 응용한 방식으로 리팩토링한다.

 

또, DayMealView 간 대소비교를 하고 있는데 원래라면 불가능한 연산이다. 이를 가능케하기 위해서 DayMealView를 아래와 같이 업데이트한다.

 

// package com.example.nutri_capture_new.db
...

@DatabaseView("""
    ...
""")
data class DayMealView(
    ...
) {
    operator fun compareTo(otherDayMealView: DayMealView): Int {
        return compareValuesBy(this, otherDayMealView,
            { it.date }, // (1차) date에 대해 오름차순으로 비교 (date가 큰 쪽이 비교 우위)
            { it.time }, // (2차) time에 대해 오름차순으로 비교 (time이 큰 쪽이 비교 우위)
            { it.mealId } // (3차) mealId에 대해 오름차순으로 비교 (mealId가 큰 쪽이 비교 우위)
        ) * -1 // 부호를 반전시키면, 내림차순으로 비교한 것과 동일한 결과가 나옴
    }
}

 

#3-6 DeleteMeal 이벤트 변경

...

class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
    ...

    // (4) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            ...

            is NutrientViewModelEvent.DeleteMeal -> {
                viewModelScope.launch {
                    var deletedRowCount = 0
                    deletedRowCount = repository.deleteMeal(event.meal)
                    if (deletedRowCount == 1) {
                        val deletedMealId = event.meal.mealId
                        _nutrientScreenState.value.dayMeals.removeIf { it.mealId == deletedMealId }
                    }
                }
            }
        }
    }

    // (5) DayMealView 작동 확인용 로그
    ...
}

...

 

#3-7 미사용 이벤트 삭제

// package com.example.nutri_capture_new.nutrient

import com.example.nutri_capture_new.db.Meal
import java.time.LocalDate

sealed class NutrientViewModelEvent {
    data object InitializeState : NutrientViewModelEvent()
    data object LoadMoreItemsAfterLastDayMeal : NutrientViewModelEvent()
    data class InsertMeal(val meal: Meal, val date: LocalDate) : NutrientViewModelEvent()
    data class DeleteMeal(val meal: Meal, val date: LocalDate) : NutrientViewModelEvent()
}

LoadMoreItemsBeforeFirstDate와 GetMealsByDate 분기를 onEvent() 분기문에서 삭제한다. 그리고, NutrientViewModelEvent에서도 제거한다.

 

#4 코드 - View (NutrientScreen)

#4-1 INSERT를 수행하는 임시 함수

...

@Composable
fun NutrientScreen(
    ...
) {
    LaunchedEffect(key1 = true) {
        ...
    }

    LaunchedEffect(key1 = true) {
        repeat(5) {
            viewModel.onEvent(
                NutrientViewModelEvent.InsertMeal(
                    meal = Meal(
                        time = LocalTime.now(),
                        name = "test",
                        nutritionInfo = NutritionInfo()
                    ),
                    date = LocalDate.now()
                )
            )
        }
    }

    LaunchedEffect(key1 = viewModel.isInitialized.value) {
        ...
    }

    LazyColumn(
        ...
    ) {
        ...
    }
}

#4-3에서, 원래는 존재했던 Meal 추가 버튼이 없어지므로 이렇게라도 구현한다. 앱이 실행되면 1초 간격으로 Meal이 하나씩 INSERT될 것이다. Meal 추가 버튼을 다시 만드는 것과 동시에 삭제할 함수다.

 

#4-2 역방향 무한 스크롤 삭제

...

@Composable
fun NutrientScreen(
    ...
) {
    LaunchedEffect(key1 = true) {
        ...
    }

    LaunchedEffect(key1 = true) {
        ...
    }

    LaunchedEffect(key1 = viewModel.isInitialized.value) {
        if (viewModel.isInitialized.value) {
            // 무한 스크롤
            snapshotFlow { listState.layoutInfo.visibleItemsInfo }.collect { visibleItemsInfo ->
                val totalMaxIndex = listState.layoutInfo.totalItemsCount - 1
                val firstVisibleItemIndex = listState.firstVisibleItemIndex
                val visibleItemCount = visibleItemsInfo.size

                if (totalMaxIndex <= firstVisibleItemIndex + visibleItemCount) {
                    viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal)
                }
            }
        }
    }

    LazyColumn(
        ...
    ) {
        ...
    }
}

채팅 UI는 양방향 무한 스크롤이 없다. 원래 있던 역방향 무한 스크롤 코드를 제거하고 순방향 무한 스크롤 코드만 남긴다. 역방향 무한 스크롤이 혹시 나중에라도 필요하면 다시 구현하겠다.

 

#4-3 LazyColumn 변경

...

@Composable
fun NutrientScreen(
    ...
) {
    LaunchedEffect(key1 = true) {
        ...
    }

    LaunchedEffect(key1 = true) {
        ...
    }

    LaunchedEffect(key1 = viewModel.isInitialized.value) {
        ...
    }

    LazyColumn(
        ...
        reverseLayout = true
    ) {
        val dayMeals = viewModel.nutrientScreenState.value.dayMeals
        itemsIndexed(dayMeals) { index, dayMeal ->
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(
                        start = 8.dp,
                        top = if (index == dayMeals.lastIndex) 8.dp else 0.dp,
                        end = 8.dp,
                        bottom = 8.dp
                    ),
                elevation = CardDefaults.cardElevation(8.dp)
            ) {
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(8.dp)
                ) {
                    Text(
                        text = DateFormatter.formatDateForNutrientScreen(dayMeal.date) + " " + dayMeal.time,
                        modifier = Modifier.fillMaxWidth(),
                        fontSize = 15.sp,
                        textAlign = TextAlign.End
                    )

                    Text(
                        text = "mealId: ${dayMeal.mealId}, index: $index",
                        modifier = Modifier.fillMaxWidth(),
                        fontSize = 30.sp,
                        textAlign = TextAlign.Center
                    )
                }
            }
        }
    }
}

LazyColumn의 reverseLayout 프로퍼티에 true를 대입한다. 이제 LazyColumn의 아이템의 순서는 뒤집힐 것이고, 순방향 무한스크롤의 방향도 (위 → 아래)에서 (아래 → 위)로 뒤집힐 것이다.

 

#5 요약

가상 테이블 DayMealView를 참조하도록 ViewModel 및 View를 변경했다.

 

#6 완성된 앱

#6-1 문제점

앱을 실행시키면 바로 튕긴다. 실행이 안되는 버전을 Commit으로 등록하는 게 약간 껄끄럽다. 하지만, 본 게시글이 과도하게 길어지기에 이 에러의 해결 과정은 다음 게시글에서 다루겠다.

 

#6-2 이 게시글 시점의 Commit

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com

 

#6-3 본 프로젝트의 가장 최신 Commit

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com