나만의 안드로이드 앱 만들기/초급자

나만의 안드로이드 앱 만들기(초보자 편) - 풀 사이클 및 마무리(9)

Victorywskim 2023. 12. 21. 14:45
반응형

이번 편에서는 지금까지 해왔던 UI 구성부터 데이터 연동까지 전반적으로 작업을 진행해보려고 합니다.

 

지금까지 진행했던 작업을 요약하면 다음과 같습니다.

  1. 더치 페이 글쓰기 기능 추가
  2. 더치 페이 글쓰기 기능 추가
  3. 메인 - 홈 탭 내 더치 페이 목록 추가

마무리가 필요한 작업들은 다음과 같습니다.

  1. 메인 - 홈 탭 내 기능 완료
  2. 더치 페이 정산 기능 추가
  3. 메인 - 저장소 탭 내 더치 페이 이력 목록 추가

기존 코드를 수정하면서 새 코드를 추가하다보니 설명이 복잡해질 우려가 있어서 아래 github 의 소스에 대한 참고 자료로 읽어주시길 바랍니다.

 

GitHub - tmvlke/SimpleDutch: 심플더치 프로젝트 입니다.

심플더치 프로젝트 입니다. Contribute to tmvlke/SimpleDutch development by creating an account on GitHub.

github.com

 

1. 메인 - 홈 탭 내 기능 완료

 

진행할 작업은 다음과 같습니다.

  1. 전체 금액 노출하기
  2. 정산 페이지로 이동 가능한 버튼 추가

 

1. 전체 금액 노출하기

 

전체 금액 노출하는 방법은 compose 에 상태를 연결하고 뷰모델에서 상태를 관리하면서 유즈케이스에서 레파지토리 및 데이터소스 를 통해 공급 받은 데이터를 상태에 주입하는 과정입니다.

 

뷰 하나에 데이터를 주입하기 위해서는 다음과 같은 절차를 진행합니다.

 

HomeScreen.kt

...

BaseScreen(
    screenState = state.screenState,
    body = {
        // 총 가격
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(150.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "전체 금액: ${CharFormatUtils.amount(state.totalAmount.value)}",
                fontSize = 30.sp
            )
        }
        
        ...

 

HomeViewModel.kt

private fun onResume() {
    setState {
        copy(
            totalAmount = mutableIntStateOf(dutchInfoUseCase.findDutchTotalAmount()),
            list = dutchInfoUseCase.findDutchInfoList().toMutableStateList()
        )
    }
}

 

DutchInfoUseCase.kt

// 더치 페이 정산 전 전체 가격
fun findDutchTotalAmount(): Int {
    return dutchInfoRepository.selectDutchTotalAmount()
}

 

DutchInfoRepository.kt

fun selectDutchTotalAmount(): Int

 

DutchInfoRepositoryImpl.kt

override fun selectDutchTotalAmount(): Int {
    return dutchInfoDataSource.selectDutchTotalAmount()
}

 

DutchInfoDataSource.kt

fun selectDutchTotalAmount(): Int

 

DutchInfoDataSourceImpl.kt

override fun selectDutchTotalAmount(): Int {
    return selectDutchInfoList().map { it.amount }.sumOf { it.toInt() }
}

 

 

 

2. 정산 페이지로 이동 가능한 버튼 추가

 

 

정산 페이지로 이동하기 위해서는 뷰에서 사용자의 버튼 클릭이라는 이벤트를 입력 받고 처리하는 과정이 필요합니다.

 

HomeScreen.kt

BaseScreen(
    screenState = state.screenState,
    body = {
        // 총 가격
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(150.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "전체 금액: ${CharFormatUtils.amount(state.totalAmount.value)}",
                fontSize = 30.sp
            )
        }

        ...

            Button(
                onClick = { onEventSent(HomeContract.Event.HomeEndButtonClicked) },
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(1f)
                    .padding(start = 10.dp),
                colors = ButtonDefaults.buttonColors(containerColor = Gray)
            ) {
                Box(
                    modifier = Modifier.height(30.dp),
                    contentAlignment = Alignment.Center
                ) {
                    Text(text = "정산하기", fontSize = 16.sp, fontWeight = FontWeight.Bold)
                }
            }
        }

        HomeList(state.list)
    }
)

 

HomeContract.kt

class HomeContract {
    sealed class Event : BaseViewEvent {
        ...
        object HomeEndButtonClicked : Event()
    }
    
    ...
    
    sealed class Effect : BaseViewSideEffect {
        sealed class Toast : Effect() {
            object ShowListEmpty : Toast()
        }
        sealed class Navigation : Effect() {
            ...
            object GoToHomeEnd : Navigation()
        }
    }
}

 

HomeViewModel.kt

override fun handleEvents(event: HomeContract.Event) {
    when (event) {
        ...
        is HomeContract.Event.HomeEndButtonClicked -> homeEndButtonClicked()
    }
}

...

private fun homeEndButtonClicked() {
    if (viewState.value.list.isEmpty()) {
        setEffect { HomeContract.Effect.Toast.ShowListEmpty }
        return
    }

    setEffect { HomeContract.Effect.Navigation.GoToHomeEnd }
}

 

HomePage.kt

fun HomePage(actions: PageMoveActions, navBackStackEntry: NavBackStackEntry) {
    val coroutineScope: CoroutineScope = rememberCoroutineScope()
    val context = LocalContext.current
    val viewModel: HomeViewModel = viewModel(
        factory = HiltViewModelFactory(context, navBackStackEntry)
    )

    HomeScreen(
        state = viewModel.viewState.value,
        effectFlow = viewModel.effect,
        onEventSent = { event -> viewModel.setEvent(event) },
        onNavigationRequested = { navigationEffect ->
            coroutineScope.launch {

                when (navigationEffect) {
                    ...
                    is HomeContract.Effect.Navigation.GoToHomeEnd -> actions.gotoHomeEnd.invoke()
                }
            }
        }
    )
}

 

PageList.kt

sealed class PageList : SnPage {
    
    ...

    object HomeEnd : SnPage {
        override val description = "홈 정산 페이지"
        override val route = "HomeEnd"
    }
}

 

PageMoveActions.kt

class PageMoveActions(navController: NavController) :
    Common,
    Main,
    Home
{
    ...

    override val gotoHomeEnd: () -> Unit = {
        navController.navigate(PageList.HomeEnd.route)
    }
}

...

interface Home : BasePageMoveActions {
    ...
    val gotoHomeEnd: () -> Unit
}

 

NavGraph.kt

fun NavGraph() {
    val navController = rememberNavController()
    val actions = remember(navController) { PageMoveActions(navController) }


    NavHost(
        navController,
        startDestination = PageList.startDestination,
    ) {
        ...

        // 홈 정산 페이지
        buildTopToBottomPage(PageList.HomeEnd.route) {
            HomeEndPage(actions, it)
        }
    }
}

 

 

2. 더치 페이 정산 기능 추가

 

정산 기능 추가의 경우에도 지금까지의 패턴과 동일하기 때문에 주요 로직만 소개해드리려합니다.

 

우선 SharedPreferencesManager 안에 다음 메소드 2가지를 추가하였습니다.

// 리스트 안에 리스트가 있는 경우 json 처리가 번거롭기 때문에 문자열을 변환하기 위함
private fun <T> encode(data: T): String {
    val encodedBytes = Base64.encode(Gson().toJson(data).toByteArray(), Base64.DEFAULT)
    return Uri.encode(String(encodedBytes))
}

// 리스트 안에 리스트가 있는 경우 json 처리가 번거롭기 때문에 문자열을 변환하기 위함
private inline fun <reified T> decode(data: String): T {
    val decodedBytes = Base64.decode(Uri.decode(data).toByteArray(), Base64.DEFAULT)
    val type = object : TypeToken<T>() {}.type
    return Gson().fromJson(String(decodedBytes), type)
}

 

이 메소드는 다음과 같이 사용하기 위함입니다.

fun saveDutchInfo(list: ArrayList<DutchListItemVO>) {
    putValueToPrivateAppPrefs(encode(list), SP_HOME_DUTCH_LIST_ITEM)
}

fun getDutchInfoList(): ArrayList<DutchListItemVO> {
    val type = object : TypeToken<List<DutchListItemVO>>() {}.type
    return privateAppPrefs.getString(SP_HOME_DUTCH_LIST_ITEM, "")?.let {
        decode(it)
    }?: arrayListOf()
}

 

list 안에 list 가 있는 객체를 json 화 된 문자열로 바꿔서 저장하는 로직이 있습니다.

이 경우 다시 json 파싱하는 과정에서 문제가 발생 할 수 있습니다.

그래서 문자열을 저장하기 전에 문자열을 encode, decode 하여 파싱에 문제 없게 하는 작업을 진행하였습니다.

 

이 기능부터 Repository 와 DataSource 의 역할이 좀 더 명확해졌습니다.

 

지금까지는 DataSource 에서 조회한 데이터를  Repository 가 단순히 넘겨주는 작업만 진행하였습니다.

 

그래서 Repository 가 굳이 있어야 하나? 이런 생각이 드셨을 수도 있습니다.

 

DutchInfoRepositoryImpl.kt

override fun selectDutchEndInfoList(): ArrayList<DutchEndListItemVO> {
    val list = dutchInfoDataSource.selectDutchEndInfoList()
    list.sortBy { it.name }
    return list
}

 

DutchInfoDataSourceImpl.kt

override fun selectDutchEndInfoList(): ArrayList<DutchEndListItemVO> {
    val list = selectDutchInfoList()
    val saveMap = HashMap<String, ArrayList<DutchListItemVO>>()
    val resultList = ArrayList<DutchEndListItemVO>()

    // HashMap 으로 중복 제거 및 기본 틀 체우기
    list.onEach { itemVO ->
        itemVO.enterPersonList.onEach { itemDetailVO ->
            if (saveMap[itemDetailVO.name] == null) {
                saveMap[itemDetailVO.name] = arrayListOf(
                    DutchListItemVO(
                        title = itemVO.title,
                        amount = itemVO.amount,
                        enterPersonList = itemVO.enterPersonList
                    )
                )
            } else {
                saveMap[itemDetailVO.name]!!.apply {
                    add(
                        DutchListItemVO(
                            title = itemVO.title,
                            amount = itemVO.amount,
                            enterPersonList = itemVO.enterPersonList
                        )
                    )
                }
            }
        }
    }

    // 기본 틀을 가지고 정산 여부 체우기
    val saveMapToList = saveMap.toList()
    saveMapToList.onEach { mapData ->

        var isEnd = true
        mapData.second.onEach {

            it.enterPersonList.onEach { personVO ->
                if (mapData.first == personVO.name && !personVO.isEnd) {
                    isEnd = false
                }
            }
        }

        resultList.add(
            DutchEndListItemVO(
                name = mapData.first,
                list = mapData.second,
                isEnd = isEnd
            )
        )
    }

    return resultList
}

 

DataSource 는 특정 카테고리의 데이터 자체를 특정한 목적에 따라 생성하여 반환하는 역할을 수행합니다.

Repository 는 DataSource 가 반환해준 데이터가 손상되지 않는 선에서 정렬하거나 다른 DataSource 가 반환해준 데이터를 조합하고 반환하는 역할을 수행합니다.

 

이 관점으로 코드를 읽어주시면 이해가 어렵지 않으실 것으로 생각합니다.

 

 

3. 메인 - 저장소 탭 내 더치 페이 이력 목록 추가

 

 

 

저장소 탭 내 더치 페이 이력 목록 추가 작업의 경우에도 1, 2번 작업들을 기반으로 작업이 이루어졌고, 코드를 읽어보시는데 크게 어려움이 없으실 것으로 예상됩니다.

 

 

마무리하며

 

 

지금까지 Compose 를 이용하여 MVI 를 적용한 싱글 모듈 프로젝트를 작업해보았습니다.

 

설명이 부족한 부분들은 댓글을 남겨주시면 언제든지 답변드릴 예정이며, 문서도 같이 보완하도록 하겠습니다.

 

감사합니다.

반응형