diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c20ce0f..3b47b44 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -14,8 +14,8 @@ android {
applicationId = "com.combo.runcombi"
minSdk = 26
targetSdk = 35
- versionCode = 108
- versionName = "1.0.8"
+ versionCode = 109
+ versionName = "1.0.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -71,6 +71,9 @@ dependencies {
ksp(libs.hilt.compiler)
implementation(libs.hilt.android)
implementation(libs.v2.user)
+
+ // Wear OS 연동
+ implementation(libs.play.services.wearable)
// Firebase
implementation(platform(libs.firebase.bom))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 47e16ba..43b626a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -53,6 +53,15 @@
android:scheme="kakao${kakaoApiKey}" />
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/core/data/auth/src/main/java/com/combo/runcombi/auth/AuthTokenProvider.kt b/core/data/auth/src/main/java/com/combo/runcombi/auth/AuthTokenProvider.kt
index f3ec0fe..73f4993 100644
--- a/core/data/auth/src/main/java/com/combo/runcombi/auth/AuthTokenProvider.kt
+++ b/core/data/auth/src/main/java/com/combo/runcombi/auth/AuthTokenProvider.kt
@@ -11,7 +11,10 @@ class AuthTokenProvider @Inject constructor(
private val authDataSource: AuthDataSource,
) : TokenProvider {
override suspend fun getAccessToken(): String? {
- return authDataSource.getAccessToken().firstOrNull()
+ android.util.Log.d("AuthTokenProvider", "getAccessToken 호출")
+ val token = authDataSource.getAccessToken().firstOrNull()
+ android.util.Log.d("AuthTokenProvider", "getAccessToken 결과: ${token?.take(20)}...")
+ return token
}
override suspend fun getRefreshToken(): String? {
diff --git a/core/data/user/src/prod/java/com/combo/runcombi/data/user/repository/UserRepositoryImpl.kt b/core/data/user/src/prod/java/com/combo/runcombi/data/user/repository/UserRepositoryImpl.kt
index e1b309b..240db36 100644
--- a/core/data/user/src/prod/java/com/combo/runcombi/data/user/repository/UserRepositoryImpl.kt
+++ b/core/data/user/src/prod/java/com/combo/runcombi/data/user/repository/UserRepositoryImpl.kt
@@ -24,9 +24,15 @@ import javax.inject.Inject
class UserRepositoryImpl @Inject constructor(private val userService: UserService) :
UserRepository {
override suspend fun getUserInfo(): DomainResult = handleResult {
- userService.getUserInfo()
+ android.util.Log.d("UserRepositoryImpl", "getUserInfo API 호출 시작")
+ val response = userService.getUserInfo()
+ android.util.Log.d("UserRepositoryImpl", "getUserInfo API 응답: ${response.code()}, ${response.body()}")
+ response
}.convert {
- it.toDomainModel()
+ android.util.Log.d("UserRepositoryImpl", "getUserInfo 데이터 변환 시작: $it")
+ val result = it.toDomainModel()
+ android.util.Log.d("UserRepositoryImpl", "getUserInfo 데이터 변환 완료: $result")
+ result
}
override suspend fun setUserTerms(agreeTerms: List): DomainResult = handleResult {
diff --git a/core/data/walk/src/main/java/com/combo/runcombi/walk/repository/WalkRepositoryImpl.kt b/core/data/walk/src/main/java/com/combo/runcombi/walk/repository/WalkRepositoryImpl.kt
index 6809f40..a42c0c5 100644
--- a/core/data/walk/src/main/java/com/combo/runcombi/walk/repository/WalkRepositoryImpl.kt
+++ b/core/data/walk/src/main/java/com/combo/runcombi/walk/repository/WalkRepositoryImpl.kt
@@ -3,6 +3,7 @@ package com.combo.runcombi.walk.repository
import com.combo.runcombi.common.DomainResult
import com.combo.runcombi.common.convert
import com.combo.runcombi.common.handleResult
+import com.combo.runcombi.network.model.request.MidRunUpdateRequest
import com.combo.runcombi.network.model.request.StartRunRequest
import com.combo.runcombi.network.model.response.MemberRunData
import com.combo.runcombi.network.model.response.PetId
@@ -73,4 +74,18 @@ class WalkRepositoryImpl @Inject constructor(private val walkService: WalkServic
routeImage = routeImagePart,
)
}.convert {}
+
+ override suspend fun midRunUpdate(
+ runId: Int,
+ runTime: Int,
+ runDistance: Double,
+ ): DomainResult = handleResult {
+ walkService.requestMidRunUpdate(
+ MidRunUpdateRequest(
+ runId = runId,
+ runTime = runTime,
+ runDistance = runDistance
+ )
+ )
+ }.convert {}
}
\ No newline at end of file
diff --git a/core/domain/auth/src/main/java/com/combo/runcombi/auth/usecase/GetAccessTokenUseCase.kt b/core/domain/auth/src/main/java/com/combo/runcombi/auth/usecase/GetAccessTokenUseCase.kt
index d48ea92..4946412 100644
--- a/core/domain/auth/src/main/java/com/combo/runcombi/auth/usecase/GetAccessTokenUseCase.kt
+++ b/core/domain/auth/src/main/java/com/combo/runcombi/auth/usecase/GetAccessTokenUseCase.kt
@@ -3,14 +3,15 @@ package com.combo.runcombi.auth.usecase
import com.combo.runcombi.auth.repository.AuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
-class GetIsNewUserUseCase @Inject constructor(
+class GetAccessTokenUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
- operator fun invoke(): Boolean = runBlocking(Dispatchers.IO) {
- authRepository.getAccessToken().first() == null
+ operator fun invoke(): String? = runBlocking(Dispatchers.IO) {
+ authRepository.getAccessToken().firstOrNull()
}
}
\ No newline at end of file
diff --git a/core/domain/walk/src/main/java/com/combo/runcombi/walk/repository/WalkRepository.kt b/core/domain/walk/src/main/java/com/combo/runcombi/walk/repository/WalkRepository.kt
index 806c024..fc31bc0 100644
--- a/core/domain/walk/src/main/java/com/combo/runcombi/walk/repository/WalkRepository.kt
+++ b/core/domain/walk/src/main/java/com/combo/runcombi/walk/repository/WalkRepository.kt
@@ -16,4 +16,10 @@ interface WalkRepository {
routeImage: File?,
): DomainResult
+ suspend fun midRunUpdate(
+ runId: Int,
+ runTime: Int,
+ runDistance: Double,
+ ): DomainResult
+
}
\ No newline at end of file
diff --git a/core/domain/walk/src/main/java/com/combo/runcombi/walk/usecase/MidRunUpdateUseCase.kt b/core/domain/walk/src/main/java/com/combo/runcombi/walk/usecase/MidRunUpdateUseCase.kt
new file mode 100644
index 0000000..42b52f2
--- /dev/null
+++ b/core/domain/walk/src/main/java/com/combo/runcombi/walk/usecase/MidRunUpdateUseCase.kt
@@ -0,0 +1,17 @@
+package com.combo.runcombi.walk.usecase
+
+import com.combo.runcombi.common.DomainResult
+import com.combo.runcombi.walk.repository.WalkRepository
+import javax.inject.Inject
+
+class MidRunUpdateUseCase @Inject constructor(
+ private val walkRepository: WalkRepository
+) {
+ suspend operator fun invoke(
+ runId: Int,
+ runTime: Int,
+ runDistance: Double,
+ ): DomainResult {
+ return walkRepository.midRunUpdate(runId, runTime, runDistance)
+ }
+}
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index 65426d1..420e377 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -53,11 +53,12 @@ dependencies {
implementation(libs.bundles.network)
implementation(libs.kotlin.serialization.json)
implementation(libs.bundles.coroutines)
+ implementation(libs.play.services.wearable)
ksp(libs.hilt.compiler)
implementation(libs.hilt.android)
}
fun getBaseUrl(): String {
- return gradleLocalProperties(rootDir, providers).getProperty("BASE_URL") ?: ""
+ return gradleLocalProperties(rootDir, providers).getProperty("BASE_URL") ?: "http://api.runcombi.site/"
}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/combo/runcombi/network/interceptor/TokenInterceptor.kt b/core/network/src/main/java/com/combo/runcombi/network/interceptor/TokenInterceptor.kt
index 30e3071..2ecbe19 100644
--- a/core/network/src/main/java/com/combo/runcombi/network/interceptor/TokenInterceptor.kt
+++ b/core/network/src/main/java/com/combo/runcombi/network/interceptor/TokenInterceptor.kt
@@ -20,15 +20,20 @@ class TokenInterceptor @Inject constructor(
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
+ android.util.Log.d("TokenInterceptor", "API 요청 시작: ${chain.request().url}")
+
val newRequest = chain.request().newBuilder().apply {
runBlocking {
val token = tokenProvider.getAccessToken()
+ android.util.Log.d("TokenInterceptor", "토큰 확인: ${token?.take(20)}...")
token?.let {
addHeader("Authorization", "Bearer $it")
+ android.util.Log.d("TokenInterceptor", "Authorization 헤더 추가됨")
}
}
}
+ android.util.Log.d("TokenInterceptor", "실제 API 호출 시작")
val response = chain.proceed(newRequest.build())
when (response.code) {
diff --git a/core/network/src/main/java/com/combo/runcombi/network/model/request/MidRunUpdateRequest.kt b/core/network/src/main/java/com/combo/runcombi/network/model/request/MidRunUpdateRequest.kt
new file mode 100644
index 0000000..c3d847c
--- /dev/null
+++ b/core/network/src/main/java/com/combo/runcombi/network/model/request/MidRunUpdateRequest.kt
@@ -0,0 +1,10 @@
+package com.combo.runcombi.network.model.request
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MidRunUpdateRequest(
+ val runId: Int,
+ val runTime: Int,
+ val runDistance: Double
+)
diff --git a/core/network/src/main/java/com/combo/runcombi/network/service/WalkService.kt b/core/network/src/main/java/com/combo/runcombi/network/service/WalkService.kt
index a82734f..bb6c10c 100644
--- a/core/network/src/main/java/com/combo/runcombi/network/service/WalkService.kt
+++ b/core/network/src/main/java/com/combo/runcombi/network/service/WalkService.kt
@@ -1,6 +1,7 @@
package com.combo.runcombi.network.service
import com.combo.runcombi.network.model.request.KakaoLoginRequest
+import com.combo.runcombi.network.model.request.MidRunUpdateRequest
import com.combo.runcombi.network.model.request.StartRunRequest
import com.combo.runcombi.network.model.response.DefaultResponse
import com.combo.runcombi.network.model.response.LoginResponse
@@ -28,4 +29,9 @@ interface WalkService {
@Part("petRunData") petRunData: RequestBody,
@Part routeImage: MultipartBody.Part?,
): Response
+
+ @POST("api/run/midRunUpdate")
+ suspend fun requestMidRunUpdate(
+ @Body request: MidRunUpdateRequest,
+ ): Response
}
\ No newline at end of file
diff --git a/core/wear/consumer-rules.pro b/core/wear/consumer-rules.pro
new file mode 100644
index 0000000..089bab1
--- /dev/null
+++ b/core/wear/consumer-rules.pro
@@ -0,0 +1 @@
+# Consumer ProGuard rules for core:wear module
diff --git a/feature/history/src/main/java/com/combo/runcombi/history/screen/AddRecordScreen.kt b/feature/history/src/main/java/com/combo/runcombi/history/screen/AddRecordScreen.kt
index 08d8473..d945068 100644
--- a/feature/history/src/main/java/com/combo/runcombi/history/screen/AddRecordScreen.kt
+++ b/feature/history/src/main/java/com/combo/runcombi/history/screen/AddRecordScreen.kt
@@ -1,4 +1,4 @@
-@file:OptIn(ExperimentalMaterial3Api::class)
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class)
package com.combo.runcombi.history.screen
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -37,6 +38,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -280,9 +283,9 @@ fun AddRecordContent(
}
},
value = distanceText,
- onValueChange = {
- distanceText = it
- updateDistance(it)
+ onValueChange = { newValue ->
+ distanceText = newValue
+ updateDistance(newValue)
},
placeholder = "",
trailingText = "km",
@@ -291,6 +294,10 @@ fun AddRecordContent(
singleLine = true,
leadingText = "거리",
textAlign = TextAlign.End,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Decimal,
+ imeAction = ImeAction.Next
+ ),
)
Spacer(Modifier.width(12.dp))
RunCombiTextField(
@@ -309,9 +316,9 @@ fun AddRecordContent(
}
},
value = timeText,
- onValueChange = {
- timeText = it
- updateTime(it)
+ onValueChange = { newValue ->
+ timeText = newValue
+ updateTime(newValue)
},
placeholder = "",
trailingText = "min",
@@ -320,6 +327,10 @@ fun AddRecordContent(
singleLine = true,
leadingText = "시간",
textAlign = TextAlign.End,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done
+ ),
)
}
Spacer(Modifier.height(24.dp))
diff --git a/feature/history/src/main/java/com/combo/runcombi/history/screen/EditRecordScreen.kt b/feature/history/src/main/java/com/combo/runcombi/history/screen/EditRecordScreen.kt
index 8ec35be..e9eac54 100644
--- a/feature/history/src/main/java/com/combo/runcombi/history/screen/EditRecordScreen.kt
+++ b/feature/history/src/main/java/com/combo/runcombi/history/screen/EditRecordScreen.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -35,6 +36,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -246,9 +249,9 @@ fun EditRecordContent(
}
},
value = distanceText,
- onValueChange = {
- distanceText = it
- updateDistance(it)
+ onValueChange = { newValue ->
+ distanceText = newValue
+ updateDistance(newValue)
},
placeholder = "",
trailingText = "km",
@@ -257,6 +260,10 @@ fun EditRecordContent(
singleLine = true,
leadingText = "거리",
textAlign = TextAlign.End,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Decimal,
+ imeAction = ImeAction.Next
+ ),
)
Spacer(Modifier.width(12.dp))
RunCombiTextField(
@@ -275,9 +282,9 @@ fun EditRecordContent(
}
},
value = timeText,
- onValueChange = {
- timeText = it
- updateTime(it)
+ onValueChange = { newValue ->
+ timeText = newValue
+ updateTime(newValue)
},
placeholder = "",
trailingText = "min",
@@ -286,6 +293,10 @@ fun EditRecordContent(
singleLine = true,
leadingText = "시간",
textAlign = TextAlign.End,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done
+ ),
)
}
Spacer(Modifier.height(24.dp))
diff --git a/feature/history/src/main/java/com/combo/runcombi/history/screen/RecordScreen.kt b/feature/history/src/main/java/com/combo/runcombi/history/screen/RecordScreen.kt
index c35b49b..038658f 100644
--- a/feature/history/src/main/java/com/combo/runcombi/history/screen/RecordScreen.kt
+++ b/feature/history/src/main/java/com/combo/runcombi/history/screen/RecordScreen.kt
@@ -181,7 +181,7 @@ fun RecordContent(
RecordImagePager(imagePaths = uiState.imagePaths, onAddPhoto = onAddPhoto)
RecordAppBar(
date = uiState.date,
- hasRunImage = uiState.imagePaths.size == 2,
+ hasRunImage = uiState.imagePaths.isNotEmpty(),
onBack = onBack,
onEdit = onEdit,
onAddPhoto = onAddPhoto,
diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts
index 70c0f32..0043eea 100644
--- a/feature/main/build.gradle.kts
+++ b/feature/main/build.gradle.kts
@@ -11,12 +11,16 @@ android {
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.bundles.lifecycle)
+ implementation(libs.play.services.wearable)
testImplementation(libs.bundles.test)
androidTestImplementation(libs.bundles.android.test)
implementation(libs.androidx.core.splashscreen)
+ // Wear OS - 비활성화
+ // implementation(libs.play.services.wearable)
+
implementation(project(":feature:login"))
implementation(project(":feature:signup"))
implementation(project(":feature:history"))
diff --git a/feature/main/src/main/java/com/combo/runcombi/main/MainActivity.kt b/feature/main/src/main/java/com/combo/runcombi/main/MainActivity.kt
index 2bbe5af..a1d8e09 100644
--- a/feature/main/src/main/java/com/combo/runcombi/main/MainActivity.kt
+++ b/feature/main/src/main/java/com/combo/runcombi/main/MainActivity.kt
@@ -7,7 +7,8 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-import com.combo.runcombi.auth.usecase.GetIsNewUserUseCase
+// import androidx.lifecycle.lifecycleScope // Wear 기능 비활성화로 사용하지 않음
+import com.combo.runcombi.auth.usecase.GetAccessTokenUseCase
import com.combo.runcombi.core.designsystem.theme.RunCombiTheme
import com.combo.runcombi.core.navigation.model.MainTabDataModel
import com.combo.runcombi.core.navigation.model.RouteModel
@@ -15,7 +16,10 @@ import com.combo.runcombi.domain.user.model.MemberStatus
import com.combo.runcombi.domain.user.usecase.GetUserStatusUseCase
import com.combo.runcombi.main.navigation.MainNavigator
import com.combo.runcombi.main.navigation.rememberMainNavigator
+// import com.combo.runcombi.main.wear.WearConnectionManager // Wear 기능 비활성화
import dagger.hilt.android.AndroidEntryPoint
+// import kotlinx.coroutines.Dispatchers // Wear 기능 비활성화
+// import kotlinx.coroutines.launch // Wear 기능 비활성화
import javax.inject.Inject
@AndroidEntryPoint
@@ -25,7 +29,10 @@ class MainActivity : ComponentActivity() {
lateinit var getUserStatusUseCase: GetUserStatusUseCase
@Inject
- lateinit var getIsNewUserUseCase: GetIsNewUserUseCase
+ lateinit var getAccessTokenUseCase: GetAccessTokenUseCase
+
+ // @Inject
+ // lateinit var wearConnectionManager: WearConnectionManager // Wear 기능 비활성화
private var status: MemberStatus? = null
@@ -38,10 +45,23 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.light(Color.BLACK, Color.BLACK)
)
- val isNew = getIsNewUserUseCase()
+ val isNew = getAccessTokenUseCase() == null
if (!isNew) {
status = getUserStatusUseCase()
+
+ // Wear 동기화 및 데이터 전송 - 비활성화
+ /*
+ if (status == MemberStatus.LIVE) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ wearConnectionManager.syncUserDataToWear()
+ } catch (e: Exception) {
+ android.util.Log.e("MainActivity", "Wear 동기화 실패", e)
+ }
+ }
+ }
+ */
}
setContent {
@@ -57,5 +77,9 @@ class MainActivity : ComponentActivity() {
}
}
}
+
+ override fun onDestroy() {
+ super.onDestroy()
+ }
}
diff --git a/feature/main/src/main/java/com/combo/runcombi/main/wear/WearConnectionManager.kt b/feature/main/src/main/java/com/combo/runcombi/main/wear/WearConnectionManager.kt
new file mode 100644
index 0000000..46b8b7b
--- /dev/null
+++ b/feature/main/src/main/java/com/combo/runcombi/main/wear/WearConnectionManager.kt
@@ -0,0 +1,183 @@
+package com.combo.runcombi.main.wear
+
+import com.combo.runcombi.auth.usecase.GetAccessTokenUseCase
+import com.google.android.gms.wearable.DataEvent
+import com.google.android.gms.wearable.DataEventBuffer
+import com.google.android.gms.wearable.DataMapItem
+import com.google.android.gms.wearable.WearableListenerService
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import javax.inject.Inject
+
+
+class WearConnectionManager @Inject constructor(
+ private val wearSyncService: WearSyncService,
+ private val getAccessTokenUseCase: GetAccessTokenUseCase,
+) {
+
+ suspend fun syncUserDataToWear() {
+ try {
+ val accessToken = getAccessTokenUseCase()
+
+ if (accessToken != null) {
+ android.util.Log.d("WearConnectionManager", "Syncing access token to wear")
+ wearSyncService.sendTokensToWear(accessToken)
+ android.util.Log.d("WearConnectionManager", "Access token synced successfully")
+ } else {
+ android.util.Log.w("WearConnectionManager", "No access token available to sync")
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("WearConnectionManager", "Failed to sync user data to wear", e)
+ }
+ }
+
+}
+
+class MobileWearDataListenerService : WearableListenerService() {
+
+ private lateinit var dataClient: com.google.android.gms.wearable.DataClient
+
+ override fun onCreate() {
+ super.onCreate()
+ android.util.Log.d("MobileWearDataListenerService", "Service created")
+
+ try {
+ dataClient = com.google.android.gms.wearable.Wearable.getDataClient(this)
+ android.util.Log.d("MobileWearDataListenerService", "DataClient initialized")
+
+ // 연결된 워치 확인
+ checkConnectedWearables()
+ } catch (e: Exception) {
+ android.util.Log.e("MobileWearDataListenerService", "Failed to initialize DataClient", e)
+ }
+ }
+
+ private fun checkConnectedWearables() {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val nodeClient = com.google.android.gms.wearable.Wearable.getNodeClient(this@MobileWearDataListenerService)
+ val connectedNodes = suspendCancellableCoroutine> { continuation ->
+ nodeClient.connectedNodes.addOnCompleteListener { task ->
+ if (task.isSuccessful) {
+ continuation.resume(task.result)
+ } else {
+ continuation.resumeWithException(task.exception ?: Exception("Failed to get connected nodes"))
+ }
+ }
+ }
+
+ android.util.Log.d("MobileWearDataListenerService", "연결된 워치 수: ${connectedNodes.size}")
+ connectedNodes.forEach { node ->
+ android.util.Log.d("MobileWearDataListenerService", "연결된 워치: ${node.displayName} (${node.id})")
+ }
+
+ if (connectedNodes.isEmpty()) {
+ android.util.Log.w("MobileWearDataListenerService", "연결된 워치가 없습니다")
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("MobileWearDataListenerService", "워치 연결 확인 실패", e)
+ }
+ }
+ }
+
+ override fun onDataChanged(dataEvents: DataEventBuffer) {
+ super.onDataChanged(dataEvents)
+
+ android.util.Log.d("MobileWearDataListenerService", "onDataChanged called with ${dataEvents.count} events")
+
+ dataEvents.forEach { event ->
+ if (event.type == DataEvent.TYPE_CHANGED && event.dataItem.uri.path == "/authRequest") {
+ android.util.Log.d("MobileWearDataListenerService", "Received auth request from wear")
+
+ // 로그인 상태 확인
+ val isLoggedIn = checkLoginStatus()
+ android.util.Log.d("MobileWearDataListenerService", "Login status: $isLoggedIn")
+
+ if (isLoggedIn) {
+ // 토큰이 있으면 토큰 전송
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ sendTokenToWear()
+ android.util.Log.d("MobileWearDataListenerService", "Token sent to wear")
+ } catch (e: Exception) {
+ android.util.Log.e("MobileWearDataListenerService", "Failed to send token to wear", e)
+ }
+ }
+ } else {
+ // 로그인 상태 응답 전송
+ val putDataReq = com.google.android.gms.wearable.PutDataMapRequest.create("/authStatus").apply {
+ dataMap.putBoolean("isLoggedIn", false)
+ }.asPutDataRequest().setUrgent()
+
+ dataClient.putDataItem(putDataReq).addOnSuccessListener {
+ android.util.Log.d("MobileWearDataListenerService", "Login status response sent to watch")
+ }.addOnFailureListener { e ->
+ android.util.Log.e("MobileWearDataListenerService", "Failed to send login status response", e)
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun sendTokenToWear() {
+ try {
+ android.util.Log.d("MobileWearDataListenerService", "Sending token to wear...")
+
+ // 실제 토큰을 가져오는 로직 (SharedPreferences 또는 다른 저장소에서)
+ val accessToken = getStoredAccessToken()
+
+ if (accessToken != null) {
+ val dataMap = com.google.android.gms.wearable.DataMap().apply {
+ putString("access_token", accessToken)
+ putLong("timestamp", System.currentTimeMillis())
+ }
+
+ val putDataMapRequest = com.google.android.gms.wearable.PutDataMapRequest.create("/auth_token")
+ putDataMapRequest.dataMap.putAll(dataMap)
+
+ android.util.Log.d("MobileWearDataListenerService", "Sending auth token to wear device...")
+
+ kotlinx.coroutines.suspendCancellableCoroutine { continuation ->
+ dataClient.putDataItem(putDataMapRequest.asPutDataRequest())
+ .addOnSuccessListener { dataItem ->
+ android.util.Log.d("MobileWearDataListenerService", "Auth token sent successfully to wear")
+ continuation.resume(dataItem)
+ }
+ .addOnFailureListener { exception ->
+ android.util.Log.e("MobileWearDataListenerService", "Failed to send auth token to wear", exception)
+ continuation.resumeWithException(exception)
+ }
+ }
+ } else {
+ android.util.Log.w("MobileWearDataListenerService", "No access token available to send")
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("MobileWearDataListenerService", "Failed to send token to wear", e)
+ }
+ }
+
+ private fun checkLoginStatus(): Boolean {
+ val token = getStoredAccessToken()
+ val isLoggedIn = token != null
+ android.util.Log.d("MobileWearDataListenerService", "Login status check: $isLoggedIn")
+ return isLoggedIn
+ }
+
+ private fun getStoredAccessToken(): String? {
+ // SharedPreferences에서 토큰을 가져오는 로직
+ val sharedPref = getSharedPreferences("auth_prefs", android.content.Context.MODE_PRIVATE)
+ val token = sharedPref.getString("access_token", null)
+ android.util.Log.d("MobileWearDataListenerService", "저장된 토큰 확인: ${token != null}")
+ if (token != null) {
+ android.util.Log.d("MobileWearDataListenerService", "토큰: ${token.take(20)}...")
+ } else {
+ android.util.Log.w("MobileWearDataListenerService", "저장된 토큰이 없습니다")
+ }
+ return token
+ }
+}
diff --git a/feature/main/src/main/java/com/combo/runcombi/main/wear/WearSyncService.kt b/feature/main/src/main/java/com/combo/runcombi/main/wear/WearSyncService.kt
new file mode 100644
index 0000000..c6d1c45
--- /dev/null
+++ b/feature/main/src/main/java/com/combo/runcombi/main/wear/WearSyncService.kt
@@ -0,0 +1,46 @@
+package com.combo.runcombi.main.wear
+
+import android.content.Context
+import com.google.android.gms.wearable.DataClient
+import com.google.android.gms.wearable.DataMap
+import com.google.android.gms.wearable.PutDataMapRequest
+import com.google.android.gms.wearable.Wearable
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WearSyncService @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val dataClient: DataClient = Wearable.getDataClient(context)
+
+ suspend fun sendTokensToWear(accessToken: String) {
+ android.util.Log.d("WearSyncService", "Sending tokens to wear...")
+
+ val dataMap = DataMap().apply {
+ putString("access_token", accessToken)
+ putLong("timestamp", System.currentTimeMillis())
+ }
+
+ val putDataMapRequest = PutDataMapRequest.create("/auth_token")
+ putDataMapRequest.dataMap.putAll(dataMap)
+
+ android.util.Log.d("WearSyncService", "Sending auth token to wear device...")
+
+ suspendCancellableCoroutine { continuation ->
+ dataClient.putDataItem(putDataMapRequest.asPutDataRequest())
+ .addOnSuccessListener { dataItem ->
+ android.util.Log.d("WearSyncService", "Auth token sent successfully to wear")
+ continuation.resume(dataItem)
+ }
+ .addOnFailureListener { exception ->
+ android.util.Log.e("WearSyncService", "Failed to send auth token to wear", exception)
+ continuation.resumeWithException(exception)
+ }
+ }
+ }
+}
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/model/AddPetUiState.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/model/AddPetUiState.kt
index 743b5ce..ac284cc 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/model/AddPetUiState.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/model/AddPetUiState.kt
@@ -13,11 +13,13 @@ data class PetInfoUiState(
val weight: String = "",
val isButtonEnabled: Boolean = false,
val isError: Boolean = false,
- val errorMessage: String = ""
+ val errorMessage: String = "",
+ val isAgeError: Boolean = false,
+ val isWeightError: Boolean = false
)
data class PetStyleUiState(
- val selectedStyle: RunStyle = RunStyle.RUNNING,
- val isButtonEnabled: Boolean = true,
+ val selectedStyle: RunStyle? = null,
+ val isButtonEnabled: Boolean = false,
val isLoading: Boolean = false
)
\ No newline at end of file
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/model/EditMemberUiState.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/model/EditMemberUiState.kt
index b773cc3..546f730 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/model/EditMemberUiState.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/model/EditMemberUiState.kt
@@ -13,4 +13,5 @@ data class EditMemberUiState(
val isWeightError: Boolean = false,
val isButtonEnabled: Boolean = false,
val isLoading: Boolean = false,
+ val hasChanges: Boolean = false,
)
\ No newline at end of file
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/model/EditPetUiState.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/model/EditPetUiState.kt
index a85afcd..84268ea 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/model/EditPetUiState.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/model/EditPetUiState.kt
@@ -13,5 +13,6 @@ data class EditPetUiState(
val isAgeError: Boolean = false,
val isWeightError: Boolean = false,
val isLoading: Boolean = false,
- val isRemovable: Boolean = true
+ val isRemovable: Boolean = true,
+ val hasChanges: Boolean = false,
)
\ No newline at end of file
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AddPetInfoScreen.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AddPetInfoScreen.kt
index f687fb6..da58bdc 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AddPetInfoScreen.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AddPetInfoScreen.kt
@@ -59,12 +59,13 @@ fun AddPetInfoScreen(
RunCombiTextField(
value = uiState.age,
maxLength = 2,
+ placeholder = "5",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
onValueChange = { petInfoViewModel.onAgeChange(it) },
modifier = Modifier.width(134.dp),
- isError = uiState.isError && uiState.errorMessage.contains("나이"),
+ isError = uiState.isAgeError,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
enabled = true,
singleLine = true,
@@ -78,12 +79,13 @@ fun AddPetInfoScreen(
RunCombiTextField(
value = uiState.weight,
maxLength = 4,
+ placeholder = "5.5",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
onValueChange = { petInfoViewModel.onWeightChange(it) },
modifier = Modifier.width(134.dp),
- isError = uiState.isError && uiState.errorMessage.contains("몸무게"),
+ isError = uiState.isWeightError,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
enabled = true,
singleLine = true,
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AddPetStyleScreen.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AddPetStyleScreen.kt
index a220453..f260d77 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AddPetStyleScreen.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AddPetStyleScreen.kt
@@ -122,13 +122,15 @@ fun AddPetStyleScreen(
keyboardController?.hide()
localFocusManager.clearFocus()
- addPetViewModel.setPetStyle(PetStyleData(walkStyle = uiState.selectedStyle))
+ uiState.selectedStyle?.let { selectedStyle ->
+ addPetViewModel.setPetStyle(PetStyleData(walkStyle = selectedStyle))
- val addPetData = addPetViewModel.getAddPetData()
- petStyleViewModel.addPet(addPetData)
+ val addPetData = addPetViewModel.getAddPetData()
+ petStyleViewModel.addPet(addPetData)
+ }
},
text = "완료",
- enabled = uiState.isButtonEnabled && !uiState.isLoading
+ enabled = uiState.isButtonEnabled && !uiState.isLoading && uiState.selectedStyle != null
)
}
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/EditMemberScreen.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/EditMemberScreen.kt
index c837101..297cdd6 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/EditMemberScreen.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/EditMemberScreen.kt
@@ -176,7 +176,8 @@ fun EditMemberContent(
) {
EditMemberAppBar(
onCancel = onCancel,
- onSave = onSave
+ onSave = onSave,
+ isSaveEnabled = uiState.hasChanges
)
Column(
@@ -320,6 +321,7 @@ fun EditMemberContent(
fun EditMemberAppBar(
onCancel: () -> Unit,
onSave: () -> Unit,
+ isSaveEnabled: Boolean = true,
) {
Box(
modifier = Modifier
@@ -350,8 +352,12 @@ fun EditMemberAppBar(
Text(
text = "저장",
style = title4,
- color = Primary01,
- modifier = Modifier.clickableWithoutRipple { onSave() }
+ color = if (isSaveEnabled) Primary01 else Primary03,
+ modifier = if (isSaveEnabled) {
+ Modifier.clickableWithoutRipple { onSave() }
+ } else {
+ Modifier
+ }
)
}
}
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/EditPetScreen.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/EditPetScreen.kt
index 5695593..014a62b 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/EditPetScreen.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/EditPetScreen.kt
@@ -65,6 +65,7 @@ import com.combo.runcombi.core.designsystem.theme.Grey05
import com.combo.runcombi.core.designsystem.theme.Grey06
import com.combo.runcombi.core.designsystem.theme.Grey08
import com.combo.runcombi.core.designsystem.theme.Primary01
+import com.combo.runcombi.core.designsystem.theme.Primary03
import com.combo.runcombi.core.designsystem.theme.RunCombiTypography.body1
import com.combo.runcombi.core.designsystem.theme.RunCombiTypography.body2
import com.combo.runcombi.core.designsystem.theme.RunCombiTypography.body3
@@ -209,7 +210,8 @@ fun EditPetContent(
) {
EditPetAppBar(
onCancel = onCancel,
- onSave = onSave
+ onSave = onSave,
+ isSaveEnabled = uiState.hasChanges
)
Column(
@@ -370,6 +372,7 @@ fun EditPetContent(
fun EditPetAppBar(
onCancel: () -> Unit,
onSave: () -> Unit,
+ isSaveEnabled: Boolean = true,
) {
Box(
modifier = Modifier
@@ -400,8 +403,12 @@ fun EditPetAppBar(
Text(
text = "저장",
style = title4,
- color = Primary01,
- modifier = Modifier.clickableWithoutRipple { onSave() }
+ color = if (isSaveEnabled) Primary01 else Primary03,
+ modifier = if (isSaveEnabled) {
+ Modifier.clickableWithoutRipple { onSave() }
+ } else {
+ Modifier
+ }
)
}
}
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/EditMemberViewModel.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/EditMemberViewModel.kt
index d9850cc..83feefb 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/EditMemberViewModel.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/EditMemberViewModel.kt
@@ -34,6 +34,13 @@ class EditMemberViewModel @Inject constructor(
private val _eventFlow = MutableSharedFlow()
val eventFlow = _eventFlow.asSharedFlow()
+ // 초기 데이터를 저장하기 위한 변수들
+ private var initialName: String = ""
+ private var initialHeight: String = ""
+ private var initialWeight: String = ""
+ private var initialGender: Gender = Gender.MALE
+ private var initialProfileImageUrl: String = ""
+
init {
getMemberProfile()
}
@@ -45,6 +52,13 @@ class EditMemberViewModel @Inject constructor(
when (result) {
is DomainResult.Success -> {
with(result.data.member) {
+ // 초기 데이터 저장
+ initialName = nickname
+ initialHeight = height.toString()
+ initialWeight = weight.toString()
+ initialGender = gender
+ initialProfileImageUrl = profileImageUrl ?: ""
+
_uiState.update {
it.copy(
name = nickname,
@@ -52,7 +66,8 @@ class EditMemberViewModel @Inject constructor(
weight = weight.toString(),
gender = gender,
profileImageUrl = profileImageUrl ?: "",
- isLoading = false
+ isLoading = false,
+ hasChanges = false
)
}
}
@@ -71,6 +86,18 @@ class EditMemberViewModel @Inject constructor(
}
}
+ // 수정사항이 있는지 확인하는 함수
+ private fun checkForChanges() {
+ val currentState = _uiState.value
+ val hasChanges = currentState.name != initialName ||
+ currentState.height != initialHeight ||
+ currentState.weight != initialWeight ||
+ currentState.gender != initialGender ||
+ _profileBitmap.value != null
+
+ _uiState.update { it.copy(hasChanges = hasChanges) }
+ }
+
fun onNameChange(newName: String) {
_uiState.update {
it.copy(
@@ -79,6 +106,29 @@ class EditMemberViewModel @Inject constructor(
isButtonEnabled = isFormValid(newName, it.height, it.weight)
)
}
+ checkForChanges()
+ }
+
+ private fun filterInvalidChars(input: String): String {
+ // 한글과 영문만 허용
+ return input.filter { char ->
+ char in '가'..'힣' || char in 'a'..'z' || char in 'A'..'Z'
+ }
+ }
+
+ private fun applyLengthLimit(input: String): String {
+ if (input.isBlank()) return input
+
+ val isKoreanOnly = isKorean(input)
+ val isEnglishOnly = isEnglish(input)
+ val isMixed = isMixed(input)
+
+ return when {
+ isKoreanOnly -> input.take(5)
+ isEnglishOnly -> input.take(7)
+ isMixed -> input.take(7)
+ else -> input
+ }
}
fun onHeightChange(newHeight: String) {
@@ -90,6 +140,7 @@ class EditMemberViewModel @Inject constructor(
isButtonEnabled = isFormValid(it.name, filtered, it.weight)
)
}
+ checkForChanges()
}
fun onWeightChange(newWeight: String) {
@@ -101,16 +152,19 @@ class EditMemberViewModel @Inject constructor(
isButtonEnabled = isFormValid(it.name, it.height, filtered)
)
}
+ checkForChanges()
}
fun selectGender(gender: Gender) {
_uiState.update {
it.copy(gender = gender)
}
+ checkForChanges()
}
fun setProfileBitmap(bitmap: Bitmap) {
_profileBitmap.value = bitmap
+ checkForChanges()
}
fun saveMemberInfo(memberImage: File?) {
@@ -180,9 +234,9 @@ class EditMemberViewModel @Inject constructor(
val isMixed = isMixed(name)
return when {
- isMixed && name.length > 5 -> true
- isKoreanOnly && name.length > 10 -> true
+ isKoreanOnly && name.length > 5 -> true
isEnglishOnly && name.length > 7 -> true
+ isMixed && name.length > 7 -> true
!isKoreanOnly && !isEnglishOnly && !isMixed -> true
else -> false
}
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/EditPetViewModel.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/EditPetViewModel.kt
index b7c322f..2618929 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/EditPetViewModel.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/EditPetViewModel.kt
@@ -41,6 +41,12 @@ class EditPetViewModel @Inject constructor(
private val _eventFlow = MutableSharedFlow()
val eventFlow = _eventFlow.asSharedFlow()
+ // 초기 데이터를 저장하기 위한 변수들
+ private var initialName: String = ""
+ private var initialAge: String = ""
+ private var initialWeight: String = ""
+ private var initialRunStyle: RunStyle = RunStyle.RUNNING
+ private var initialProfileImageUrl: String = ""
fun getMemberProfile(petId: Int) {
viewModelScope.launch {
@@ -53,6 +59,13 @@ class EditPetViewModel @Inject constructor(
}
pet?.let { petData ->
+ // 초기 데이터 저장
+ initialName = petData.name
+ initialAge = petData.age.toString()
+ initialWeight = petData.weight.toString()
+ initialRunStyle = petData.runStyle
+ initialProfileImageUrl = petData.profileImageUrl ?: ""
+
_uiState.update {
it.copy(
name = petData.name,
@@ -61,7 +74,8 @@ class EditPetViewModel @Inject constructor(
profileImageUrl = petData.profileImageUrl ?: "",
runStyle = petData.runStyle,
isLoading = false,
- isRemovable = result.data.petList.size >= 2
+ isRemovable = result.data.petList.size >= 2,
+ hasChanges = false
)
}
}
@@ -80,6 +94,18 @@ class EditPetViewModel @Inject constructor(
}
}
+ // 수정사항이 있는지 확인하는 함수
+ private fun checkForChanges() {
+ val currentState = _uiState.value
+ val hasChanges = currentState.name != initialName ||
+ currentState.age != initialAge ||
+ currentState.weight != initialWeight ||
+ currentState.runStyle != initialRunStyle ||
+ _profileBitmap.value != null
+
+ _uiState.update { it.copy(hasChanges = hasChanges) }
+ }
+
fun savePetInfo(petImage: File?, petId: Int) {
val currentState = _uiState.value
@@ -163,6 +189,7 @@ class EditPetViewModel @Inject constructor(
fun setProfileBitmap(bitmap: Bitmap) {
_profileBitmap.value = bitmap
+ checkForChanges()
}
fun onNameChange(newName: String) {
@@ -172,6 +199,60 @@ class EditPetViewModel @Inject constructor(
isNameError = false
)
}
+ checkForChanges()
+ }
+
+ private fun filterInvalidChars(input: String): String {
+ // 한글과 영문만 허용
+ return input.filter { char ->
+ char in '가'..'힣' || char in 'a'..'z' || char in 'A'..'Z'
+ }
+ }
+
+ private fun applyLengthLimit(input: String): String {
+ if (input.isBlank()) return input
+
+ val isKoreanOnly = isKorean(input)
+ val isEnglishOnly = isEnglish(input)
+ val isMixed = isMixed(input)
+
+ return when {
+ isKoreanOnly -> input.take(5)
+ isEnglishOnly -> input.take(7)
+ isMixed -> input.take(7)
+ else -> input
+ }
+ }
+
+ private fun isKorean(input: String) = input.matches(Regex("^[가-힣]+$"))
+ private fun isEnglish(input: String) = input.matches(Regex("^[a-zA-Z]+$"))
+ private fun isMixed(input: String): Boolean {
+ val hasKorean = input.any { it in '\uAC00'..'\uD7A3' }
+ val hasEnglish = input.any { it.isLetter() && (it in 'a'..'z' || it in 'A'..'Z') }
+ return hasKorean && hasEnglish
+ }
+
+ private fun validateName(name: String): Boolean {
+ if (name.isBlank()) return true
+
+ if (containsInvalidChars(name)) return true
+
+ val isKoreanOnly = isKorean(name)
+ val isEnglishOnly = isEnglish(name)
+ val isMixed = isMixed(name)
+
+ return when {
+ isKoreanOnly && name.length > 5 -> true
+ isEnglishOnly && name.length > 7 -> true
+ isMixed && name.length > 7 -> true
+ !isKoreanOnly && !isEnglishOnly && !isMixed -> true
+ else -> false
+ }
+ }
+
+ private fun containsInvalidChars(input: String): Boolean {
+ val regex = Regex("^[가-힣a-zA-Z]+$")
+ return !regex.matches(input)
}
fun onSelectRunStyle(runStyle: RunStyle) {
@@ -180,9 +261,22 @@ class EditPetViewModel @Inject constructor(
runStyle = runStyle
)
}
+ checkForChanges()
}
fun onAgeChange(newAge: String) {
+ // 빈 문자열이면 바로 반환
+ if (newAge.isEmpty()) {
+ _uiState.update {
+ it.copy(
+ age = "",
+ isAgeError = false
+ )
+ }
+ checkForChanges()
+ return
+ }
+
val filtered = newAge.filter { it.isDigit() }
_uiState.update {
it.copy(
@@ -190,9 +284,21 @@ class EditPetViewModel @Inject constructor(
isAgeError = false
)
}
+ checkForChanges()
}
fun onWeightChange(newWeight: String) {
+ if (newWeight.isEmpty()) {
+ _uiState.update {
+ it.copy(
+ weight = "",
+ isWeightError = false
+ )
+ }
+ checkForChanges()
+ return
+ }
+
var filtered = newWeight.filter { it.isDigit() || it == '.' }
val dotCount = filtered.count { it == '.' }
if (dotCount > 1) {
@@ -203,16 +309,14 @@ class EditPetViewModel @Inject constructor(
val parts = filtered.split('.')
filtered = parts[0] + "." + parts.getOrNull(1)?.take(1).orEmpty()
}
+
_uiState.update {
it.copy(
weight = filtered,
isWeightError = false
)
}
- }
-
- private fun validateName(name: String): Boolean {
- return name.isBlank() || name.length < 2 || name.length > 10
+ checkForChanges()
}
private fun validateAge(age: String): Boolean {
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/PetInfoViewModel.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/PetInfoViewModel.kt
index c5b5406..22414c9 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/PetInfoViewModel.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/PetInfoViewModel.kt
@@ -13,7 +13,9 @@ class PetInfoViewModel : ViewModel() {
weight = "",
isButtonEnabled = false,
isError = false,
- errorMessage = ""
+ errorMessage = "",
+ isAgeError = false,
+ isWeightError = false
)
)
val uiState: StateFlow = _uiState
@@ -25,7 +27,8 @@ class PetInfoViewModel : ViewModel() {
age = filtered,
isButtonEnabled = isValid(filtered, it.weight),
isError = false,
- errorMessage = ""
+ errorMessage = "",
+ isAgeError = false
)
}
}
@@ -44,9 +47,10 @@ class PetInfoViewModel : ViewModel() {
_uiState.update {
it.copy(
weight = filtered,
- isButtonEnabled = isValid(it.age, filtered),
+ isButtonEnabled = isValid(filtered, it.age),
isError = false,
- errorMessage = ""
+ errorMessage = "",
+ isWeightError = false
)
}
}
@@ -54,23 +58,56 @@ class PetInfoViewModel : ViewModel() {
fun validateAndProceed(onSuccess: () -> Unit) {
val age = uiState.value.age
val weight = uiState.value.weight
- val (hasError, message) = validate(age, weight)
- if (hasError) {
+
+ val ageValidation = validateAge(age)
+ val weightValidation = validateWeight(weight)
+
+ if (ageValidation || weightValidation) {
_uiState.update {
- it.copy(isError = true, errorMessage = message)
+ it.copy(
+ isError = true,
+ isAgeError = ageValidation,
+ isWeightError = weightValidation
+ )
}
- } else {
- _uiState.update {
- it.copy(isError = false, errorMessage = "")
- }
- onSuccess()
+ return
+ }
+
+ _uiState.update {
+ it.copy(
+ isError = false,
+ isAgeError = false,
+ isWeightError = false
+ )
}
+ onSuccess()
}
private fun isValid(age: String, weight: String): Boolean {
return age.isNotBlank() && weight.isNotBlank()
}
+ private fun validateAge(age: String): Boolean {
+ if (age.isBlank()) return true
+ val ageInt = age.toIntOrNull()
+ return ageInt == null || ageInt !in 1..25
+ }
+
+ private fun validateWeight(weight: String): Boolean {
+ if (weight.isBlank()) return true
+ val weightFloat = weight.toFloatOrNull()
+ if (weightFloat == null || weightFloat < 0.5f || weightFloat > 100f) {
+ return true
+ }
+ if (weight.contains('.')) {
+ val parts = weight.split('.')
+ if ((parts.getOrNull(1)?.length ?: 0) > 1) {
+ return true
+ }
+ }
+ return false
+ }
+
private fun validate(age: String, weight: String): Pair {
if (age.isBlank() || weight.isBlank()) {
return true to "나이와 몸무게를 모두 입력해주세요."
diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/PetProfileViewModel.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/PetProfileViewModel.kt
index 6acdbcc..b8ae5d8 100644
--- a/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/PetProfileViewModel.kt
+++ b/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/PetProfileViewModel.kt
@@ -19,13 +19,39 @@ class PetProfileViewModel : ViewModel() {
it.copy(
name = newName,
isError = false,
- isButtonEnabled = newName.isNotBlank() && newName.length <= 10
+ isButtonEnabled = newName.isNotBlank()
)
}
}
- fun setProfileBitmap(bitmap: Bitmap) {
- _profileBitmap.value = bitmap
+ private fun filterInvalidChars(input: String): String {
+ // 한글과 영문만 허용
+ return input.filter { char ->
+ char in '가'..'힣' || char in 'a'..'z' || char in 'A'..'Z'
+ }
+ }
+
+ private fun applyLengthLimit(input: String): String {
+ if (input.isBlank()) return input
+
+ val isKoreanOnly = isKorean(input)
+ val isEnglishOnly = isEnglish(input)
+ val isMixed = isMixed(input)
+
+ return when {
+ isKoreanOnly -> input.take(5)
+ isEnglishOnly -> input.take(7)
+ isMixed -> input.take(7)
+ else -> input
+ }
+ }
+
+ private fun isKorean(input: String) = input.matches(Regex("^[가-힣]+$"))
+ private fun isEnglish(input: String) = input.matches(Regex("^[a-zA-Z]+$"))
+ private fun isMixed(input: String): Boolean {
+ val hasKorean = input.any { it in '\uAC00'..'\uD7A3' }
+ val hasEnglish = input.any { it.isLetter() && (it in 'a'..'z' || it in 'A'..'Z') }
+ return hasKorean && hasEnglish
}
fun validateAndProceed(onSuccess: () -> Unit) {
@@ -37,7 +63,25 @@ class PetProfileViewModel : ViewModel() {
return
}
- if (currentState.name.length > 10) {
+ if (containsInvalidChars(currentState.name)) {
+ _uiState.update {
+ it.copy(isError = true)
+ }
+ return
+ }
+
+ val isKoreanOnly = isKorean(currentState.name)
+ val isEnglishOnly = isEnglish(currentState.name)
+ val isMixed = isMixed(currentState.name)
+
+ val isValidLength = when {
+ isKoreanOnly -> currentState.name.length <= 5
+ isEnglishOnly -> currentState.name.length <= 7
+ isMixed -> currentState.name.length <= 7
+ else -> false
+ }
+
+ if (!isValidLength) {
_uiState.update {
it.copy(isError = true)
}
@@ -46,4 +90,13 @@ class PetProfileViewModel : ViewModel() {
onSuccess()
}
+
+ private fun containsInvalidChars(input: String): Boolean {
+ val regex = Regex("^[가-힣a-zA-Z]+$")
+ return !regex.matches(input)
+ }
+
+ fun setProfileBitmap(bitmap: Bitmap) {
+ _profileBitmap.value = bitmap
+ }
}
\ No newline at end of file
diff --git a/feature/signup/src/main/java/com/combo/runcombi/signup/model/BodyUiState.kt b/feature/signup/src/main/java/com/combo/runcombi/signup/model/BodyUiState.kt
index 0ceca17..da9ea80 100644
--- a/feature/signup/src/main/java/com/combo/runcombi/signup/model/BodyUiState.kt
+++ b/feature/signup/src/main/java/com/combo/runcombi/signup/model/BodyUiState.kt
@@ -6,4 +6,6 @@ data class BodyUiState(
val isError: Boolean = false,
val errorMessage: String = "",
val isButtonEnabled: Boolean = false,
+ val isHeightError: Boolean = false,
+ val isWeightError: Boolean = false
)
\ No newline at end of file
diff --git a/feature/signup/src/main/java/com/combo/runcombi/signup/model/PetInfoUiState.kt b/feature/signup/src/main/java/com/combo/runcombi/signup/model/PetInfoUiState.kt
index 997e855..3cac971 100644
--- a/feature/signup/src/main/java/com/combo/runcombi/signup/model/PetInfoUiState.kt
+++ b/feature/signup/src/main/java/com/combo/runcombi/signup/model/PetInfoUiState.kt
@@ -6,4 +6,6 @@ data class PetInfoUiState(
val isError: Boolean = false,
val errorMessage: String = "",
val isButtonEnabled: Boolean = false,
+ val isAgeError: Boolean = false,
+ val isWeightError: Boolean = false
)
\ No newline at end of file
diff --git a/feature/signup/src/main/java/com/combo/runcombi/signup/screen/BodyScreen.kt b/feature/signup/src/main/java/com/combo/runcombi/signup/screen/BodyScreen.kt
index 6b6d11f..76422c6 100644
--- a/feature/signup/src/main/java/com/combo/runcombi/signup/screen/BodyScreen.kt
+++ b/feature/signup/src/main/java/com/combo/runcombi/signup/screen/BodyScreen.kt
@@ -37,6 +37,7 @@ import com.combo.runcombi.signup.viewmodel.BodyViewModel
import com.combo.runcombi.signup.viewmodel.SignupViewModel
import com.combo.runcombi.ui.ext.clickableWithoutRipple
import com.combo.runcombi.ui.ext.screenDefaultPadding
+import com.combo.runcombi.domain.user.model.Gender
@Composable
fun BodyScreen(
@@ -82,6 +83,12 @@ fun BodyScreen(
RunCombiTextField(
value = uiState.height,
maxLength = 3,
+ placeholder = uiState.height.ifEmpty {
+ when (gender) {
+ Gender.MALE -> "172"
+ Gender.FEMALE -> "160"
+ }
+ },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
@@ -89,7 +96,7 @@ fun BodyScreen(
modifier = Modifier
.width(134.dp)
.height(40.dp),
- isError = uiState.isError && uiState.errorMessage.contains("키"),
+ isError = uiState.isHeightError,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
enabled = true,
singleLine = true,
@@ -103,6 +110,12 @@ fun BodyScreen(
RunCombiTextField(
value = uiState.weight,
maxLength = 3,
+ placeholder = uiState.weight.ifEmpty {
+ when (gender) {
+ Gender.MALE -> "68"
+ Gender.FEMALE -> "55"
+ }
+ },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
@@ -110,7 +123,7 @@ fun BodyScreen(
modifier = Modifier
.width(134.dp)
.height(40.dp),
- isError = uiState.isError && uiState.errorMessage.contains("몸무게"),
+ isError = uiState.isWeightError,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
enabled = true,
singleLine = true,
diff --git a/feature/signup/src/main/java/com/combo/runcombi/signup/screen/PetInfoScreen.kt b/feature/signup/src/main/java/com/combo/runcombi/signup/screen/PetInfoScreen.kt
index 2e358b0..389e253 100644
--- a/feature/signup/src/main/java/com/combo/runcombi/signup/screen/PetInfoScreen.kt
+++ b/feature/signup/src/main/java/com/combo/runcombi/signup/screen/PetInfoScreen.kt
@@ -75,12 +75,13 @@ fun PetInfoScreen(
RunCombiTextField(
value = uiState.age,
maxLength = 2,
+ placeholder = uiState.age.ifEmpty { "5" },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
onValueChange = { petInfoViewModel.onAgeChange(it) },
modifier = Modifier.width(134.dp),
- isError = uiState.isError && uiState.errorMessage.contains("나이"),
+ isError = uiState.isAgeError,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
enabled = true,
singleLine = true,
@@ -94,12 +95,13 @@ fun PetInfoScreen(
RunCombiTextField(
value = uiState.weight,
maxLength = 4,
+ placeholder = uiState.weight.ifEmpty { "5.5" },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
onValueChange = { petInfoViewModel.onWeightChange(it) },
modifier = Modifier.width(134.dp),
- isError = uiState.isError && uiState.errorMessage.contains("몸무게"),
+ isError = uiState.isWeightError,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
enabled = true,
singleLine = true,
diff --git a/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/BodyViewModel.kt b/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/BodyViewModel.kt
index a05b3f8..9c2ac85 100644
--- a/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/BodyViewModel.kt
+++ b/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/BodyViewModel.kt
@@ -12,20 +12,14 @@ class BodyViewModel : ViewModel() {
val uiState: StateFlow = _uiState
fun setDefaultValues(gender: Gender) {
- val defaultHeight = when (gender) {
- Gender.MALE -> "172"
- Gender.FEMALE -> "160"
- }
- val defaultWeight = when (gender) {
- Gender.MALE -> "68"
- Gender.FEMALE -> "55"
- }
_uiState.value = _uiState.value.copy(
- height = defaultHeight,
- weight = defaultWeight,
- isButtonEnabled = true,
+ height = "",
+ weight = "",
+ isButtonEnabled = false,
isError = false,
- errorMessage = ""
+ errorMessage = "",
+ isHeightError = false,
+ isWeightError = false
)
}
@@ -36,7 +30,8 @@ class BodyViewModel : ViewModel() {
height = filtered,
isButtonEnabled = isValid(filtered, it.weight),
isError = false,
- errorMessage = ""
+ errorMessage = "",
+ isHeightError = false
)
}
}
@@ -48,7 +43,8 @@ class BodyViewModel : ViewModel() {
weight = filtered,
isButtonEnabled = isValid(it.height, filtered),
isError = false,
- errorMessage = ""
+ errorMessage = "",
+ isWeightError = false
)
}
}
@@ -56,23 +52,47 @@ class BodyViewModel : ViewModel() {
fun validateAndProceed(gender: Gender, onSuccess: () -> Unit) {
val height = uiState.value.height
val weight = uiState.value.weight
- val (hasError, message) = validate(height, weight, gender)
- if (hasError) {
- _uiState.update {
- it.copy(isError = true, errorMessage = message)
- }
- } else {
+
+ val heightValidation = validateHeight(height)
+ val weightValidation = validateWeight(weight)
+
+ if (heightValidation || weightValidation) {
_uiState.update {
- it.copy(isError = false, errorMessage = "")
+ it.copy(
+ isError = true,
+ isHeightError = heightValidation,
+ isWeightError = weightValidation
+ )
}
- onSuccess()
+ return
}
+
+ _uiState.update {
+ it.copy(
+ isError = false,
+ isHeightError = false,
+ isWeightError = false
+ )
+ }
+ onSuccess()
}
private fun isValid(height: String, weight: String): Boolean {
return height.isNotBlank() && weight.isNotBlank()
}
+ private fun validateHeight(height: String): Boolean {
+ if (height.isBlank()) return true
+ val heightInt = height.toIntOrNull()
+ return heightInt == null || heightInt !in 91..242
+ }
+
+ private fun validateWeight(weight: String): Boolean {
+ if (weight.isBlank()) return true
+ val weightInt = weight.toIntOrNull()
+ return weightInt == null || weightInt !in 10..227
+ }
+
private fun validate(height: String, weight: String, gender: Gender): Pair {
if (height.isBlank() || weight.isBlank()) {
return true to "키와 몸무게를 모두 입력해주세요."
diff --git a/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/PetInfoViewModel.kt b/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/PetInfoViewModel.kt
index a434197..ab03a99 100644
--- a/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/PetInfoViewModel.kt
+++ b/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/PetInfoViewModel.kt
@@ -9,11 +9,13 @@ import kotlinx.coroutines.flow.update
class PetInfoViewModel : ViewModel() {
private val _uiState = MutableStateFlow(
PetInfoUiState(
- age = "5",
- weight = "5.5",
+ age = "",
+ weight = "",
isButtonEnabled = true,
isError = false,
- errorMessage = ""
+ errorMessage = "",
+ isAgeError = false,
+ isWeightError = false
)
)
val uiState: StateFlow = _uiState
@@ -25,7 +27,8 @@ class PetInfoViewModel : ViewModel() {
age = filtered,
isButtonEnabled = isValid(filtered, it.weight),
isError = false,
- errorMessage = ""
+ errorMessage = "",
+ isAgeError = false
)
}
}
@@ -44,9 +47,10 @@ class PetInfoViewModel : ViewModel() {
_uiState.update {
it.copy(
weight = filtered,
- isButtonEnabled = isValid(it.age, filtered),
+ isButtonEnabled = isValid(filtered, it.age),
isError = false,
- errorMessage = ""
+ errorMessage = "",
+ isWeightError = false
)
}
}
@@ -54,23 +58,56 @@ class PetInfoViewModel : ViewModel() {
fun validateAndProceed(onSuccess: () -> Unit) {
val age = uiState.value.age
val weight = uiState.value.weight
- val (hasError, message) = validate(age, weight)
- if (hasError) {
+
+ val ageValidation = validateAge(age)
+ val weightValidation = validateWeight(weight)
+
+ if (ageValidation || weightValidation) {
_uiState.update {
- it.copy(isError = true, errorMessage = message)
+ it.copy(
+ isError = true,
+ isAgeError = ageValidation,
+ isWeightError = weightValidation
+ )
}
- } else {
- _uiState.update {
- it.copy(isError = false, errorMessage = "")
- }
- onSuccess()
+ return
+ }
+
+ _uiState.update {
+ it.copy(
+ isError = false,
+ isAgeError = false,
+ isWeightError = false
+ )
}
+ onSuccess()
}
private fun isValid(age: String, weight: String): Boolean {
return age.isNotBlank() && weight.isNotBlank()
}
+ private fun validateAge(age: String): Boolean {
+ if (age.isBlank()) return true
+ val ageInt = age.toIntOrNull()
+ return ageInt == null || ageInt !in 1..25
+ }
+
+ private fun validateWeight(weight: String): Boolean {
+ if (weight.isBlank()) return true
+ val weightFloat = weight.toFloatOrNull()
+ if (weightFloat == null || weightFloat < 0.5f || weightFloat > 100f) {
+ return true
+ }
+ if (weight.contains('.')) {
+ val parts = weight.split('.')
+ if ((parts.getOrNull(1)?.length ?: 0) > 1) {
+ return true
+ }
+ }
+ return false
+ }
+
private fun validate(age: String, weight: String): Pair {
if (age.isBlank() || weight.isBlank()) {
return true to "나이와 몸무게를 모두 입력해주세요."
diff --git a/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/ProfileViewModel.kt b/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/ProfileViewModel.kt
index 11460a9..5f0ae11 100644
--- a/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/ProfileViewModel.kt
+++ b/feature/signup/src/main/java/com/combo/runcombi/signup/viewmodel/ProfileViewModel.kt
@@ -35,12 +35,34 @@ class ProfileViewModel : ViewModel() {
}
}
+ private fun filterInvalidChars(input: String): String {
+ // 한글과 영문만 허용
+ return input.filter { char ->
+ char in '가'..'힣' || char in 'a'..'z' || char in 'A'..'Z'
+ }
+ }
+
+ private fun applyLengthLimit(input: String): String {
+ if (input.isBlank()) return input
+
+ val isKoreanOnly = isKorean(input)
+ val isEnglishOnly = isEnglish(input)
+ val isMixed = isMixed(input)
+
+ return when {
+ isKoreanOnly -> input.take(5)
+ isEnglishOnly -> input.take(7)
+ isMixed -> input.take(7)
+ else -> input
+ }
+ }
+
private fun validateName(input: String): Pair {
if (input.isBlank()) {
return true to "이름을 입력해주세요."
}
if (containsInvalidChars(input)) {
- return true to "이모지, 공백, 특수문자, 숫자, 기타 언어는 입력할 수 없습니다."
+ return true to "한글과 영문만 입력할 수 있어요!"
}
val isKoreanOnly = isKorean(input)
@@ -48,9 +70,9 @@ class ProfileViewModel : ViewModel() {
val isMixed = isMixed(input)
return when {
- isMixed && input.length > 5 -> true to "한글·영문 혼합은 5자 이하로 입력해주세요."
- isKoreanOnly && input.length > 10 -> true to "한글은 10자 이하로 입력해주세요."
- isEnglishOnly && input.length > 7 -> true to "영문은 7자 이하로 입력해주세요."
+ isKoreanOnly && input.length > 5 -> true to "한글은 최대 5자까지 입력할 수 있어요!"
+ isEnglishOnly && input.length > 7 -> true to "이름은 최대 7자까지 입력할 수 있어요!"
+ isMixed && input.length > 7 -> true to "이름은 최대 7자까지 입력할 수 있어요!"
!isKoreanOnly && !isEnglishOnly && !isMixed -> true to "한글 또는 영문만 입력 가능합니다."
else -> false to ""
}
diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkMainScreen.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkMainScreen.kt
index dd99baf..1a37df9 100644
--- a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkMainScreen.kt
+++ b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkMainScreen.kt
@@ -423,14 +423,6 @@ private fun PetProfile(
)
)
}
- Spacer(modifier = Modifier.height(12.dp))
- Text(
- pet.name,
- style = body1,
- color = Grey06,
- textAlign = TextAlign.Center,
- modifier = Modifier.padding(start = startPadding),
- )
}
}
diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt
index ba1fb7f..c37d914 100644
--- a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt
+++ b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt
@@ -1,6 +1,8 @@
package com.combo.runcombi.walk.screen
import android.annotation.SuppressLint
+import android.app.NotificationManager
+import android.content.Context
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@@ -70,6 +72,7 @@ import com.combo.runcombi.walk.model.WalkUiState
import com.combo.runcombi.walk.model.getBottomSheetContent
import com.combo.runcombi.walk.viewmodel.WalkMainViewModel
import com.combo.runcombi.walk.viewmodel.WalkTrackingViewModel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@SuppressLint("MissingPermission")
@@ -88,6 +91,22 @@ fun WalkTrackingScreen(
val isInitialized = rememberSaveable { mutableStateOf(false) }
val showSheet = remember { mutableStateOf(BottomSheetType.NONE) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(5000)
+
+ if (walkRecordViewModel.isTracking()) {
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val activeNotifications = notificationManager.activeNotifications
+ val hasNotification = activeNotifications.any { it.id == 1001 }
+
+ if (!hasNotification) {
+ walkRecordViewModel.restartNotification()
+ }
+ }
+ }
+ }
+
LaunchedEffect(isInitialized.value) {
if (!isInitialized.value) {
analyticsHelper.logScreenView("WalkTrackingScreen")
diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingService.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingService.kt
index 8899891..fedb2fe 100644
--- a/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingService.kt
+++ b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingService.kt
@@ -49,6 +49,7 @@ class WalkTrackingService : Service() {
const val ACTION_STOP_TRACKING = "com.combo.runcombi.STOP_TRACKING"
const val ACTION_PAUSE_TRACKING = "com.combo.runcombi.PAUSE_TRACKING"
const val ACTION_RESUME_TRACKING = "com.combo.runcombi.RESUME_TRACKING"
+ const val ACTION_RESTART_NOTIFICATION = "com.combo.runcombi.RESTART_NOTIFICATION"
const val EXTRA_EXERCISE_TYPE = "exercise_type"
@@ -111,6 +112,11 @@ class WalkTrackingService : Service() {
resumeTracking()
}
+ ACTION_RESTART_NOTIFICATION -> {
+ Log.d(TAG, "onStartCommand: 알림 재시작")
+ restartNotification()
+ }
+
else -> {
Log.w(TAG, "onStartCommand: 알 수 없는 action=${intent?.action}")
}
@@ -140,6 +146,7 @@ class WalkTrackingService : Service() {
}
private fun stopTracking() {
+ Log.d(TAG, "stopTracking: 운동 추적 중지 및 서비스 종료")
dataManager.updateTrackingState(false)
stopLocationUpdates()
stopTimeUpdates()
@@ -346,6 +353,20 @@ class WalkTrackingService : Service() {
.build()
}
+ private fun restartNotification() {
+ try {
+ // 현재 알림을 제거하고 새로운 알림으로 교체
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.cancel(NOTIFICATION_ID)
+
+ // 새로운 알림으로 포그라운드 서비스 재시작
+ startForeground(NOTIFICATION_ID, createNotification())
+ Log.d(TAG, "restartNotification: 알림 재시작 완료")
+ } catch (e: Exception) {
+ Log.e(TAG, "restartNotification: 알림 재시작 실패", e)
+ }
+ }
+
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingServiceHelper.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingServiceHelper.kt
index 5e814aa..4724a75 100644
--- a/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingServiceHelper.kt
+++ b/feature/walk/src/main/java/com/combo/runcombi/walk/service/WalkTrackingServiceHelper.kt
@@ -72,6 +72,19 @@ class WalkTrackingServiceHelper @Inject constructor(
}
}
+ fun restartNotification() {
+ Log.d(TAG, "restartNotification: 알림 재시작")
+ val intent = Intent(context, WalkTrackingService::class.java).apply {
+ action = WalkTrackingService.ACTION_RESTART_NOTIFICATION
+ }
+ try {
+ context.startService(intent)
+ Log.d(TAG, "restartNotification: 알림 재시작 요청 성공")
+ } catch (e: Exception) {
+ Log.e(TAG, "restartNotification: 알림 재시작 요청 실패", e)
+ }
+ }
+
fun isTracking(): Boolean {
Log.d(TAG, "isTracking: 서비스 상태 확인 중")
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/viewmodel/WalkTrackingViewModel.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/viewmodel/WalkTrackingViewModel.kt
index 640c5c5..de34b72 100644
--- a/feature/walk/src/main/java/com/combo/runcombi/walk/viewmodel/WalkTrackingViewModel.kt
+++ b/feature/walk/src/main/java/com/combo/runcombi/walk/viewmodel/WalkTrackingViewModel.kt
@@ -146,6 +146,14 @@ class WalkTrackingViewModel @Inject constructor(
serviceHelper.stopTracking()
}
+ fun isTracking(): Boolean {
+ return serviceHelper.isTracking()
+ }
+
+ fun restartNotification() {
+ serviceHelper.restartNotification()
+ }
+
override fun onCleared() {
super.onCleared()
if (serviceHelper.isTracking()) {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c60d28a..c992b6b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -64,6 +64,10 @@ v2User = "2.21.4"
playServicesLocation = "21.3.0"
runtimeAndroid = "1.8.3"
playServicesMeasurementApi = "23.0.0"
+playServicesWearable = "19.0.0"
+composeMaterialVersion = "1.2.1"
+composeFoundation = "1.2.1"
+wearToolingPreview = "1.0.0"
[libraries]
# AndroidX
@@ -148,6 +152,10 @@ play-services-location = { group = "com.google.android.gms", name = "play-servic
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" }
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" }
+play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" }
+androidx-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "composeMaterialVersion" }
+androidx-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "composeFoundation" }
+androidx-wear-tooling-preview = { group = "androidx.wear", name = "wear-tooling-preview", version.ref = "wearToolingPreview" }
[plugins]
# Android
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 48b70b1..4cfe1f2 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -51,3 +51,4 @@ include(":core:data:history")
include(":core:domain:setting")
include(":core:data:setting")
include(":core:analytics")
+include(":wear")
diff --git a/wear/.gitignore b/wear/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/wear/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
new file mode 100644
index 0000000..9585ee9
--- /dev/null
+++ b/wear/build.gradle.kts
@@ -0,0 +1,113 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.kotlin.ksp)
+}
+
+android {
+ namespace = "com.combo.runcombi.wear"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.combo.runcombi.wear"
+ minSdk = 30
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ }
+
+ buildTypes {
+ debug {
+ buildConfigField("String", "BASE_URL", "\"http://api.runcombi.site/\"")
+ }
+ release {
+ isMinifyEnabled = false
+ buildConfigField("String", "BASE_URL", "\"http://api.runcombi.site/\"")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ flavorDimensions += "mode"
+ productFlavors {
+ create("mock") {
+ dimension = "mode"
+ }
+ create("prod") {
+ dimension = "mode"
+ }
+ }
+}
+
+dependencies {
+ // Hilt
+ ksp(libs.hilt.compiler)
+ implementation(libs.hilt.android)
+ implementation(libs.androidx.hilt.navigation.compose)
+
+ // Wear OS
+ implementation(libs.play.services.wearable)
+ implementation(libs.play.services.location)
+ implementation(libs.androidx.compose.material)
+ implementation(libs.androidx.compose.foundation)
+ implementation(libs.androidx.wear.tooling.preview)
+
+ // Compose
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.core.splashscreen)
+
+ // Lifecycle
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+
+ // Coroutines
+ implementation(libs.coroutines.android)
+
+ // Core modules
+ implementation(project(":core:designsystem"))
+ implementation(project(":core:datastore"))
+ implementation(project(":core:network"))
+ implementation(project(":core:data:auth"))
+ implementation(project(":core:data:user"))
+ implementation(project(":core:data:walk"))
+ implementation(project(":core:domain:user"))
+ implementation(project(":core:domain:walk"))
+ implementation(project(":core:domain:auth"))
+ implementation(project(":core:domain:common"))
+
+ // Feature modules for resources - 제거 (history 의존성 문제)
+ // implementation(project(":feature:walk"))
+
+ // Network dependencies
+ implementation(libs.okhttp3.logging.interceptor)
+ implementation(libs.kotlin.serialization.json)
+ implementation(libs.retrofit.kotlinx.serialization)
+
+ // Test dependencies
+ testImplementation(libs.junit)
+ testImplementation(libs.kotlin.test)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/wear/lint.xml b/wear/lint.xml
new file mode 100644
index 0000000..44fac75
--- /dev/null
+++ b/wear/lint.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/wear/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b356080
--- /dev/null
+++ b/wear/src/main/AndroidManifest.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/src/main/ic_launcher-playstore.png b/wear/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..5637bd8
Binary files /dev/null and b/wear/src/main/ic_launcher-playstore.png differ
diff --git a/wear/src/main/java/com/combo/runcombi/wear/WearApplication.kt b/wear/src/main/java/com/combo/runcombi/wear/WearApplication.kt
new file mode 100644
index 0000000..840c21a
--- /dev/null
+++ b/wear/src/main/java/com/combo/runcombi/wear/WearApplication.kt
@@ -0,0 +1,7 @@
+package com.combo.runcombi.wear
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class WearApplication : Application()
diff --git a/wear/src/main/java/com/combo/runcombi/wear/auth/WearAuthManager.kt b/wear/src/main/java/com/combo/runcombi/wear/auth/WearAuthManager.kt
new file mode 100644
index 0000000..aa44773
--- /dev/null
+++ b/wear/src/main/java/com/combo/runcombi/wear/auth/WearAuthManager.kt
@@ -0,0 +1,129 @@
+package com.combo.runcombi.wear.auth
+
+import com.combo.runcombi.common.DomainResult
+import com.combo.runcombi.datastore.datasource.AuthDataSource
+import com.combo.runcombi.domain.user.model.MemberStatus
+import com.combo.runcombi.domain.user.model.UserInfo
+import com.combo.runcombi.domain.user.repository.UserRepository
+import com.google.android.gms.wearable.DataEvent
+import com.google.android.gms.wearable.DataEventBuffer
+import com.google.android.gms.wearable.DataMapItem
+import com.google.android.gms.wearable.WearableListenerService
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class WearAuthManager @Inject constructor(
+ private val authDataSource: AuthDataSource,
+ private val userRepository: UserRepository,
+) {
+
+ var onTokenReceivedCallback: (() -> Unit)? = null
+ private set
+
+ fun setOnTokenReceivedCallback(callback: () -> Unit) {
+ onTokenReceivedCallback = callback
+ }
+
+ suspend fun isLoggedIn(): Boolean {
+ return try {
+ val accessToken = authDataSource.getAccessToken().first()
+ accessToken != null && isTokenValid(accessToken)
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ suspend fun getUserStatus(): MemberStatus {
+ return try {
+ when (val result = userRepository.getUserInfo()) {
+ is DomainResult.Success -> result.data.memberStatus
+ else -> MemberStatus.PENDING_AGREE
+ }
+ } catch (e: Exception) {
+ MemberStatus.PENDING_AGREE
+ }
+ }
+
+ suspend fun getUserInfo(): UserInfo? {
+ return try {
+ when (val result = userRepository.getUserInfo()) {
+ is DomainResult.Success -> result.data
+ else -> null
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ suspend fun saveToken(accessToken: String) {
+ authDataSource.setAccessToken(accessToken)
+ }
+
+ suspend fun clearTokens() {
+ authDataSource.deleteAccessToken()
+ }
+
+ private suspend fun isTokenValid(token: String): Boolean {
+ return try {
+ // 토큰 유효성 검증을 위해 간단한 API 호출 시도
+ val result = userRepository.getUserInfo()
+ result is DomainResult.Success
+ } catch (e: Exception) {
+ false
+ }
+ }
+}
+
+@AndroidEntryPoint
+class WearDataListenerService : WearableListenerService() {
+
+ @Inject
+ lateinit var wearAuthManager: WearAuthManager
+
+ override fun onDataChanged(dataEvents: DataEventBuffer) {
+ super.onDataChanged(dataEvents)
+
+ android.util.Log.d("WearDataListenerService", "onDataChanged called with ${dataEvents.count} events")
+
+ for (event in dataEvents) {
+ if (event.type == DataEvent.TYPE_CHANGED) {
+ val dataItem = event.dataItem
+ android.util.Log.d("WearDataListenerService", "Data changed: ${dataItem.uri.path}")
+
+ when (dataItem.uri.path) {
+ "/auth_token" -> {
+ android.util.Log.d("WearDataListenerService", "Received auth_token")
+ val dataMap = DataMapItem.fromDataItem(dataItem).dataMap
+ val accessToken = dataMap.getString("access_token")
+
+ if (accessToken != null) {
+ android.util.Log.d("WearDataListenerService", "Saving access token")
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ wearAuthManager.saveToken(accessToken)
+ android.util.Log.d("WearDataListenerService", "Token saved successfully")
+ // 토큰 저장 성공 시 콜백 호출
+ wearAuthManager.onTokenReceivedCallback?.invoke()
+ } catch (e: Exception) {
+ android.util.Log.e("WearDataListenerService", "Failed to save token", e)
+ }
+ }
+ } else {
+ android.util.Log.w("WearDataListenerService", "Access token is null")
+ }
+ }
+ "/request_login" -> {
+ android.util.Log.d("WearDataListenerService", "Received request_login (ignoring - should be handled by mobile)")
+ }
+ else -> {
+ android.util.Log.d("WearDataListenerService", "Unknown path: ${dataItem.uri.path}")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/wear/src/main/java/com/combo/runcombi/wear/auth/WearTokenManager.kt b/wear/src/main/java/com/combo/runcombi/wear/auth/WearTokenManager.kt
new file mode 100644
index 0000000..33a0d07
--- /dev/null
+++ b/wear/src/main/java/com/combo/runcombi/wear/auth/WearTokenManager.kt
@@ -0,0 +1,37 @@
+package com.combo.runcombi.wear.auth
+
+import com.combo.runcombi.datastore.datasource.AuthDataSource
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.collect
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WearTokenManager @Inject constructor(
+ private val authDataSource: AuthDataSource
+) {
+
+ suspend fun initializeTokens() {
+ // 하드코딩된 토큰 초기화 비활성화
+ // 모바일로부터 토큰을 받아서 사용
+ android.util.Log.d("WearTokenManager", "하드코딩된 토큰 초기화 비활성화됨")
+ }
+
+ suspend fun getAccessToken(): String? {
+ return authDataSource.getAccessToken().first()
+ }
+
+ suspend fun getRefreshToken(): String? {
+ return authDataSource.getRefreshToken().first()
+ }
+
+ suspend fun clearTokens() {
+ authDataSource.deleteAccessToken()
+ authDataSource.deleteRefreshToken()
+ }
+
+ suspend fun saveAccessTokenFromMobile(accessToken: String) {
+ android.util.Log.d("WearTokenManager", "Saving access token from mobile: ${accessToken.take(20)}...")
+ authDataSource.setAccessToken(accessToken).collect()
+ }
+}
diff --git a/wear/src/main/java/com/combo/runcombi/wear/data/WearConnectionChecker.kt b/wear/src/main/java/com/combo/runcombi/wear/data/WearConnectionChecker.kt
new file mode 100644
index 0000000..09c2cc6
--- /dev/null
+++ b/wear/src/main/java/com/combo/runcombi/wear/data/WearConnectionChecker.kt
@@ -0,0 +1,109 @@
+package com.combo.runcombi.wear.data
+
+import android.content.Context
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.wearable.Node
+import com.google.android.gms.wearable.Wearable
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.suspendCancellableCoroutine
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+@Singleton
+class WearConnectionChecker @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ fun checkGooglePlayServices(): Boolean {
+ val googleApiAvailability = GoogleApiAvailability.getInstance()
+ val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context)
+
+ android.util.Log.d("WearConnectionChecker", "Google Play Services 상태: $resultCode")
+
+ return when (resultCode) {
+ ConnectionResult.SUCCESS -> {
+ android.util.Log.d("WearConnectionChecker", "Google Play Services 사용 가능")
+ true
+ }
+
+ else -> {
+ android.util.Log.e(
+ "WearConnectionChecker",
+ "Google Play Services 사용 불가: $resultCode"
+ )
+ false
+ }
+ }
+ }
+
+ suspend fun checkWearableConnection(): Boolean {
+ return try {
+ android.util.Log.d("WearConnectionChecker", "Wearable 연결 상태 확인 중...")
+
+ val nodeClient = Wearable.getNodeClient(context)
+ val connectedNodes =
+ suspendCancellableCoroutine> { continuation: CancellableContinuation> ->
+ nodeClient.connectedNodes.addOnCompleteListener { task ->
+ if (task.isSuccessful) {
+ continuation.resume(task.result)
+ } else {
+ continuation.resumeWithException(
+ task.exception ?: Exception("Failed to get connected nodes")
+ )
+ }
+ }
+ }
+
+ android.util.Log.d("WearConnectionChecker", "연결된 노드 수: ${connectedNodes.size}")
+ connectedNodes.forEach { node ->
+ android.util.Log.d(
+ "WearConnectionChecker",
+ "연결된 노드: ${node.displayName} (${node.id})"
+ )
+ }
+
+ connectedNodes.isNotEmpty()
+ } catch (e: Exception) {
+ android.util.Log.e("WearConnectionChecker", "Wearable 연결 확인 실패", e)
+ false
+ }
+ }
+
+ suspend fun checkCapability(): Boolean {
+ return try {
+ android.util.Log.d("WearConnectionChecker", "Capability 확인 중...")
+
+ val capabilityClient = Wearable.getCapabilityClient(context)
+ val capabilities =
+ suspendCancellableCoroutine { continuation: CancellableContinuation