Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
62ab30f
🍱 Chore: string 리소스 추가
dogmania Feb 9, 2026
c3b0ca1
✨ Feat: CreateGoalResponse -> CreatedGoal Mapper 구현
dogmania Feb 9, 2026
baa20ae
✨ Feat: 목표 생성 API 통신 로직 구현
dogmania Feb 9, 2026
2910aeb
✨ Feat: toApi 변환 메서드 구현
dogmania Feb 9, 2026
8943825
✨ Feat: tryEmit 구현
dogmania Feb 9, 2026
87f2495
✨ Feat: tryEmitSideEffect 메서드 구현
dogmania Feb 9, 2026
06ea3cc
✨ Feat: 목표 생성 비즈니스 로직 연결
dogmania Feb 9, 2026
ec73e2e
✨ Feat: 목표 생성 이후 홈화면으로 네비게이션
dogmania Feb 9, 2026
2dd4a5d
✨ Feat: 리스트 갱신 로직 추가
dogmania Feb 9, 2026
32ab8d2
✨ Feat: CreateGoalParam -> CreateGoalRequest Mapper 구현
dogmania Feb 9, 2026
d8063ef
✨ Feat: 목표 생성 DTO, Domain Model 구현
dogmania Feb 9, 2026
965d4a9
✨ Feat: 목표 생성 응답 DTO 구현
dogmania Feb 9, 2026
866326a
✨ Feat: 목표 생성 응답 Domain Model 구현
dogmania Feb 9, 2026
d181776
♻️ Refactor: Init 제거 및 LaunchedEffect 추가
dogmania Feb 9, 2026
536514f
Merge branch 'develop' of github.com:YAPP-Github/Twix-Android into fe…
dogmania Feb 9, 2026
26b8450
✨ Feat: safeContentPadding 추가
dogmania Feb 9, 2026
481e9a6
🔥 Remove: 더미데이터 제거
dogmania Feb 9, 2026
f552e5f
✨ Feat: UtilModule Koin 컨테이너 등록
dogmania Feb 9, 2026
6c133f6
✨ Feat: GoalRefreshBus 구현
dogmania Feb 9, 2026
131448f
🔥 Remove: RefreshIntent 제거
dogmania Feb 9, 2026
3cf16d6
✨ Feat: Koin 의존성 추가
dogmania Feb 9, 2026
9bf2f1f
✨ Feat: :core:util 의존성 추가
dogmania Feb 9, 2026
81ec510
🔥 Remove: 불필요한 의존성 제거
dogmania Feb 9, 2026
84715e3
✨ Feat: GoalRefreshBus 이벤트 수집 및 상태 처리 로직 구현
dogmania Feb 9, 2026
56be48f
✨ Feat: GoalRefreshBus emit 로직 추가
dogmania Feb 9, 2026
3a53728
♻️ Refactor: repeatCount 기본값 1로 변경
dogmania Feb 9, 2026
33af4ac
🐛 Fix: conflict 해결
dogmania Feb 9, 2026
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
2 changes: 2 additions & 0 deletions app/src/main/java/com/yapp/twix/di/InitKoin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import com.twix.data.di.dataModule
import com.twix.datastore.di.dataStoreModule
import com.twix.network.di.networkModule
import com.twix.util.di.utilModule
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import org.koin.core.module.Module
Expand All @@ -24,6 +25,7 @@ fun initKoin(
add(uiModule)
add(dataStoreModule)
add(appModule)
add(utilModule)
},
)
}
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/com/yapp/twix/main/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
Expand All @@ -30,7 +31,8 @@ class MainActivity : ComponentActivity() {
Box(
modifier =
Modifier
.fillMaxSize(),
.fillMaxSize()
.safeContentPadding(),
) {
AppNavHost()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class FeatureConventionPlugin : BuildLogicConventionPlugin({
implementation(project(":core:design-system"))
implementation(project(":core:navigation"))
implementation(project(":core:ui"))
implementation(project(":core:util"))
implementation(project(":core:result"))
implementation(project(":domain"))
}
Expand Down
1 change: 1 addition & 0 deletions core/design-system/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,6 @@
<!-- 토스트 메시지 -->
<string name="toast_end_date_before_start_date">종료 날짜가 시작 날짜보다 이전입니다.</string>
<string name="toast_goal_fetch_failed">목표 조회에 실패했습니다.</string>
<string name="toast_create_goal_failed">목표 생성에 실패했습니다.</string>

</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.twix.network.model.request.goal.mapper

import com.twix.domain.model.goal.CreateGoalParam
import com.twix.network.model.request.goal.model.CreateGoalRequest

fun CreateGoalParam.toRequest(): CreateGoalRequest =
CreateGoalRequest(
name = name,
icon = icon.toApi(),
repeatCycle = repeatCycle.toApi(),
repeatCount = repeatCount,
startDate = startDate.toString(),
endDate = endDate?.toString(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.twix.network.model.request.goal.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class CreateGoalRequest(
@SerialName("name") val name: String,
@SerialName("icon") val icon: String,
@SerialName("repeatCycle") val repeatCycle: String,
@SerialName("repeatCount") val repeatCount: Int,
@SerialName("startDate") val startDate: String,
@SerialName("endDate") val endDate: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package com.twix.network.model.response.goal.mapper
import com.twix.domain.model.enums.GoalIconType
import com.twix.domain.model.enums.GoalReactionType
import com.twix.domain.model.enums.RepeatCycle
import com.twix.domain.model.goal.CreatedGoal
import com.twix.domain.model.goal.Goal
import com.twix.domain.model.goal.GoalList
import com.twix.domain.model.goal.GoalVerification
import com.twix.network.model.response.goal.model.CreateGoalResponse
import com.twix.network.model.response.goal.model.GoalListResponse
import com.twix.network.model.response.goal.model.GoalResponse
import com.twix.network.model.response.goal.model.VerificationResponse
import java.time.LocalDate

fun GoalListResponse.toDomain(): GoalList =
GoalList(
Expand Down Expand Up @@ -37,3 +40,15 @@ fun VerificationResponse.toDomainOrNull(): GoalVerification? =
reaction = GoalReactionType.fromApi(reaction),
uploadedAt = uploadedAt,
)

fun CreateGoalResponse.toDomain(): CreatedGoal =
CreatedGoal(
goalId = goalId,
name = name,
icon = GoalIconType.fromApi(icon),
repeatCycle = RepeatCycle.fromApi(repeatCycle),
repeatCount = repeatCount,
startDate = LocalDate.parse(startDate),
endDate = endDate?.let(LocalDate::parse),
createdAt = createdAt,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.twix.network.model.response.goal.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class CreateGoalResponse(
Comment on lines +6 to +7
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

서버도 코틀린을 사용하다보니 카멜 케이스를 사용하는게 똑같아서
파라미터 네이밍이 거의 같을 것 같은데 SerialName을 사용하는 것에 대해서 컨벤션을 정해보면 좋을 것 같아 !

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

나중에 서버에서 변수명 변경할 수도 있어서 확장성 고려해서 다 붙여놨어요!

@SerialName("goalId") val goalId: Long,
@SerialName("name") val name: String,
@SerialName("icon") val icon: String,
@SerialName("repeatCycle") val repeatCycle: String,
@SerialName("repeatCount") val repeatCount: Int,
@SerialName("startDate") val startDate: String,
@SerialName("endDate") val endDate: String?,
@SerialName("goalStatus") val goalStatus: String,
@SerialName("createdAt") val createdAt: String,
)
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package com.twix.network.service

import com.twix.network.model.request.goal.model.CreateGoalRequest
import com.twix.network.model.response.goal.model.CreateGoalResponse
import com.twix.network.model.response.goal.model.GoalListResponse
import de.jensklingenberg.ktorfit.http.Body
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.POST
import de.jensklingenberg.ktorfit.http.Query

interface GoalService {
@GET("api/v1/goals")
suspend fun fetchGoals(
@Query("date") date: String,
): GoalListResponse

@POST("api/v1/goals")
suspend fun createGoal(
@Body body: CreateGoalRequest,
): CreateGoalResponse
}
4 changes: 4 additions & 0 deletions core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ abstract class BaseViewModel<S : State, I : Intent, SE : SideEffect>(
sideEffectHolder.emit(effect)
}

protected fun tryEmitSideEffect(effect: SE) {
sideEffectHolder.tryEmit(effect)
}

/**
* Intent를 처리하는 메서드
* */
Expand Down
4 changes: 4 additions & 0 deletions core/ui/src/main/java/com/twix/ui/base/SideEffectHolder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ class SideEffectHolder<S : SideEffect> {
suspend fun emit(effect: S) {
channel.send(effect)
}

fun tryEmit(effect: S) {
channel.trySend(effect)
}
Comment on lines +14 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

토끼 리뷰 참고해서 send와 trySend 어떤걸 사용할지 이야기 해보면 좋을 것 같아 !

나도 요거에 대해서 배포 끝나면 공부해볼태니 이야기 해보는거 어때 ?

}
1 change: 1 addition & 0 deletions core/util/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.twix.android.library)
alias(libs.plugins.twix.koin)
}

android {
Expand Down
15 changes: 15 additions & 0 deletions core/util/src/main/java/com/twix/util/bus/GoalRefreshBus.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.twix.util.bus

import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow

class GoalRefreshBus {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

이 클래스의 역할이 어떤건지 설명해줄 수 있을ㄲ ㅏ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

HomeScreen으로 진입할 때 LaunchedEffect로 매번 API를 부르면 비효율적이니까 다른 NavGraph에서 데이터가 생성되거나 삭제됐음을 알리는 용도입니다! 딱 필요할 때만 데이터를 불러오도록 돕는 역할이에요

private val _events =
MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
)
val events: SharedFlow<Unit> = _events

fun notifyChanged() = _events.tryEmit(Unit)
}
9 changes: 9 additions & 0 deletions core/util/src/main/java/com/twix/util/di/UtilModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.twix.util.di

import com.twix.util.bus.GoalRefreshBus
import org.koin.dsl.module

val utilModule =
module {
single { GoalRefreshBus() }
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.twix.data.repository

import com.twix.domain.model.goal.CreateGoalParam
import com.twix.domain.model.goal.CreatedGoal
import com.twix.domain.model.goal.GoalList
import com.twix.domain.repository.GoalRepository
import com.twix.network.execute.safeApiCall
import com.twix.network.model.request.goal.mapper.toRequest
import com.twix.network.model.response.goal.mapper.toDomain
import com.twix.network.service.GoalService
import com.twix.result.AppResult
Expand All @@ -11,4 +14,9 @@ class DefaultGoalRepository(
private val service: GoalService,
) : GoalRepository {
override suspend fun fetchGoalList(date: String): AppResult<GoalList> = safeApiCall { service.fetchGoals(date).toDomain() }

override suspend fun createGoal(param: CreateGoalParam): AppResult<CreatedGoal> =
safeApiCall {
service.createGoal(param.toRequest()).toDomain()
}
}
12 changes: 12 additions & 0 deletions domain/src/main/java/com/twix/domain/model/enums/GoalIconType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ enum class GoalIconType {
LAPTOP,
;

fun toApi(): String =
when (this) {
DEFAULT -> "ICON_DEFAULT"
CLEAN -> "ICON_CLEAN"
EXERCISE -> "ICON_EXERCISE"
BOOK -> "ICON_BOOK"
PENCIL -> "ICON_PENCIL"
HEALTH -> "ICON_HEALTH"
HEART -> "ICON_HEART"
LAPTOP -> "ICON_LAPTOP"
}

companion object {
fun fromApi(icon: String): GoalIconType =
when (icon) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ enum class RepeatCycle {
MONTHLY,
;

fun toApi(): String = name

companion object {
fun fromApi(value: String): RepeatCycle = runCatching { valueOf(value) }.getOrElse { DAILY }
}
Expand Down
14 changes: 14 additions & 0 deletions domain/src/main/java/com/twix/domain/model/goal/CreateGoalParam.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.twix.domain.model.goal

import com.twix.domain.model.enums.GoalIconType
import com.twix.domain.model.enums.RepeatCycle
import java.time.LocalDate

data class CreateGoalParam(
val name: String,
val icon: GoalIconType,
val repeatCycle: RepeatCycle,
val repeatCount: Int,
val startDate: LocalDate,
val endDate: LocalDate?,
)
16 changes: 16 additions & 0 deletions domain/src/main/java/com/twix/domain/model/goal/CreatedGoal.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.twix.domain.model.goal

import com.twix.domain.model.enums.GoalIconType
import com.twix.domain.model.enums.RepeatCycle
import java.time.LocalDate

data class CreatedGoal(
val goalId: Long,
val name: String,
val icon: GoalIconType,
val repeatCycle: RepeatCycle,
val repeatCount: Int,
val startDate: LocalDate,
val endDate: LocalDate?,
val createdAt: String,
)
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package com.twix.domain.repository

import com.twix.domain.model.goal.CreateGoalParam
import com.twix.domain.model.goal.CreatedGoal
import com.twix.domain.model.goal.GoalList
import com.twix.result.AppResult

interface GoalRepository {
suspend fun fetchGoalList(date: String): AppResult<GoalList>

suspend fun createGoal(param: CreateGoalParam): AppResult<CreatedGoal>
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ fun GoalEditorRoute(
viewModel.sideEffect.collect { effect ->
when (effect) {
is GoalEditorSideEffect.ShowToast -> toastManager.tryShow(ToastData(currentContext.getString(effect.resId), effect.type))
is GoalEditorSideEffect.NavigateToHome -> navigateToBack()
}
}
}
Expand Down Expand Up @@ -317,7 +318,7 @@ private fun RepeatCountBottomSheetContent(
.padding(horizontal = 12.dp, vertical = 5.5.dp)
.noRippleClickable(onClick = {
internalSelectedRepeatType = RepeatCycle.WEEKLY
internalRepeatCount = 0
internalRepeatCount = 1
}),
)

Expand All @@ -333,7 +334,7 @@ private fun RepeatCountBottomSheetContent(
.padding(horizontal = 12.dp, vertical = 5.5.dp)
.noRippleClickable(onClick = {
internalSelectedRepeatType = RepeatCycle.MONTHLY
internalRepeatCount = 0
internalRepeatCount = 1
}),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ interface GoalEditorSideEffect : SideEffect {
@param:StringRes val resId: Int,
val type: ToastType,
) : GoalEditorSideEffect

object NavigateToHome : GoalEditorSideEffect
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import com.twix.designsystem.R
import com.twix.designsystem.components.toast.model.ToastType
import com.twix.domain.model.enums.GoalIconType
import com.twix.domain.model.enums.RepeatCycle
import com.twix.domain.model.goal.CreateGoalParam
import com.twix.domain.repository.GoalRepository
import com.twix.goal_editor.model.GoalEditorUiState
import com.twix.ui.base.BaseViewModel
import com.twix.util.bus.GoalRefreshBus
import kotlinx.coroutines.launch
import java.time.LocalDate

class GoalEditorViewModel :
BaseViewModel<GoalEditorUiState, GoalEditorIntent, GoalEditorSideEffect>(
class GoalEditorViewModel(
private val goalRepository: GoalRepository,
private val goalRefreshBus: GoalRefreshBus,
) : BaseViewModel<GoalEditorUiState, GoalEditorIntent, GoalEditorSideEffect>(
GoalEditorUiState(),
) {
override suspend fun handleIntent(intent: GoalEditorIntent) {
Expand All @@ -38,7 +43,7 @@ class GoalEditorViewModel :
}

private fun setRepeatType(repeatCycle: RepeatCycle) {
reduce { copy(selectedRepeatCycle = repeatCycle) }
reduce { copy(selectedRepeatCycle = repeatCycle, repeatCount = 1) }
}

private fun setRepeatCount(repeatCount: Int) {
Expand Down Expand Up @@ -68,5 +73,24 @@ class GoalEditorViewModel :
}
return
}

launchResult(
block = { goalRepository.createGoal(currentState.toCreateParam()) },
onSuccess = {
goalRefreshBus.notifyChanged()
tryEmitSideEffect(GoalEditorSideEffect.NavigateToHome)
},
onError = { emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_create_goal_failed, ToastType.ERROR)) },
)
Comment on lines +80 to +88
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the GoalEditorViewModel file and search for sideEffectHolder
find . -name "GoalEditorViewModel.kt" -type f

Repository: YAPP-Github/Twix-Android

Length of output: 147


🏁 Script executed:

# Search for MutableSharedFlow configuration in the codebase
rg "MutableSharedFlow|sideEffectHolder" --type kotlin -B 2 -A 2

Repository: YAPP-Github/Twix-Android

Length of output: 3386


🏁 Script executed:

# Check for ViewModel base class that might define sideEffectHolder
fd -e kt -path "*/ViewModel.kt" | head -20

Repository: YAPP-Github/Twix-Android

Length of output: 236


🏁 Script executed:

# Find SideEffectHolder implementation
rg "class SideEffectHolder" --type kotlin -A 10

Repository: YAPP-Github/Twix-Android

Length of output: 989


🏁 Script executed:

# Also check the BaseViewModel for more context
cat core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt

Repository: YAPP-Github/Twix-Android

Length of output: 4374


tryEmitSideEffect 사용 시 이벤트 유실 가능성이 존재합니다.

네비게이션처럼 반드시 전달되어야 하는 NavigateToHome 이벤트에 tryEmitSideEffect를 사용하는 것이 문제입니다. SideEffectHolder는 내부적으로 Channel(Channel.BUFFERED) (용량: 64)를 사용하는데, tryEmit()은 버퍼가 가득 차면 이벤트를 조용히 실패시킵니다. 용량이 64로 충분하지만, 버퍼 오버플로우 상황에서는 사용자가 화면 전환 없이 멈춰있을 수 있습니다.

반면 onError에서 emitSideEffect(suspend)를 사용하고 있어 불일치가 발생합니다.

개선 방안:
onSuccess 콜백 내에서 viewModelScope.launch { emitSideEffect(...) }로 감싸면 suspend 호출이 가능해지고, 버퍼가 가득 찬 경우에도 안전하게 대기할 수 있습니다:

개선 예시
launchResult(
    block = { goalRepository.createGoal(currentState.toCreateParam()) },
    onSuccess = {
        goalRefreshBus.notifyChanged()
        viewModelScope.launch {
            emitSideEffect(GoalEditorSideEffect.NavigateToHome)
        }
    },
    onError = { emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_create_goal_failed, ToastType.ERROR)) },
)

launchResultonSuccess 시그니처를 suspend 람다로 변경하는 것도 근본적인 해결 방안입니다.

🤖 Prompt for AI Agents
In
`@feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorViewModel.kt`
around lines 80 - 88, The onSuccess callback uses tryEmitSideEffect which can
silently drop the NavigateToHome event; change it to call the suspend
emitSideEffect inside a coroutine so the event is delivered reliably — e.g.,
inside the onSuccess body use viewModelScope.launch {
emitSideEffect(GoalEditorSideEffect.NavigateToHome) } instead of
tryEmitSideEffect; alternatively, make launchResult's onSuccess parameter a
suspend lambda and call emitSideEffect directly. Ensure references:
launchResult, onSuccess, tryEmitSideEffect, emitSideEffect, viewModelScope,
GoalEditorSideEffect.NavigateToHome.

}

private fun GoalEditorUiState.toCreateParam(): CreateGoalParam =
CreateGoalParam(
name = goalTitle.trim(),
icon = selectedIcon,
repeatCycle = selectedRepeatCycle,
repeatCount = repeatCount,
startDate = startDate,
endDate = if (endDateEnabled) endDate else null,
)
}
Loading