๊ฐœ๋ฐœ ์ผ์ง€ ๐Ÿ’ป/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๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ์—…๋ฐ์ดํŠธํ•œ๋‹ค.

 

#3-6 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 // ๋ถ€ํ˜ธ๋ฅผ ๋ฐ˜์ „์‹œํ‚ค๋ฉด, ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ๋น„๊ตํ•œ ๊ฒƒ๊ณผ ๋™์ผํ•œ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ด
    }
}

์ฐธ๊ณ : ์—ฐ์‚ฐ์ž ์˜ค๋ฒ„๋กœ๋”ฉ (Operator overloading)

 

#3-7 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-8 ๋ฏธ์‚ฌ์šฉ ์ด๋ฒคํŠธ ์‚ญ์ œ

// 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