From 857039390cab8ce4b42da200a1f8a1caa2186e9e Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 20:15:05 +0900 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20=ED=8F=AC=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A2=85=EB=A3=8C=20=EC=8B=9C=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../walk/screen/WalkTrackingScreen.kt | 19 +++++++++++++++++ .../walk/service/WalkTrackingService.kt | 21 +++++++++++++++++++ .../walk/service/WalkTrackingServiceHelper.kt | 13 ++++++++++++ .../walk/viewmodel/WalkTrackingViewModel.kt | 8 +++++++ 4 files changed, 61 insertions(+) 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()) { From ee33925947d52eecf05551821d1830113e160844 Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 20:31:40 +0900 Subject: [PATCH 02/16] =?UTF-8?q?fix:=20name=20=EC=9C=A0=ED=9A=A8=EC=84=B1?= =?UTF-8?q?=20=EA=B2=80=EC=82=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../setting/viewmodel/EditMemberViewModel.kt | 26 +++++++- .../setting/viewmodel/EditPetViewModel.kt | 57 +++++++++++++++-- .../setting/viewmodel/PetProfileViewModel.kt | 61 +++++++++++++++++-- .../signup/viewmodel/ProfileViewModel.kt | 30 +++++++-- 4 files changed, 160 insertions(+), 14 deletions(-) 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..4df4fe4 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 @@ -81,6 +81,28 @@ class EditMemberViewModel @Inject constructor( } } + 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) { val filtered = newHeight.filter { it.isDigit() } _uiState.update { @@ -180,9 +202,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..d518fae 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 @@ -174,6 +174,59 @@ class EditPetViewModel @Inject constructor( } } + 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) { _uiState.update { it.copy( @@ -211,10 +264,6 @@ class EditPetViewModel @Inject constructor( } } - private fun validateName(name: String): Boolean { - return name.isBlank() || name.length < 2 || name.length > 10 - } - private fun validateAge(age: String): Boolean { if (age.isBlank()) return true val ageInt = age.toIntOrNull() 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/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 "" } From dcdbdc6075df8f5e2c90f22cd138b11f282d4cae Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 20:33:13 +0900 Subject: [PATCH 03/16] =?UTF-8?q?fix:=20=ED=8E=AB=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EC=8B=9C=20=EC=82=B0=EC=B1=85=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EB=94=94=ED=8F=B4=ED=8A=B8=20=EA=B0=92=20=EC=A7=80=EC=A0=95?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/combo/runcombi/setting/model/AddPetUiState.kt | 4 ++-- .../combo/runcombi/setting/screen/AddPetStyleScreen.kt | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) 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..bb353ad 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 @@ -17,7 +17,7 @@ data class PetInfoUiState( ) 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/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 ) } From 4feefbcdbde980ff92d3d67cbb7b191b4ac351ff Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 20:58:52 +0900 Subject: [PATCH 04/16] =?UTF-8?q?fix:=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runcombi/setting/model/AddPetUiState.kt | 4 +- .../setting/screen/AddPetInfoScreen.kt | 6 +- .../setting/viewmodel/EditPetViewModel.kt | 22 +++++++ .../setting/viewmodel/PetInfoViewModel.kt | 61 +++++++++++++---- .../runcombi/signup/model/BodyUiState.kt | 2 + .../runcombi/signup/model/PetInfoUiState.kt | 2 + .../runcombi/signup/screen/BodyScreen.kt | 17 ++++- .../runcombi/signup/screen/PetInfoScreen.kt | 6 +- .../signup/viewmodel/BodyViewModel.kt | 64 +++++++++++------- .../signup/viewmodel/PetInfoViewModel.kt | 65 +++++++++++++++---- 10 files changed, 194 insertions(+), 55 deletions(-) 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 bb353ad..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,7 +13,9 @@ 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( 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/viewmodel/EditPetViewModel.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/viewmodel/EditPetViewModel.kt index d518fae..1dc60f6 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 @@ -236,6 +236,17 @@ class EditPetViewModel @Inject constructor( } fun onAgeChange(newAge: String) { + // 빈 문자열이면 바로 반환 + if (newAge.isEmpty()) { + _uiState.update { + it.copy( + age = "", + isAgeError = false + ) + } + return + } + val filtered = newAge.filter { it.isDigit() } _uiState.update { it.copy( @@ -246,6 +257,16 @@ class EditPetViewModel @Inject constructor( } fun onWeightChange(newWeight: String) { + if (newWeight.isEmpty()) { + _uiState.update { + it.copy( + weight = "", + isWeightError = false + ) + } + return + } + var filtered = newWeight.filter { it.isDigit() || it == '.' } val dotCount = filtered.count { it == '.' } if (dotCount > 1) { @@ -256,6 +277,7 @@ class EditPetViewModel @Inject constructor( val parts = filtered.split('.') filtered = parts[0] + "." + parts.getOrNull(1)?.take(1).orEmpty() } + _uiState.update { it.copy( weight = filtered, 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/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 "나이와 몸무게를 모두 입력해주세요." From 419ff6041bb7e93f3ef13cd8a44a9f6ba1256b21 Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 21:14:13 +0900 Subject: [PATCH 05/16] =?UTF-8?q?fix:=20=EC=9E=85=EB=A0=A5=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../history/screen/AddRecordScreen.kt | 23 ++++++++++++++----- .../history/screen/EditRecordScreen.kt | 23 ++++++++++++++----- 2 files changed, 34 insertions(+), 12 deletions(-) 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..742d3b7 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 @@ -37,6 +37,9 @@ 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.KeyboardOptions 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..687bfc1 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 @@ -35,6 +35,9 @@ 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.KeyboardOptions 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)) From 4365f4cccde1324c984bd9d4fb8075dc554e8c7a Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 21:15:55 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20hasRunImage=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/combo/runcombi/history/screen/RecordScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From f13897bcbffc93048285e142f25201da5f610610 Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 21:19:31 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix:=20=EC=BD=A4=EB=B9=84=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=ED=91=9C=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/combo/runcombi/walk/screen/WalkMainScreen.kt | 8 -------- 1 file changed, 8 deletions(-) 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), - ) } } From 07e5ea9497b21f1e0d23a819e9dc8ab44e46ddfd Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 21:19:41 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix:=20import=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/combo/runcombi/history/screen/AddRecordScreen.kt | 2 +- .../java/com/combo/runcombi/history/screen/EditRecordScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 742d3b7..45bb04e 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 @@ -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 @@ -39,7 +40,6 @@ 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.KeyboardOptions import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview 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 687bfc1..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 @@ -37,7 +38,6 @@ 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.KeyboardOptions import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview From f81519e2b9d5f76b75c9eae1febaeef19117fb29 Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 21:25:19 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix:=20=EC=A0=80=EC=9E=A5=20=ED=95=AD?= =?UTF-8?q?=EC=83=81=20=ED=99=9C=EC=84=B1=ED=99=94=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../setting/model/EditMemberUiState.kt | 1 + .../runcombi/setting/model/EditPetUiState.kt | 3 +- .../setting/screen/EditMemberScreen.kt | 12 +++++-- .../runcombi/setting/screen/EditPetScreen.kt | 13 +++++-- .../setting/viewmodel/EditMemberViewModel.kt | 34 +++++++++++++++++- .../setting/viewmodel/EditPetViewModel.kt | 35 ++++++++++++++++++- 6 files changed, 89 insertions(+), 9 deletions(-) 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/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 4df4fe4..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,7 @@ class EditMemberViewModel @Inject constructor( isButtonEnabled = isFormValid(newName, it.height, it.weight) ) } + checkForChanges() } private fun filterInvalidChars(input: String): String { @@ -112,6 +140,7 @@ class EditMemberViewModel @Inject constructor( isButtonEnabled = isFormValid(it.name, filtered, it.weight) ) } + checkForChanges() } fun onWeightChange(newWeight: String) { @@ -123,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?) { 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 1dc60f6..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,7 @@ class EditPetViewModel @Inject constructor( isNameError = false ) } + checkForChanges() } private fun filterInvalidChars(input: String): String { @@ -233,6 +261,7 @@ class EditPetViewModel @Inject constructor( runStyle = runStyle ) } + checkForChanges() } fun onAgeChange(newAge: String) { @@ -244,6 +273,7 @@ class EditPetViewModel @Inject constructor( isAgeError = false ) } + checkForChanges() return } @@ -254,6 +284,7 @@ class EditPetViewModel @Inject constructor( isAgeError = false ) } + checkForChanges() } fun onWeightChange(newWeight: String) { @@ -264,6 +295,7 @@ class EditPetViewModel @Inject constructor( isWeightError = false ) } + checkForChanges() return } @@ -284,6 +316,7 @@ class EditPetViewModel @Inject constructor( isWeightError = false ) } + checkForChanges() } private fun validateAge(age: String): Boolean { From 89506092e8e5b7a3384f155ebd9c03a7d4be8a65 Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 21:26:19 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20=EA=B2=BD=EA=B3=A0=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/combo/runcombi/history/screen/AddRecordScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 45bb04e..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 From fbd08bc85a08d518029f1d227db9a4482fde0d0e Mon Sep 17 00:00:00 2001 From: changs97 Date: Thu, 21 Aug 2025 21:29:08 +0900 Subject: [PATCH 11/16] =?UTF-8?q?release:=20=ED=94=84=EB=A1=9C=EB=8D=95?= =?UTF-8?q?=EC=85=98=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=201.0.9(109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c20ce0f..bb8bd54 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" From de262aacecbb659fa5b160df2d33e95f2096fd2e Mon Sep 17 00:00:00 2001 From: changs97 Date: Tue, 9 Sep 2025 00:24:38 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feature:=20=EB=9F=B0=EC=BD=A4=EB=B9=84=20?= =?UTF-8?q?wear=20os=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 9 + .../auth/usecase/GetAccessTokenUseCase.kt | 7 +- core/network/build.gradle.kts | 1 + core/wear/consumer-rules.pro | 1 + feature/main/build.gradle.kts | 3 + .../com/combo/runcombi/main/MainActivity.kt | 28 ++- .../main/wear/WearConnectionManager.kt | 67 ++++++ .../runcombi/main/wear/WearSyncService.kt | 46 +++++ gradle/libs.versions.toml | 8 + settings.gradle.kts | 1 + wear/.gitignore | 1 + wear/build.gradle.kts | 96 +++++++++ wear/lint.xml | 8 + wear/proguard-rules.pro | 21 ++ wear/src/main/AndroidManifest.xml | 45 ++++ wear/src/main/ic_launcher-playstore.png | Bin 0 -> 12407 bytes .../combo/runcombi/wear/WearApplication.kt | 7 + .../runcombi/wear/auth/WearAuthManager.kt | 129 ++++++++++++ .../runcombi/wear/data/WearDataSyncService.kt | 66 ++++++ .../wear/presentation/MainActivity.kt | 56 +++++ .../wear/presentation/WearAuthScreen.kt | 192 ++++++++++++++++++ .../wear/presentation/WearAuthViewModel.kt | 117 +++++++++++ .../wear/presentation/WearMainScreen.kt | 126 ++++++++++++ .../runcombi/wear/presentation/theme/Theme.kt | 17 ++ .../res/drawable/ic_launcher_background.xml | 170 ++++++++++++++++ .../res/drawable/ic_launcher_foreground.xml | 39 ++++ wear/src/main/res/drawable/splash_icon.xml | 31 +++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 972 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2068 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 712 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1308 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1272 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 2926 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 1902 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 4566 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 2706 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 6802 bytes wear/src/main/res/values-round/strings.xml | 3 + .../res/values/ic_launcher_background.xml | 4 + wear/src/main/res/values/strings.xml | 3 + wear/src/main/res/values/styles.xml | 8 + 43 files changed, 1314 insertions(+), 6 deletions(-) create mode 100644 core/wear/consumer-rules.pro create mode 100644 feature/main/src/main/java/com/combo/runcombi/main/wear/WearConnectionManager.kt create mode 100644 feature/main/src/main/java/com/combo/runcombi/main/wear/WearSyncService.kt create mode 100644 wear/.gitignore create mode 100644 wear/build.gradle.kts create mode 100644 wear/lint.xml create mode 100644 wear/proguard-rules.pro create mode 100644 wear/src/main/AndroidManifest.xml create mode 100644 wear/src/main/ic_launcher-playstore.png create mode 100644 wear/src/main/java/com/combo/runcombi/wear/WearApplication.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/auth/WearAuthManager.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/theme/Theme.kt create mode 100644 wear/src/main/res/drawable/ic_launcher_background.xml create mode 100644 wear/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 wear/src/main/res/drawable/splash_icon.xml create mode 100644 wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 wear/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 wear/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 wear/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 wear/src/main/res/values-round/strings.xml create mode 100644 wear/src/main/res/values/ic_launcher_background.xml create mode 100644 wear/src/main/res/values/strings.xml create mode 100644 wear/src/main/res/values/styles.xml 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/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/network/build.gradle.kts b/core/network/build.gradle.kts index 65426d1..e3211fc 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -53,6 +53,7 @@ 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) 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/main/build.gradle.kts b/feature/main/build.gradle.kts index 70c0f32..2b7fdc7 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -17,6 +17,9 @@ dependencies { 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..b849d1d 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 +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 import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch 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 private var status: MemberStatus? = null @@ -38,10 +45,21 @@ 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 +75,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..24f51c1 --- /dev/null +++ b/feature/main/src/main/java/com/combo/runcombi/main/wear/WearConnectionManager.kt @@ -0,0 +1,67 @@ +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 javax.inject.Inject + +@AndroidEntryPoint +class WearConnectionManager @Inject constructor( + private val wearSyncService: WearSyncService, + private val getAccessTokenUseCase: GetAccessTokenUseCase, +) { + + suspend fun syncUserDataToWear() { + try { + val accessToken = getAccessTokenUseCase() + + if (accessToken != null) { + wearSyncService.sendTokensToWear(accessToken) + } + } catch (e: Exception) { + // 에러 처리 + } + } + +} + +@AndroidEntryPoint +class MobileWearDataListenerService : WearableListenerService() { + + @Inject + lateinit var wearConnectionManager: WearConnectionManager + + override fun onDataChanged(dataEvents: DataEventBuffer) { + super.onDataChanged(dataEvents) + + android.util.Log.d("MobileWearDataListenerService", "onDataChanged called with ${dataEvents.count} events") + + for (event in dataEvents) { + if (event.type == DataEvent.TYPE_CHANGED) { + val dataItem = event.dataItem + android.util.Log.d("MobileWearDataListenerService", "Data changed: ${dataItem.uri.path}") + + when (dataItem.uri.path) { + "/request_login" -> { + android.util.Log.d("MobileWearDataListenerService", "Received login request from wear") + // 워치에서 로그인 요청을 받았을 때 + CoroutineScope(Dispatchers.IO).launch { + try { + wearConnectionManager.syncUserDataToWear() + android.util.Log.d("MobileWearDataListenerService", "User data synced to wear") + } catch (e: Exception) { + android.util.Log.e("MobileWearDataListenerService", "Failed to sync user data", e) + } + } + } + } + } + } + } +} 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/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..702163f --- /dev/null +++ b/wear/build.gradle.kts @@ -0,0 +1,96 @@ +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 { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = 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.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:user")) + implementation(project(":core:domain:user")) + implementation(project(":core:domain:auth")) + implementation(project(":core:domain:common")) + + // 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..94bd02e --- /dev/null +++ b/wear/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 0000000000000000000000000000000000000000..5637bd8e7d00c0d43fcbf7aa1dc675213e1e6634 GIT binary patch literal 12407 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 literal 0 HcmV?d00001 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/data/WearDataSyncService.kt b/wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt new file mode 100644 index 0000000..077fb75 --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt @@ -0,0 +1,66 @@ +package com.combo.runcombi.wear.data + +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 WearDataSyncService @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val dataClient: DataClient = Wearable.getDataClient(context) + + suspend fun sendTokenToWear(accessToken: String) { + val dataMap = DataMap().apply { + putString("access_token", accessToken) + putLong("timestamp", System.currentTimeMillis()) + } + + val putDataMapRequest = PutDataMapRequest.create("/auth_token") + putDataMapRequest.dataMap.putAll(dataMap) + + suspendCancellableCoroutine { continuation -> + dataClient.putDataItem(putDataMapRequest.asPutDataRequest()) + .addOnSuccessListener { dataItem -> + continuation.resume(dataItem) + } + .addOnFailureListener { exception -> + continuation.resumeWithException(exception) + } + } + } + + suspend fun requestMobileLogin() { + android.util.Log.d("WearDataSyncService", "Creating login request...") + + val dataMap = DataMap().apply { + putString("action", "request_login") + putLong("timestamp", System.currentTimeMillis()) + } + + val putDataMapRequest = PutDataMapRequest.create("/request_login") + putDataMapRequest.dataMap.putAll(dataMap) + + android.util.Log.d("WearDataSyncService", "Sending login request to mobile...") + + suspendCancellableCoroutine { continuation -> + dataClient.putDataItem(putDataMapRequest.asPutDataRequest()) + .addOnSuccessListener { dataItem -> + android.util.Log.d("WearDataSyncService", "Login request sent successfully") + continuation.resume(dataItem) + } + .addOnFailureListener { exception -> + android.util.Log.e("WearDataSyncService", "Failed to send login request", exception) + continuation.resumeWithException(exception) + } + } + } +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt new file mode 100644 index 0000000..cdc9667 --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt @@ -0,0 +1,56 @@ +package com.combo.runcombi.wear.presentation + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.wear.compose.material.TimeText +import androidx.wear.tooling.preview.devices.WearDevices +import androidx.hilt.navigation.compose.hiltViewModel +import com.combo.runcombi.domain.user.model.MemberStatus +import com.combo.runcombi.wear.presentation.theme.RunCombi_AndroidTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + + super.onCreate(savedInstanceState) + + setTheme(android.R.style.Theme_DeviceDefault) + + setContent { + WearApp() + } + } +} + +@Composable +fun WearApp() { + RunCombi_AndroidTheme { + val viewModel: WearAuthViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Box( + modifier = Modifier.fillMaxSize() + ) { + TimeText() + + if (uiState.isLoggedIn && uiState.memberStatus == MemberStatus.LIVE && uiState.userInfo != null) { + WearMainScreen(userInfo = uiState.userInfo!!) + } else { + WearAuthScreen(viewModel = viewModel) + } + } + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt new file mode 100644 index 0000000..1102b86 --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt @@ -0,0 +1,192 @@ +package com.combo.runcombi.wear.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import androidx.wear.tooling.preview.devices.WearDevices +import com.combo.runcombi.core.designsystem.component.RunCombiButton +import com.combo.runcombi.domain.user.model.MemberStatus +import com.combo.runcombi.wear.presentation.theme.RunCombi_AndroidTheme + +@Composable +fun WearAuthScreen( + viewModel: WearAuthViewModel +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + when { + uiState.isLoading -> { + LoadingScreen() + } + + uiState.isLoggedIn && uiState.memberStatus == MemberStatus.LIVE -> { + // 메인 화면으로 이동됨 + } + + uiState.isLoggedIn && uiState.memberStatus != MemberStatus.LIVE -> { + IncompleteProfileScreen( + memberStatus = uiState.memberStatus, + onRefresh = { viewModel.refreshAuthStatus() } + ) + } + + else -> { + LoginRequiredScreen( + onLoginClick = { viewModel.requestMobileLogin() }, + error = uiState.error, + onClearError = { viewModel.clearError() }, + isWaitingForResponse = uiState.isWaitingForMobileResponse + ) + } + } + } +} + +@Composable +private fun LoadingScreen() { + val scrollState = rememberScrollState() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.verticalScroll(scrollState) + ) { + CircularProgressIndicator() + Text( + text = "로그인 상태 확인 중...", + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp) + ) + } +} + +@Composable +private fun LoginRequiredScreen( + onLoginClick: () -> Unit, + error: String?, + onClearError: () -> Unit, + isWaitingForResponse: Boolean = false +) { + val scrollState = rememberScrollState() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.verticalScroll(scrollState) + ) { + Text( + text = "RunCombi", + textAlign = TextAlign.Center + ) + + if (isWaitingForResponse) { + Text( + text = "모바일 앱 응답 대기 중...", + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 16.dp) + ) + + CircularProgressIndicator( + modifier = Modifier.padding(vertical = 8.dp) + ) + } else { + Text( + text = "모바일 앱에서 로그인해주세요", + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + + if (error != null) { + Text( + text = error, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + if (!isWaitingForResponse) { + RunCombiButton( + modifier = Modifier.padding(horizontal = 30.dp), + text = "로그인", + onClick = { + onClearError() + onLoginClick() + } + ) + } + } +} + +@Composable +private fun IncompleteProfileScreen( + memberStatus: MemberStatus, + onRefresh: () -> Unit +) { + val scrollState = rememberScrollState() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.verticalScroll(scrollState) + ) { + Text( + text = "프로필 설정 필요", + textAlign = TextAlign.Center + ) + + val message = when (memberStatus) { + MemberStatus.PENDING_AGREE -> "약관 동의가 필요합니다" + MemberStatus.PENDING_MEMBER_DETAIL -> "회원 정보 입력이 필요합니다" + MemberStatus.LIVE -> "이미 완료된 상태입니다" + } + + Text( + text = message, + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 16.dp) + ) + + RunCombiButton( + text = "새로고침", + onClick = onRefresh + ) + } +} + +@Composable +@androidx.compose.ui.tooling.preview.Preview(device = WearDevices.SMALL_ROUND) +fun WearAuthScreenPreview() { + RunCombi_AndroidTheme { + LoginRequiredScreen( + onLoginClick = {}, + error = null, + onClearError = {} + ) + } +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt new file mode 100644 index 0000000..3c4d4da --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt @@ -0,0 +1,117 @@ +package com.combo.runcombi.wear.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.combo.runcombi.domain.user.model.MemberStatus +import com.combo.runcombi.domain.user.model.UserInfo +import com.combo.runcombi.wear.auth.WearAuthManager +import com.combo.runcombi.wear.data.WearDataSyncService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class WearAuthUiState( + val isLoading: Boolean = true, + val isLoggedIn: Boolean = false, + val userInfo: UserInfo? = null, + val memberStatus: MemberStatus = MemberStatus.PENDING_AGREE, + val error: String? = null, + val isWaitingForMobileResponse: Boolean = false +) + +@HiltViewModel +class WearAuthViewModel @Inject constructor( + private val wearAuthManager: WearAuthManager, + private val wearDataSyncService: WearDataSyncService +) : ViewModel() { + + private val _uiState = MutableStateFlow(WearAuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + checkAuthStatus() + wearAuthManager.setOnTokenReceivedCallback { + onTokenReceived() + } + } + + private fun checkAuthStatus() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + + try { + val isLoggedIn = wearAuthManager.isLoggedIn() + + if (isLoggedIn) { + val memberStatus = wearAuthManager.getUserStatus() + val userInfo = if (memberStatus == MemberStatus.LIVE) { + wearAuthManager.getUserInfo() + } else null + + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoggedIn = true, + userInfo = userInfo, + memberStatus = memberStatus + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoggedIn = false + ) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message + ) + } + } + } + + fun requestMobileLogin() { + viewModelScope.launch { + try { + android.util.Log.d("WearAuthViewModel", "Requesting mobile login...") + _uiState.value = _uiState.value.copy(isWaitingForMobileResponse = true) + + wearDataSyncService.requestMobileLogin() + android.util.Log.d("WearAuthViewModel", "Mobile login request sent") + + // 10초 후 응답이 없으면 타임아웃 처리 + kotlinx.coroutines.delay(10000) + if (_uiState.value.isWaitingForMobileResponse) { + _uiState.value = _uiState.value.copy( + isWaitingForMobileResponse = false, + error = "모바일 앱이 실행되지 않았거나 연결되지 않았습니다" + ) + } + } catch (e: Exception) { + android.util.Log.e("WearAuthViewModel", "Failed to request mobile login", e) + _uiState.value = _uiState.value.copy( + isWaitingForMobileResponse = false, + error = "모바일 로그인 요청 실패: ${e.message}" + ) + } + } + } + + fun refreshAuthStatus() { + checkAuthStatus() + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + fun onTokenReceived() { + _uiState.value = _uiState.value.copy( + isWaitingForMobileResponse = false + ) + // 토큰을 받았으므로 인증 상태 다시 확인 + checkAuthStatus() + } +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt new file mode 100644 index 0000000..774db33 --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt @@ -0,0 +1,126 @@ +package com.combo.runcombi.wear.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import androidx.wear.tooling.preview.devices.WearDevices +import com.combo.runcombi.core.designsystem.component.RunCombiButton +import com.combo.runcombi.domain.user.model.Gender +import com.combo.runcombi.domain.user.model.Member +import com.combo.runcombi.domain.user.model.MemberStatus +import com.combo.runcombi.domain.user.model.Pet +import com.combo.runcombi.domain.user.model.RunStyle +import com.combo.runcombi.domain.user.model.UserInfo +import com.combo.runcombi.wear.presentation.theme.RunCombi_AndroidTheme + +@Composable +fun WearMainScreen( + userInfo: UserInfo, + onStartExercise: () -> Unit = {}, + onViewHistory: () -> Unit = {}, + onSettings: () -> Unit = {} +) { + val scrollState = rememberScrollState() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(scrollState) + ) { + // 환영 메시지 + Text( + text = "안녕하세요!", + textAlign = TextAlign.Center + ) + + Text( + text = userInfo.member.nickname, + textAlign = TextAlign.Center + ) + + Text( + text = "콤비 ${userInfo.petList.size}마리와 함께", + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center + ) + + // 운동 시작 버튼 + RunCombiButton( + text = "운동 시작", + onClick = onStartExercise, + modifier = Modifier.padding(vertical = 8.dp) + ) + + // 운동 기록 보기 버튼 + RunCombiButton( + text = "운동 기록", + onClick = onViewHistory, + modifier = Modifier.padding(vertical = 4.dp) + ) + + // 설정 버튼 + RunCombiButton( + text = "설정", + onClick = onSettings, + modifier = Modifier.padding(vertical = 4.dp) + ) + + // 추가 정보 + Text( + text = "오늘도 화이팅!", + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp) + ) + } +} + +@Composable +@Preview(device = WearDevices.SMALL_ROUND) +fun WearMainScreenPreview() { + RunCombi_AndroidTheme { + WearMainScreen( + userInfo = UserInfo( + member = Member( + nickname = "창스", + gender = Gender.MALE, + height = 180, + weight = 80, + profileImageUrl = "" + ), + petList = listOf( + Pet( + id = 1, + name = "초코", + age = 10, + weight = 5.5, + runStyle = RunStyle.RUNNING, + profileImageUrl = "" + ), + Pet( + id = 2, + name = "코난", + age = 8, + weight = 6.5, + runStyle = RunStyle.RUNNING, + profileImageUrl = "" + ) + ), + memberStatus = MemberStatus.LIVE + ) + ) + } +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/theme/Theme.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/theme/Theme.kt new file mode 100644 index 0000000..5889224 --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/theme/Theme.kt @@ -0,0 +1,17 @@ +package com.combo.runcombi.wear.presentation.theme + +import androidx.compose.runtime.Composable +import androidx.wear.compose.material.MaterialTheme + +@Composable +fun RunCombi_AndroidTheme( + content: @Composable () -> Unit, +) { + /** + * Empty theme to customize for your app. + * See: https://developer.android.com/jetpack/compose/designsystems/custom + */ + MaterialTheme( + content = content + ) +} \ No newline at end of file diff --git a/wear/src/main/res/drawable/ic_launcher_background.xml b/wear/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/res/drawable/ic_launcher_foreground.xml b/wear/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..eb43d34 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + diff --git a/wear/src/main/res/drawable/splash_icon.xml b/wear/src/main/res/drawable/splash_icon.xml new file mode 100644 index 0000000..c3e6efb --- /dev/null +++ b/wear/src/main/res/drawable/splash_icon.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/wear/src/main/res/mipmap-hdpi/ic_launcher.webp b/wear/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..e6abc520a33024d6774741af5e9bd5b7433f95f1 GIT binary patch literal 972 zcmV;-12g%Isx#^84@2+w#(D$4PfVjwIPtRE35cHsBjxb6u_VzQ;_%t!-OXHP6GH zj5gfKD2r++#5y3Q2haTfn*bpGfgBJ(009($5)eQD0c1c0C;|cGkB;q8Th@XEwE=Y0 z|6G>8@Olr*)M2ImogO(Ho{#HXmiJtiOSPy55K)UOFaaWJk$@wR)Bpl_s13CXOn_FH zb1Z8rl}ZKtl)L}}B|r%j3XuX~N-ir3L<%w->{}rYRw)#Ro3a!_l?$?@P$;sr79Af7 z6$Mc)I6W;Wj`xKqtp!P*F2AP12tvsw)FdK$;{?62LQ;zzW%q>RlV9DHaEW) zkw34mh3zdEMXs0$3Te)S=tp8AqG&!HIlqs0Va|Q z1zh7KwWMv3E5x>tQ9Cj*WhYWh z-eOa|iTAG{=l&qql$4UJFlSs(cI&}A`vFhZgsFPph|c=x_tXHc|u`O?KJ`pPp? zZw4=%Ja!g^*ZTPfM5aoU^#U!wzauOxv>TTtd9H5V&AKm(261VISEHp*KOtWKc%y0} zmnL}uTKi<(s%2|d-<_T&&&b;r;jJYrKVO~SlgslCTe7h~muSYE7MwMh%Ov%G6r1R@ zGgCn)!OUhlt;}rano2>{5~FRqh%g(s&ZB@Okl-NcgG21VQbrK|qyhlm^2Klf literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..321e7936128746ab85b8557b3e16aee753d53508 GIT binary patch literal 2068 zcmV+v21HIgZQHhO+qP|6zhK+8^$W3`+U?7o z0Lj_5pR?n&(Svs9#I|kQww+a0wr$(CZQB}uXEp!%{r|qpsXz=QNlmo>{~;vj?(PS`m2BIzwZ8V+zL9O)w)tY)elN5Aj?d};1W=(F z%rJPH#!wNPR;~ry0p%Eoz!ZfRnz9%2Es&n4!nL|?JZA%CYoYxX2qH>JxgNaMotksw zqFomM;6MNG&smS}{iR!T)fGe}0zU~ul$LX|+)bD6g1pP_#@!DS8R3^e6LE(*qwXUB z4!QqQI*{d@D2i-FN*Mt-a`|c^9r!{J&2e{+07z+(x0MpW%|8)|!f8$OH0CS;kP$qN zB!C~UCant;=OLO?=6)fP5d=mug8ThAQK=v$ii`1W!2LtPL%`kHNu>gmh#R{&gcd%u zA&g@}CC>yoe(MmzryKNsW*rK0a~BYz7!az|s>wwVcSVHafXi_}%#TESn)o4Pu}Gv&4THB6kYfu(#S$R{ zT3jh;OOP`R2~VOM5!EP(*oq2)REa4PZD?wdxeivI^4soty#(^xw(mw)LWo70Jyn@> zmLMk+70B{?R_2_OIpPXpGMTEa>$|+P%DO28mBq0l5?O)a>yX}*-*&XbIlzMI>zc_F z71S3Z*7JixBoeziARshV1iAPRJ5%A*R~j!kc`6Rn7K(4U`LqZlP7wx9zthH>#LZ{jL4H?u=#4RQL_ z`4~#@A$iAmjWNXdIT95QA{kR-T{FrBHFG<*%{YSaRFFh75i**l$Z|TY2TA^~d8rN3 z#C$3u)DJ%svDu&@&edo}a#*AoW0Xm2nyJoTJZU)2j8f!8v~DPjTn)nJ^TWr~pgoMf6c zL(hvSMmHaZ#|%(i++<8Jz!~5VAOKZa9w?dLbQs^_m}HhUU6a4U=9S@n*cfVHkW!~w zzFPre0@?yX<&PpA7xp<(({X%c_RZUhTf0=zC>4a-=#LLvJ}qRPxDv-E0k%3;+cx$~<4e*}Dk4De4Mv z0XPBtjEDe+p@JlaNs=*!31zM!^RN%8QaWNY@oR~F(p?b`LEIJfG%~~}6N5zjfr%PJ ztz1kXHIrB7=k_kdOie8P^WdXqO*Q8KZDLYQ%bx~Bg7LOA=M!7eQDqEr|6d)djQ}fD-)XoX7}DIhyunvD+ujMl{}92vrslQaq4ufR#kpYsNU*jGZIsdWo2ck zd0ux~vNI`Uz@*rkAToNC8mGYK@@Y!$gZw+4xa>GjvhAndwzI5jeTpzTbCV4jxPL(9|!6Pr{`hfG{h787hNg#+dA@h}AI zh`l5+CHp^295w@{*O%iwS%&cpG2jeXLwD+pOD+1L;3mT)(|c#)ut>57ntx`psE&7S zYzneHe`iV6u(b9LYO|HAJULi_bBZNG8jOi9i?(PgACpg>Ko*Nc(-l+rn%Wg_O^tB? zqT+~99iQ@6sXdy=&lF=D%Ka;jP&Mpc_%7wR^sQme7r+*SMdEd+$6K{r=-qE;w=b4O zuvm6Y@B4PF0Of3kiZIQl0~PvCZBTV5Osi^gqZ*UwW)??dgWp2cjOk|e+-H>UXh9}7 zGAQ+`q`xF$ZzIB(+%J^>U{R(#Au|C%`blE*_&lbv$ar7|iZk^oB}vEy?w|6D3bA=| z1ydg&VaQAUS;vIz>v8wd%hi5n`Jruk~`K z*3G8jFbxn!g^EH{>yEqG{=Qt3_hyh7Q)0%`dlJtIVN{4Hgo-~;?`J0eer%|arrcYB z=??a*+fD%m_uqPDCDg04cLw_(@?68RvciwRnn0;H6cinZ2ODpE>7b)9?ssFd9uon=G5T)ODFj;(z? zrnJ-_#7NaHg&fu5YMfNR|NetpJDyv#lzh8%s7NPMVS3B^xs1)8F;utGbQeR!DM@zf zWzA6u%FVm#SAO_>$8M%~YG-vR`C2o+&pMg%Z%qH*ZWib47~nf7VBL>9x>+y3_sQSF y&d<)$B-2t&2|}QV8f8%PoFYmFv(1&5)LcZU zNMCL%8_JS}=O;U7G#bF< z;PO;qm!;P#lA@zgk);1Ned7im8&YXzufw{&U{a_5_uUg(2Iyw*JAfxI9y#RuJye5d4&eFQ53gM^9CQ*bK6C&tKSsWDYhbU7WcbPf z;Mtq^ubi^;ub-#P6e|wtW!cbWaVjmky<7Mn0lv86+(3=sTUA2< literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..29e9625d2cdc1cfc9fb99c0de28053ea5c92cda7 GIT binary patch literal 1308 zcmV+%1>^csNk&E#1pok7MM6+kP&iBn1pojqFTe{B^#(z<&FatnGepD$G^t?Sl$>o- zBsu@^%#N9PnxQZ=hE~48-2X7u6_wd1_%vx|8p<|rEr-%lra2_9m_AcR88QqjW(t!O z0aXA1L)O{0?Mu&oui3V3HJcw``(@i^KjmkeGXZe3Z9Dcjt7Hk=wv$Sw?bf!_S`{kW zwo(4Z+vR_;LKesX{{Z)K{B>qXo8y}#Rmv0$BuO&V|Nr6i*|ynm1OO}~<+W?uYHfSB zv+XTyTW4Fj{!ai#7U)HI3z=bPR%9M23+w^{gBJ*Ax#y%$J@NzwjZMQE0B2Njit36s zW0Sg44_`n1-N$cyMF7SoRsc_&Nl;7bMFH)v{(j^JJWwR#GkBG)3?$?K=ugv;eEoh8 z;}gh$GY;yIjQ`Y6pmwMOdFd*Ujg$eZSp71TOt1_rZw9gvG9d0CYsW-gO}+tSS{6{_ z3=I=+0^I zFjVa{7c48`qR7zi zWuq1TT%Sc^;MV7U>xhDyn4-BMps6}TK7;S?jL_VtwT1$WDq;cL_`KQNY_M)9xcomX z?Cc8ym?A_by@2=W&7~1T03e1W9-R;is?Jb7ZNynWEM3b%Dlo^7FSWk<+OUo&0N
  • @(OqrKL_4krrbO|TPTD?yb?F8O(fTg=b{|Yaa>@7ZCq+28Vh4AM=F~yb1?9SlG@3&Z9@`E1Y*8VTrG%ciG%%^ipg;lk`vWX1mhK)q}FBx{~KLXQbzPJd-ajWQw zVZd2Go2e86wT4m>^P7C_Bx+#q`LH95hY-5!R6hew`pwt|g-cUt^0~7Iq}5#55;8oD zAf^i{=eP{rFZt=Re+(cnY!dd2ESE#cwrQgT7Jw1Vbb0lQOV9n1PuBcy0Kh`H-BZ@- z)oh)M00JL?xqK|R|HU$`lHIR^5tj=rZGZXa3BTm0+P*dz>Q{s}J6@^3kVbeQ zpPstD;)lTsQlxX!Im_fq<}v$U4M23qYHEjB~;N))VGW| z$wrf8{@z4`=w5lwBhliraMoF!ET7-%KIRpNR{6`X-1yLEmkT8A3&9Fo)Z6_|Rn1N< z9=1)ri{9;*x4qFitY3IdH2=R}SfpspSV;eii%Bs5;UugCx3_RADtCv<-Kjkl?hdtkgj)*$2<7gw zZQC}+UO!-bO|P-Nxv9R!)@0kB06EgO9osX*IO|Ad+qP}nw$ZAxZQHhO+kEf0^84P; zBI5M!u1PxE?lR9JV!I6kw~Z9_|3BQBJ!W1!z8lE4ZJUnv*T%MOCoS7a*;Osuwr%sN z{}X`4tO3e_32-$xs;W?kRaMNg;fQvGo9~wfC7kcKO(2djs={otFHK6mmG9_33~_xm z)VQ(JiB(L-Frf8yeSbkd6FrVGs6sE**OTPwd;2~D-2aoFzLkMhEJ#L@7x_sc9{66~ zzdg%f6%{B_kuy?|Gt$vW#N#SW6`CYuBfVcyoJrSKp#`9d3P6+IcNU5JqH>(mtr`tN zai3MrRXTv_O+s;96z+~Zj{G@w6Yr%{sN;AK(e-%%b^L!_ZH5l7OJB)Q`6~(S$r-gH^$54dse&T)6!O3!AxkTJBnd3Z( z1X={Sj-%}?(K9}MfVh`UZr1WzcJc(jM}yiV9Nos!YHsEqyP|Px7mJLD1ylxTtW%pJ zt+0e`T3X4?|6>+ih_Ou!hQvZLVY1^F zierN8_;w&V;0Xi-W&rB@ddmhJE*!V{#CyM%XGWN$Dtvk&PzmS_5J1}qDUKc3m?#VH z+iV&ygNGE`3=xNhn)<$|fGWg=m#{jl@<-cuKd)I4u;Oo)@@C1JT3?x(pFies^ zPdl|3&TnFAy{WC{+h?&HU&ptV zuikeK!2M=(*F`>g_k&Y6-PmOAiTVFYKAcMfn&s3@ycY!AXC`{HP?i)P778udip06g zveB(}L@>NfIU|Z0Y3+sbIyz|3jsTK1H|u+mpBN6_J%4?^VFKv@pI~>4X5W49GXd_u z&srvNZXoE9bmxh9;HAZ^lYc41f6FvA^`1aDr$c~8(m@^{&=f2~$H94ZUjDf5`Ewjs zHO08#X5|+$JO@-5cN2`EzPM0w{DAYj+ED-Z=1|i)3j_$z;j9n|C_n>R?)yh;247Z@ i?KvO-2efc@7-xv{msLP(JUmC{=N(^%+<$ePWARds6?!=U literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..19a4c412ad876c31c1ba649ac21353e9a0e169ac GIT binary patch literal 2926 zcmV-!3z76vNk&Fy3jhFDMM6+kP&iCk3jhEwU%(d-^@f7BZJ2~V>~;r2L`(pC0c~5u zN^OPkDqZOkbdE*Q|P( zGaHd@rABy*fh0*y_WwWRz^n)6EMnkbOOkBb>aRVvZC~5AZQHhepN_4yR{t5eZKNn! z-aY1f0={z)^g!R+jOEeugxTP2!W|0>&~hBlG{2YCMdWE+d`T^tF)L|B?4ta(wXYvofM(8P{4z zt=rEEWttmFy23GtT$yxZw{J{CIEWI>F0po^KpIlgFkN9l^!=YPq9gR41aX~g-Qjf| zxxYq08X5R5(JSi^qND5RD55l-l@j?b=|a~hy0mTv;;4Ru-ewF_8upwhj7T>|A<_5+ zv^>3LLu9eXiG0$PK9L6|vAqHvUEGo$DvatrC_Qp?urpX@KPZaG z5JnM6Kf5G2s=2*M$`Z&A$RGwjNr@RbIvTyq;+4{j$;u?OOEsB=mh?U6##-v6lL9Di+FOs>M)9#XrhP=XG9t37>lDSD_pS2Clk=6bakt- zsscg?>6tm4$miX!Js~jpZKA*&2&kuHu8LscE2)@VG6`KumewlKDhQD|XdWkWdpb`F zt6=P=SQ}-Kc@R>nz1gPFkRzn?xoD=)d0i;O!L+&5l8a^v$<`KY997mcdo*s*5kSL( z8HflfEily6aa34}JG>?#%hlM=?BDJhD7Eg#`BU;{k}OyvM^8!5jMuu&4d%u%O;uzm z>PQ*>mSyI0%eK60FAeQ86=Wei>9NLLMvCyamxNo5JlFN05kY9PMr+{G8HF8oNnUVmk;gOcu$GCTGz_$dQ~t6lp;6@b5)B*CyIp z)6yOikubA%&TQadyq?yvTAU8Lu@Xsq^iX>6F$W&N4G}BUphFS&pGevPZfEL4X z$-s63%YlQy8Q|uNsABtWFajuUR^3gj?Pc(qZ8U6l@xR2Bs)RT|)4+tx1fl~TKtW&^ z!2Aa!T3ly?aW-1a<1V~4uAU6dj;u>P$}Kx4515u1P!zBQemMi<0Ty#mxCy0K-Ekp2 z7=~2$S}z&wCMWfX{ij!_dg1}Yv{@Z+0Dk!d<^b$ZLhc|U)C5h2sfeH$)_B`8G}@`| zL=;kcT0B5JE>L)ntp)M}5rMYA0e~G>_Xz23Evh!H3TGD5`Bm<6tg=*{lI#txNR7k= zNC4v;lOh95lYMba{UC9NRwWHwPgB>~hMPNbWA~=E6E~&yZ-^m_o#a?-oGDjX=un$( zgaiWdp{a-ch#faDuR>-b1iMHW{Qh(h;wa?VGe;>p#cU|ai)YkTIhV1sJ3h~wCQoOm zMB4lNt9T180*MIaoZMm$;s7`T@9Y3uF#l2v2PUQ~XR_9_5rqfh^e>g@Wjhq&g5pTB zQa54+SOYe|3tM6CQVa_q?n~|eLGF2301m>`J#|QPf1e>)jH$eBZS#W%Ep1z>^FB22 zPy}~*+j9_=MDDN;{I0zu=>jp)V!n@Umlkz*mSJ2U_fI083rrU`o*Wy+9Dv(8L)CFD z|9P`Zi!J8v$jU#daOq-uZd{NVi5BK|%N539kh4F7gs?y_+i*Cmcadc*a(S(ONuHk?zaicI|#O(aSA!iY7+d=Aovxtszr6$47bs+HeA%ZCm8Dms-*uDRe+M`5MS zSPApFi2p&$F&>#J1PsNs7eDwB74SKT7kH)`%ywPb*)%tBSfP(7<_f{E?K(FTcoa80 zIQ@}8B|f`4&o|vjp_yW5^PG^YcM%oC91$3|oaqTo6jM-l)t@60s7xS1UTL2wAU158 zD!je8!nOZ6Vxa&=YgWb-nFA4Z!^%zn%@PYN#8N-dZEt#9bBwuh!>6D6OP-#>>_^Z} z@<^0%-wf{a_KF&x=N}2oU(x9@FkL!7HEf20Ati-~ejYOv7`;O(*1x&@d`zglZ#?`TIJEqX-M~|q=^?fD~HQH9ExAt_`5O*V;8$5D( z=g{i&z0THr*Cc|%0lVdk+sa~{2f0siTlunOVCPslRR;q}wK>>K^>2JX!XsC=26rPq zQvY#E;Ku_X&AA?CgP_I)*z&54Z3RV&LR~hIAdINkR=9j~8Q2<=gSKu@COzOz)>P48 zTjO1OxxJxCe`ubLNA4dK53jviz^Xhwa_xn-+>w|l3d1Piii>g{NYN3ar4s!l}Yz&X{HEYR@#hX#Urh-dWO7+u4|CIec#TFaN!U=R23J-G1W5+2a0d z)|iTx&eI+L6;#^>fw_FPT;W{d_OLo|dc^kG;|90ysSbGfxGju- z-KBK=;MK|6{(=2K4;3$8{`ljMFBgY?+CR{5T|Icbl>Y*Z38c?}cW^hzLD^?z+4s7C Y_rV9BDQYH>gy-3IDNEb-JfWY403md+S^xk5 literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c1e2a223e9634e616099a1cab87c0389c779e9dc GIT binary patch literal 1902 zcmV-!2a)(vNk&Fy2LJ$9MM6+kP&iCl2LJ#skH8}k6^GikZ5aCh$5Z!+Aef|~+J4a{ z4w5R}CzE&vx$^%hlJZZ^B@*1-WqlfZ3Euy4c6)06ME1{`=wh;suO`D9k05i++^0@5 ztZ~iAc!0)jaF>4(lR42oz+~qaQW0>hZ9C?SZQHhOEy12@su(M9s$?%YDW?xh+v&%8 zCxDLq|L5#1wrx9|ZQHhO+eu~Hwr$(CZO`1_zZ(7T%zlB7hNe})|J?(TxzL6juPwAEjG7U$~Owr$(CZQHi} zj?eag0%#t!fCSWRwC5Xk5zoRHLecDDm+T>;S%ewV8OpT6<2N7MMnrpDp=giO6sO`@ z^u$DrJ^tUzH~uf&L&^n;rpLvt05lg(OOTSLNK~v9)6kha3k2K4(ArF$l%zfM+uBx( zrZce_Nabjc6s8zf+}T!(=x3XJ>`w#)%hwpBnj<&WUG${B= z!U4dKa9}hr?2p6A81BTVZ)A=N2slOa59cy`+kg;al1;G{0*+Xz(3E&_kAm!oS*oiD z@SZCKqMd8wiRBpE1^N3f$B^jJ_}Lb#bXyK5{e0KV7xP>> z30$J-=RSVE|LbAK1F+5}Y^HO-TJtgq0Q%YhN38HnDBRZpz{x927yDQmOELg(6-~O1 z@pyOlPXPcbv?aD-F`IqXTa<~w+dU1vK!^>9Tw^Qnj?uFJ_k!7CA4@DBm_dj~|J*YP z<^$#0(hE#>p;ZwN{CC=8gJhU`E|jUXry)pde4|^4OdYirjO8W5c1=GrjmBZqk!h=TZb8ssKc~p z*JTuJh)B54{zMm@iz_unj-$c^PUBzKEF8pm`mW3P`#a?_3}Ve8TDq-p$SU^S`M0y8 zpd<+OwP+hh+XU+CzR(9Ji}tlEF*hOG)Bz%*DvSE>C!L_VSI{Qmn8k z0GUjt3`hpBRQyB6OFm%n=l!?}bMKwV>Nwi~&xjgCcR{ekvabC1TTXo-K?X#1e5@( zVVe_^U?}A>JY5o!49$vwlS+UOA5JQTFfA6r(B*;F7bI|T!l}c}xxwr5+BVDTVGP-T z$UJp}1Q!?@r36F24c8tCs#WJ)AQ)OvXs8D<9b$OLAin{|J9}F`$anyta~9z& z{1P1b`fs+1Q>Qf8w0m%IZFfxqc?KFU-^aXu|9%2sfbsMFT@xH#DBG5SI4>^L6d3Za z#@$oQskd~MU7<{0VE()MmZhuY5QX8!6ZKjrSPK3eT@fqbD$+cU3cHT{sW*JAUhC@I ztXiaS6os)Qr(OnaijAd})tfvJcR3~XvVKkT=*D?WhcsjyF@WnuyBfP}5G+NyHb04- zz{R!eY^U*$V98Nb8dYf^sU7lYPwtuEDe!wbnjDfc(D=S3m|B^5pMD^rLEaOBD||2b z)fktUA~Yl_12EBJr;kr;Fentg${nDi8jXyb2J)5 zF|?Cmz-n;*m5eT+lu1B0a%UvB8gM|`t$_CtgeMkz`iT=C*D0VM oY|%{ene{9G{y`w;3!a|OAH46s(#+&aW@9vh#xRqM89|y`DS^eH)Bpeg literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..82701958a1ee86986e56f66744f0dd384635162a GIT binary patch literal 4566 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 Ax&QzG literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..fc7681b4bb9afd9f772beb293175af12a98f85bd GIT binary patch literal 2706 zcmV;D3T^dLNk&GB3IG6CMM6+kP&iC}3IG5vzrZgL6^GikZ5ZqSx6{iW5fi}EvJ+ZQ zStk-{!>mhsAmS<~m>Eyf0aVNJbhhn3Bs#~oZC9tVZQHhE+qUggwr$(Cjosf=J3D9e zpV&!nC!NzDRMA$yTyJdKU%<9gSrfE&ZGHnMQ%|fa7XwL>4E6tiIIFYmTg)y*Ns>%k z{k8Y_-1}_Xwr$(CZQD5J_J0ECy8o}*QvSHR`^a^7*?Vxe(%njWeFUBYTIU6z_tL5< zv0m%3b!YucSh#zqJ5+Wm#&&Rb9Xs6~Ty|D+I&9(Y?*4CZILWyFgS(A(__&^&WaoBd z9JZ09NX|dQV`hHlhg;jWs%m!oAaf^z&^mAn=>RIQ*yX#stDM`mjXkq%+qP}HPG?rO z^9R)1RnpnERVANMzkc5}0cz)M0?PBCIvr~Hs7v45i^~jqdnqLD<#AbHPbsCQ!^+0D z6o2U-d5ls@c`O{DVwAE-u^Fwxk1)+;{Xb#YKvjys#$@>A4edd$$NIOp|E%!^S|(}oVm_{ znj{#ia%h{D-V3r%^c>RMOIeH_ut*VK-9C!0lY9R_HAwlq3}a}0}-tQ zEL_A_vCm@57mc~9bRv(6BzQX4FJ^fuS=2WWQ98gvMM4GlCNj5&bJa_f3fE9y$IQzC zqX2yYlLG4k%n~bPBJ_aQO8Fmwov~n<2o*^RWFMFp1Tp|2fZ#wFpdPRRz)b7tHG06@ zRa(jLE3Isa^t(+%P0FG#W-bpnj^l&{Mh2Lr_mZTj3tTYw=LuOIyHpmX3N?ky8v$_v zAEyJejUf6HRk+0BdKBMKB!7{4zm|X4#;!_FiZC3F`Z!6I;&Xt7gA;Sa*{fv zgQRFM_YVB#I8JO}7Jym0_egMhX**+`_nmjS+3X@HnOHedpP z0A}iHp<9ojr)>+1vLv25|Lv+e5GqriD6z`y0hR|A16Bi=nZApM^#c#lZxso$XmY?k z4r*UReVxQA%fGz~G#{_YZ?#Q!xeWI>_w_k;4mh+9**A>XRgc-0j?)xOQe{t3W%jX- z?kJch-}wis0aWLGBt@P1Vk!L7xk4r!nr}o*;rB2IpfET{>Zl9m%4wTIX!$Tznbg{TC9jEocL)aZ+HbP21tP)C zkccgo9Lf0*rdaOU3OJzw-=61;kYu{OHZ~#J6so3alKRhp-=3-eKS(k;;VhX) z!xY!4Dx|j$>D;q?((950zi7$0eeQEG$8EzfYe`Xl4dq*D8&Sm{B$>jjTy7H89fX(s(o9B6|W4kcKx^+wYbw9y?};s(s`lw?1FbK(;`d&l zzj}Yd zikHbEs{fC+cn+GW&V`A-*!nrl1|pztu8j=@XCfO4Zl4^}Aeu-#183a@^K`TSY}$vSdQy z-%1r5Defjm`;Ro@ZG>3KLo^k+fAkQc0$PA>W&A9DAVp{MXELJxcu)WXXgGj*DOV`b z7V;9v;Nyo|ML+DpOW?q)A`8Y ze=g!&WI0GB-!KPBCjVO`u`vamBM|ZVNsoQHm($!RA=wQ9`uF>Q$LB3aAb12qh9`Pl zN++d!5~^fB3Dp7{lfSAfE(aD0zsn^eAh|k2t4Vo^G^Ew zCI9|#zkrn}Cg>ObrVii)xIYH?-&H;Wp(79``IhA)5O~sK4XMsI;eLS;MzIM(e>@FX z!~jRY`K5*jO26StUeDO+PyZ1L3@dTjxRLi>fBKu#0qZ(3>DRgC^lzSi{k@25SP9K0 zi1c&*2pR literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..ed31cf2201a65526128bbbc166bea4b197db464a GIT binary patch literal 6802 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 AKL7v# literal 0 HcmV?d00001 diff --git a/wear/src/main/res/values-round/strings.xml b/wear/src/main/res/values-round/strings.xml new file mode 100644 index 0000000..42f1229 --- /dev/null +++ b/wear/src/main/res/values-round/strings.xml @@ -0,0 +1,3 @@ + + From the Round world,\nHello, %1$s! + \ No newline at end of file diff --git a/wear/src/main/res/values/ic_launcher_background.xml b/wear/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..cb1cb39 --- /dev/null +++ b/wear/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #3B3B3B + \ No newline at end of file diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml new file mode 100644 index 0000000..de0fca5 --- /dev/null +++ b/wear/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 런콤비 + \ No newline at end of file diff --git a/wear/src/main/res/values/styles.xml b/wear/src/main/res/values/styles.xml new file mode 100644 index 0000000..97fdb56 --- /dev/null +++ b/wear/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file From 92a91cc2409cc89f6936b5144a921abc303956e1 Mon Sep 17 00:00:00 2001 From: changs97 Date: Tue, 9 Sep 2025 01:10:15 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20hilt=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/combo/runcombi/main/wear/WearConnectionManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 24f51c1..65cd644 100644 --- 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 @@ -11,7 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject -@AndroidEntryPoint + class WearConnectionManager @Inject constructor( private val wearSyncService: WearSyncService, private val getAccessTokenUseCase: GetAccessTokenUseCase, From 6446460d5bd274c1da2ed68dc2c7f0b8ef8d91fc Mon Sep 17 00:00:00 2001 From: changs97 Date: Mon, 29 Sep 2025 20:45:45 +0900 Subject: [PATCH 14/16] =?UTF-8?q?feat:=20wear=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 3 + .../combo/runcombi/auth/AuthTokenProvider.kt | 5 +- .../user/repository/UserRepositoryImpl.kt | 10 +- .../walk/repository/WalkRepositoryImpl.kt | 15 + .../walk/repository/WalkRepository.kt | 6 + .../walk/usecase/MidRunUpdateUseCase.kt | 17 + core/network/build.gradle.kts | 2 +- .../network/interceptor/TokenInterceptor.kt | 5 + .../model/request/MidRunUpdateRequest.kt | 10 + .../runcombi/network/service/WalkService.kt | 6 + feature/main/build.gradle.kts | 4 +- .../com/combo/runcombi/main/MainActivity.kt | 16 +- .../main/wear/WearConnectionManager.kt | 3 + .../runcombi/main/wear/WearSyncService.kt | 3 + wear/build.gradle.kts | 16 + wear/src/main/AndroidManifest.xml | 8 +- .../runcombi/wear/auth/WearTokenManager.kt | 37 ++ .../wear/presentation/MainActivity.kt | 95 ++++- .../wear/presentation/WearAuthScreen.kt | 176 +++++---- .../wear/presentation/WearAuthViewModel.kt | 137 ++++--- .../presentation/WearExerciseResultScreen.kt | 170 +++++++++ .../WearExerciseTrackingScreen.kt | 342 ++++++++++++++++++ .../WearExerciseTypeSelectScreen.kt | 153 ++++++++ .../presentation/WearExerciseViewModel.kt | 172 +++++++++ .../wear/presentation/WearMainScreen.kt | 84 ++--- .../wear/presentation/WearPetSelectScreen.kt | 267 ++++++++++++++ wear/src/main/res/drawable/ic_pause.xml | 9 + wear/src/main/res/drawable/ic_pet_box.xml | 9 + .../main/res/drawable/ic_pet_box_center.xml | 9 + wear/src/main/res/drawable/ic_pet_defalut.xml | 21 ++ wear/src/main/res/drawable/ic_resume.xml | 9 + wear/src/main/res/drawable/ic_stop.xml | 9 + wear/src/main/res/drawable/ic_user_box.xml | 9 + wear/src/main/res/drawable/person_profile.xml | 12 + 34 files changed, 1626 insertions(+), 223 deletions(-) create mode 100644 core/domain/walk/src/main/java/com/combo/runcombi/walk/usecase/MidRunUpdateUseCase.kt create mode 100644 core/network/src/main/java/com/combo/runcombi/network/model/request/MidRunUpdateRequest.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/auth/WearTokenManager.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseResultScreen.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTrackingScreen.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTypeSelectScreen.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseViewModel.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/presentation/WearPetSelectScreen.kt create mode 100644 wear/src/main/res/drawable/ic_pause.xml create mode 100644 wear/src/main/res/drawable/ic_pet_box.xml create mode 100644 wear/src/main/res/drawable/ic_pet_box_center.xml create mode 100644 wear/src/main/res/drawable/ic_pet_defalut.xml create mode 100644 wear/src/main/res/drawable/ic_resume.xml create mode 100644 wear/src/main/res/drawable/ic_stop.xml create mode 100644 wear/src/main/res/drawable/ic_user_box.xml create mode 100644 wear/src/main/res/drawable/person_profile.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 43b626a..9932358 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -54,6 +54,8 @@ + + \ 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/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 e3211fc..420e377 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -60,5 +60,5 @@ dependencies { } 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/feature/main/build.gradle.kts b/feature/main/build.gradle.kts index 2b7fdc7..5f2b015 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -17,8 +17,8 @@ dependencies { implementation(libs.androidx.core.splashscreen) - // Wear OS - implementation(libs.play.services.wearable) + // Wear OS - 비활성화 + // implementation(libs.play.services.wearable) implementation(project(":feature:login")) implementation(project(":feature:signup")) 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 b849d1d..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,7 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.lifecycleScope +// 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 @@ -16,10 +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 +// import com.combo.runcombi.main.wear.WearConnectionManager // Wear 기능 비활성화 import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +// import kotlinx.coroutines.Dispatchers // Wear 기능 비활성화 +// import kotlinx.coroutines.launch // Wear 기능 비활성화 import javax.inject.Inject @AndroidEntryPoint @@ -31,8 +31,8 @@ class MainActivity : ComponentActivity() { @Inject lateinit var getAccessTokenUseCase: GetAccessTokenUseCase - @Inject - lateinit var wearConnectionManager: WearConnectionManager + // @Inject + // lateinit var wearConnectionManager: WearConnectionManager // Wear 기능 비활성화 private var status: MemberStatus? = null @@ -50,7 +50,8 @@ class MainActivity : ComponentActivity() { if (!isNew) { status = getUserStatusUseCase() - // Wear 동기화 및 데이터 전송 + // Wear 동기화 및 데이터 전송 - 비활성화 + /* if (status == MemberStatus.LIVE) { lifecycleScope.launch(Dispatchers.IO) { try { @@ -60,6 +61,7 @@ class MainActivity : ComponentActivity() { } } } + */ } setContent { 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 index 65cd644..acf0e3f 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -65,3 +67,4 @@ class MobileWearDataListenerService : WearableListenerService() { } } } +*/ 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 index c6d1c45..7fcc48d 100644 --- 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 @@ -1,5 +1,7 @@ package com.combo.runcombi.main.wear +// 워치 연동 비활성화 +/* import android.content.Context import com.google.android.gms.wearable.DataClient import com.google.android.gms.wearable.DataMap @@ -44,3 +46,4 @@ class WearSyncService @Inject constructor( } } } +*/ diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 702163f..fc767b1 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -20,8 +20,12 @@ android { } 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" @@ -37,6 +41,7 @@ android { } buildFeatures { compose = true + buildConfig = true } flavorDimensions += "mode" @@ -81,10 +86,21 @@ dependencies { 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) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 94bd02e..85b72da 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -2,14 +2,18 @@ + + + - - + --> \ No newline at end of file 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..ffeab92 --- /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 +) { + companion object { + // 하드코딩된 실제 토큰 + private const val HARDCODED_ACCESS_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3NTg1NDU5MDcsInN1YiI6IjEzMiIsImV4cCI6MTc1OTE1MDcwNywicm9sZSI6IlVTRVIifQ.1Vpd4jIy36CBWsVxasKOWGLegZMWengjJyd8rL4EmHnd7OcLz1Us0ZYX3uwNfhBPMKgpV0upPei-bq_IUbkGtg" + private const val HARDCODED_REFRESH_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3NTg1MzYyNTAsInN1YiI6Ijc5IiwiZXhwIjoxNzYxMTI4MjUwLCJtZW1iZXJJZCI6NzksInJvbGUiOiJVU0VSIn0.f9-eYiqwJ_c5kdZAbHr6j07DYEMVWjkSMpQzGqQbIkLWcHKDHsJ8K8FXSUC2yyO7QjR1xt143cp5lHhbTytnIA" + } + + suspend fun initializeTokens() { + // 앱 시작 시 하드코딩된 토큰을 저장 + authDataSource.setAccessToken(HARDCODED_ACCESS_TOKEN).collect() + authDataSource.setRefreshToken(HARDCODED_REFRESH_TOKEN).collect() + } + + suspend fun getAccessToken(): String? { + return authDataSource.getAccessToken().first() + } + + suspend fun getRefreshToken(): String? { + return authDataSource.getRefreshToken().first() + } + + suspend fun clearTokens() { + authDataSource.deleteAccessToken() + authDataSource.deleteRefreshToken() + } +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt index cdc9667..0843965 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt @@ -11,12 +11,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.wear.compose.material.TimeText -import androidx.wear.tooling.preview.devices.WearDevices import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.material.TimeText import com.combo.runcombi.domain.user.model.MemberStatus +import com.combo.runcombi.walk.model.ExerciseType import com.combo.runcombi.wear.presentation.theme.RunCombi_AndroidTheme import dagger.hilt.android.AndroidEntryPoint @@ -40,16 +40,93 @@ fun WearApp() { RunCombi_AndroidTheme { val viewModel: WearAuthViewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() - + var currentScreen by remember { mutableStateOf("main") } + var selectedPets by remember { + mutableStateOf>( + emptyList() + ) + } + var selectedExerciseType by remember { mutableStateOf(null) } + var exerciseResult by remember { mutableStateOf(null) } + Box( modifier = Modifier.fillMaxSize() ) { TimeText() - - if (uiState.isLoggedIn && uiState.memberStatus == MemberStatus.LIVE && uiState.userInfo != null) { - WearMainScreen(userInfo = uiState.userInfo!!) - } else { - WearAuthScreen(viewModel = viewModel) + + when { + uiState.isLoggedIn && uiState.memberStatus == MemberStatus.LIVE && uiState.userInfo != null -> { + when (currentScreen) { + "main" -> { + WearMainScreen( + userInfo = uiState.userInfo!!, + onStartExercise = { currentScreen = "petSelect" }, + ) + } + + "petSelect" -> { + WearPetSelectScreen( + userInfo = uiState.userInfo!!, + onStartExercise = { pets -> + selectedPets = pets + currentScreen = "exerciseType" + }, + onBack = { currentScreen = "main" } + ) + } + + "exerciseType" -> { + WearExerciseTypeSelectScreen( + selectedPets = selectedPets, + onExerciseTypeSelected = { exerciseType -> + selectedExerciseType = exerciseType + currentScreen = "exercise" + }, + onBack = { currentScreen = "petSelect" } + ) + } + + "exercise" -> { + selectedExerciseType?.let { exerciseType -> + WearExerciseTrackingScreen( + member = uiState.userInfo!!.member, + selectedPets = selectedPets, + exerciseType = exerciseType, + onFinish = { time, distance -> + // 운동 결과 생성 + exerciseResult = ExerciseResult( + time = time, + distance = distance, + exerciseType = exerciseType, + member = uiState.userInfo!!.member, + selectedPets = selectedPets + ) + currentScreen = "result" + }, + onBack = { currentScreen = "exerciseType" } + ) + } + } + + "result" -> { + exerciseResult?.let { result -> + WearExerciseResultScreen( + result = result, + onBackToMain = { + selectedPets = emptyList() + selectedExerciseType = null + exerciseResult = null + currentScreen = "main" + } + ) + } + } + } + } + + else -> { + WearAuthScreen(viewModel = viewModel) + } } } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt index 1102b86..94c5898 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt @@ -5,8 +5,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -31,7 +31,7 @@ fun WearAuthScreen( Box( modifier = Modifier .fillMaxSize() - .padding(16.dp), + .padding(8.dp), contentAlignment = Alignment.Center ) { when { @@ -46,16 +46,16 @@ fun WearAuthScreen( uiState.isLoggedIn && uiState.memberStatus != MemberStatus.LIVE -> { IncompleteProfileScreen( memberStatus = uiState.memberStatus, - onRefresh = { viewModel.refreshAuthStatus() } + onRefresh = { /* TODO: 새로고침 기능 */ } ) } else -> { LoginRequiredScreen( - onLoginClick = { viewModel.requestMobileLogin() }, + onLoginClick = { /* TODO: 로그인 기능 */ }, error = uiState.error, onClearError = { viewModel.clearError() }, - isWaitingForResponse = uiState.isWaitingForMobileResponse + isWaitingForResponse = false ) } } @@ -64,20 +64,24 @@ fun WearAuthScreen( @Composable private fun LoadingScreen() { - val scrollState = rememberScrollState() + val listState = rememberScalingLazyListState() - Column( + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.verticalScroll(scrollState) + verticalArrangement = Arrangement.Center ) { - CircularProgressIndicator() - Text( - text = "로그인 상태 확인 중...", - style = MaterialTheme.typography.body2, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 16.dp) - ) + item { + CircularProgressIndicator() + } + item { + Text( + text = "확인 중...", + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + } } } @@ -88,56 +92,67 @@ private fun LoginRequiredScreen( onClearError: () -> Unit, isWaitingForResponse: Boolean = false ) { - val scrollState = rememberScrollState() + val listState = rememberScalingLazyListState() - Column( + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.verticalScroll(scrollState) + verticalArrangement = Arrangement.Center ) { - Text( - text = "RunCombi", - textAlign = TextAlign.Center - ) - - if (isWaitingForResponse) { + item { Text( - text = "모바일 앱 응답 대기 중...", - style = MaterialTheme.typography.body2, - textAlign = TextAlign.Center, - modifier = Modifier.padding(vertical = 16.dp) + text = "RunCombi", + style = MaterialTheme.typography.title2, + textAlign = TextAlign.Center ) + } + + if (isWaitingForResponse) { + item { + Text( + text = "응답 대기 중...", + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp) + ) + } - CircularProgressIndicator( - modifier = Modifier.padding(vertical = 8.dp) - ) + item { + CircularProgressIndicator( + modifier = Modifier.padding(vertical = 4.dp) + ) + } } else { - Text( - text = "모바일 앱에서 로그인해주세요", - style = MaterialTheme.typography.body2, - textAlign = TextAlign.Center, - modifier = Modifier.padding(vertical = 16.dp) - ) + item { + Text( + text = "모바일 앱에서 로그인", + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp) + ) + } } if (error != null) { - Text( - text = error, - textAlign = TextAlign.Center, - color = MaterialTheme.colors.error, - modifier = Modifier.padding(bottom = 16.dp) - ) + item { + Text( + text = error, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error, + modifier = Modifier.padding(bottom = 8.dp) + ) + } } if (!isWaitingForResponse) { - RunCombiButton( - modifier = Modifier.padding(horizontal = 30.dp), - text = "로그인", - onClick = { - onClearError() - onLoginClick() - } - ) + item { + RunCombiButton( + text = "로그인", + onClick = { + onClearError() + onLoginClick() + } + ) + } } } } @@ -147,35 +162,42 @@ private fun IncompleteProfileScreen( memberStatus: MemberStatus, onRefresh: () -> Unit ) { - val scrollState = rememberScrollState() + val listState = rememberScalingLazyListState() - Column( + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.verticalScroll(scrollState) + verticalArrangement = Arrangement.Center ) { - Text( - text = "프로필 설정 필요", - textAlign = TextAlign.Center - ) - - val message = when (memberStatus) { - MemberStatus.PENDING_AGREE -> "약관 동의가 필요합니다" - MemberStatus.PENDING_MEMBER_DETAIL -> "회원 정보 입력이 필요합니다" - MemberStatus.LIVE -> "이미 완료된 상태입니다" + item { + Text( + text = "프로필 설정 필요", + style = MaterialTheme.typography.title2, + textAlign = TextAlign.Center + ) } - Text( - text = message, - style = MaterialTheme.typography.body2, - textAlign = TextAlign.Center, - modifier = Modifier.padding(vertical = 16.dp) - ) + item { + val message = when (memberStatus) { + MemberStatus.PENDING_AGREE -> "약관 동의 필요" + MemberStatus.PENDING_MEMBER_DETAIL -> "회원 정보 입력 필요" + MemberStatus.LIVE -> "완료됨" + } + + Text( + text = message, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp) + ) + } - RunCombiButton( - text = "새로고침", - onClick = onRefresh - ) + item { + RunCombiButton( + text = "새로고침", + onClick = onRefresh + ) + } } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt index 3c4d4da..9c7bd39 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt @@ -2,14 +2,20 @@ package com.combo.runcombi.wear.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.combo.runcombi.common.DomainResult +import com.combo.runcombi.domain.user.model.Gender +import com.combo.runcombi.domain.user.model.Member import com.combo.runcombi.domain.user.model.MemberStatus +import com.combo.runcombi.domain.user.model.Pet +import com.combo.runcombi.domain.user.model.RunStyle import com.combo.runcombi.domain.user.model.UserInfo -import com.combo.runcombi.wear.auth.WearAuthManager -import com.combo.runcombi.wear.data.WearDataSyncService +import com.combo.runcombi.domain.user.usecase.GetUserInfoUseCase +import com.combo.runcombi.wear.auth.WearTokenManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -24,94 +30,85 @@ data class WearAuthUiState( @HiltViewModel class WearAuthViewModel @Inject constructor( - private val wearAuthManager: WearAuthManager, - private val wearDataSyncService: WearDataSyncService + private val getUserInfoUseCase: GetUserInfoUseCase, + private val wearTokenManager: WearTokenManager ) : ViewModel() { private val _uiState = MutableStateFlow(WearAuthUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { - checkAuthStatus() - wearAuthManager.setOnTokenReceivedCallback { - onTokenReceived() - } + // 하드코딩된 토큰 초기화 및 유저 정보 조회 + initializeTokensAndLoadUserInfo() } - private fun checkAuthStatus() { + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + private fun initializeTokensAndLoadUserInfo() { viewModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true) try { - val isLoggedIn = wearAuthManager.isLoggedIn() + // 하드코딩된 토큰 초기화 + android.util.Log.d("WearAuthViewModel", "토큰 초기화 시작") + wearTokenManager.initializeTokens() + android.util.Log.d("WearAuthViewModel", "토큰 초기화 완료") - if (isLoggedIn) { - val memberStatus = wearAuthManager.getUserStatus() - val userInfo = if (memberStatus == MemberStatus.LIVE) { - wearAuthManager.getUserInfo() - } else null - - _uiState.value = _uiState.value.copy( - isLoading = false, - isLoggedIn = true, - userInfo = userInfo, - memberStatus = memberStatus - ) - } else { - _uiState.value = _uiState.value.copy( - isLoading = false, - isLoggedIn = false - ) - } - } catch (e: Exception) { - _uiState.value = _uiState.value.copy( - isLoading = false, - error = e.message - ) - } - } - } - - fun requestMobileLogin() { - viewModelScope.launch { - try { - android.util.Log.d("WearAuthViewModel", "Requesting mobile login...") - _uiState.value = _uiState.value.copy(isWaitingForMobileResponse = true) + // 실제 API로 유저 정보 조회 + android.util.Log.d("WearAuthViewModel", "유저 정보 API 호출 시작") + val result = getUserInfoUseCase().first() + android.util.Log.d("WearAuthViewModel", "유저 정보 API 호출 완료: $result") - wearDataSyncService.requestMobileLogin() - android.util.Log.d("WearAuthViewModel", "Mobile login request sent") + // 결과 타입별 상세 로그 + when (result) { + is DomainResult.Success -> { + android.util.Log.d("WearAuthViewModel", "Success - 데이터: ${result.data}") + } + is DomainResult.Error -> { + android.util.Log.e("WearAuthViewModel", "Error - 코드: ${result.code}, 메시지: ${result.message}") + } + is DomainResult.Exception -> { + android.util.Log.e("WearAuthViewModel", "Exception - 에러: ${result.error}") + } + } - // 10초 후 응답이 없으면 타임아웃 처리 - kotlinx.coroutines.delay(10000) - if (_uiState.value.isWaitingForMobileResponse) { - _uiState.value = _uiState.value.copy( - isWaitingForMobileResponse = false, - error = "모바일 앱이 실행되지 않았거나 연결되지 않았습니다" - ) + when (result) { + is DomainResult.Success -> { + android.util.Log.d("WearAuthViewModel", "유저 정보 조회 성공: ${result.data}") + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoggedIn = true, + userInfo = result.data, + memberStatus = result.data.memberStatus + ) + } + is DomainResult.Error -> { + android.util.Log.e("WearAuthViewModel", "유저 정보 조회 실패: ${result.message}") + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoggedIn = false, + error = "유저 정보 조회 실패: ${result.message}" + ) + } + else -> { + android.util.Log.e("WearAuthViewModel", "알 수 없는 오류: $result") + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoggedIn = false, + error = "알 수 없는 오류" + ) + } } } catch (e: Exception) { - android.util.Log.e("WearAuthViewModel", "Failed to request mobile login", e) + android.util.Log.e("WearAuthViewModel", "유저 정보 조회 중 오류 발생", e) _uiState.value = _uiState.value.copy( - isWaitingForMobileResponse = false, - error = "모바일 로그인 요청 실패: ${e.message}" + isLoading = false, + isLoggedIn = false, + error = "유저 정보 조회 중 오류 발생: ${e.message}" ) } } } - - fun refreshAuthStatus() { - checkAuthStatus() - } - - fun clearError() { - _uiState.value = _uiState.value.copy(error = null) - } - - fun onTokenReceived() { - _uiState.value = _uiState.value.copy( - isWaitingForMobileResponse = false - ) - // 토큰을 받았으므로 인증 상태 다시 확인 - checkAuthStatus() - } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseResultScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseResultScreen.kt new file mode 100644 index 0000000..69f4b42 --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseResultScreen.kt @@ -0,0 +1,170 @@ +package com.combo.runcombi.wear.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.Text +import androidx.wear.tooling.preview.devices.WearDevices +import com.combo.runcombi.core.designsystem.component.RunCombiButton +import com.combo.runcombi.core.designsystem.theme.Primary01 +import com.combo.runcombi.core.designsystem.theme.Grey08 +import com.combo.runcombi.core.designsystem.theme.Grey02 +import com.combo.runcombi.domain.user.model.Member +import com.combo.runcombi.domain.user.model.Pet +import com.combo.runcombi.walk.model.ExerciseType +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class ExerciseResult( + val time: String, + val distance: Double, + val exerciseType: ExerciseType, + val member: Member, + val selectedPets: List, + val isFirstRun: Boolean = false, + val nthRun: Int = 1, +) + +@Composable +fun WearExerciseResultScreen( + result: ExerciseResult, + onBackToMain: () -> Unit, +) { + Scaffold( + timeText = { TimeText() } + ) { + val listState = rememberScalingLazyListState() + + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) + ) { + item { + // 완료 메시지 + Text( + text = "완료!", + style = MaterialTheme.typography.title1, + color = Primary01, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ) + } + + item { + // 운동 타입 표시 + Text( + text = when (result.exerciseType) { + ExerciseType.SLOW_WALKING -> "걷기" + ExerciseType.WALKING -> "빠른 걷기" + ExerciseType.RUNNING -> "조깅" + }, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onSurface, + textAlign = TextAlign.Center + ) + } + + item { + // 운동 통계 - 간단하게 + Text( + text = "${result.time} / ${String.format("%.1f", result.distance)}km", + style = MaterialTheme.typography.title2, + color = MaterialTheme.colors.onSurface, + textAlign = TextAlign.Center + ) + } + + item { + // 함께한 친구들 - 간단하게 + Text( + text = "${result.member.nickname} + ${ + result.selectedPets.map { it.name }.joinToString(", ") + }", + style = MaterialTheme.typography.body2, + color = Primary01, + textAlign = TextAlign.Center + ) + } + + item { + // 메인으로 돌아가기 버튼 + RunCombiButton( + text = "메인", + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onBackToMain, + textColor = Color.Black, + enabledColor = Primary01 + ) + } + } + } +} + + +@Preview(device = WearDevices.SMALL_ROUND) +@Composable +fun WearExerciseResultScreenPreview() { + MaterialTheme { + val sampleMember = Member( + nickname = "테스터", + height = 170, + weight = 70, + gender = com.combo.runcombi.domain.user.model.Gender.MALE, + profileImageUrl = "" + ) + + val samplePets = listOf( + Pet( + id = 1, + name = "멍멍이", + age = 3, + weight = 25.0, + runStyle = com.combo.runcombi.domain.user.model.RunStyle.RUNNING, + profileImageUrl = "" + ) + ) + + val sampleResult = ExerciseResult( + time = "25:30", + distance = 3.2, + exerciseType = ExerciseType.RUNNING, + member = sampleMember, + selectedPets = samplePets, + isFirstRun = false, + nthRun = 5 + ) + + WearExerciseResultScreen( + result = sampleResult, + onBackToMain = {} + ) + } +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTrackingScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTrackingScreen.kt new file mode 100644 index 0000000..690542b --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTrackingScreen.kt @@ -0,0 +1,342 @@ +package com.combo.runcombi.wear.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import androidx.wear.tooling.preview.devices.WearDevices +import androidx.hilt.navigation.compose.hiltViewModel +import com.combo.runcombi.core.designsystem.component.RunCombiButton +import com.combo.runcombi.core.designsystem.component.StableImage +import com.combo.runcombi.core.designsystem.component.NetworkImage +import com.combo.runcombi.core.designsystem.theme.Primary01 +import com.combo.runcombi.core.designsystem.theme.Grey08 +import com.combo.runcombi.core.designsystem.theme.Grey02 +import com.combo.runcombi.core.designsystem.theme.Grey01 +import com.combo.runcombi.domain.user.model.Member +import com.combo.runcombi.domain.user.model.Pet +import com.combo.runcombi.walk.model.ExerciseType +import com.combo.runcombi.wear.R +import kotlinx.coroutines.delay +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class ExerciseTrackingState( + val isTracking: Boolean = false, + val isPaused: Boolean = false, + val startTime: Long = 0L, + val pausedTime: Long = 0L, + val totalPausedDuration: Long = 0L, + val distance: Double = 0.0, + val currentTime: String = "00:00:00", +) + +@Composable +fun WearExerciseTrackingScreen( + member: Member, + selectedPets: List, + exerciseType: ExerciseType, + onFinish: (String, Double) -> Unit, + onBack: () -> Unit, + viewModel: WearExerciseViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + var isInitialized by remember { mutableStateOf(false) } + + // 초기화 - 운동 시작 + LaunchedEffect(selectedPets, exerciseType) { + if (!isInitialized && selectedPets.isNotEmpty()) { + viewModel.startExercise(selectedPets, exerciseType) + isInitialized = true + } + } + + // 시간 업데이트 및 거리 계산 + LaunchedEffect(uiState.isTracking, uiState.isPaused) { + while (uiState.isTracking) { + delay(1000) + val currentTime = System.currentTimeMillis() + val elapsed = currentTime - uiState.startTime - uiState.totalPausedDuration + val timeString = formatTime(elapsed) + viewModel.updateTime(timeString) + + // 거리 계산 (간단한 시뮬레이션 - 실제로는 GPS 데이터 사용) + if (!uiState.isPaused) { + val elapsedSeconds = elapsed / 1000.0 + val simulatedSpeed = 3.0 // km/h (평균 걷기 속도) + val newDistance = (simulatedSpeed * elapsedSeconds) / 3600.0 + viewModel.updateDistance(newDistance) + } + } + } + + val listState = rememberScalingLazyListState() + + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp) + ) { + item { + // 헤더 - 더 작게 + Text( + text = "운동 중", + style = MaterialTheme.typography.body1, + color = Grey08, + textAlign = TextAlign.Center + ) + } + + item { + // 시간 표시 + Text( + text = uiState.currentTime, + style = MaterialTheme.typography.title1, + color = Primary01, + textAlign = TextAlign.Center + ) + } + + item { + // 거리 표시 + Text( + text = "${String.format("%.1f", uiState.distance)}km", + style = MaterialTheme.typography.body2, + color = Grey08, + textAlign = TextAlign.Center + ) + } + + item { + // 콤비 정보 + Text( + text = "${member.nickname} + ${selectedPets.map { it.name }.joinToString(", ")}", + style = MaterialTheme.typography.body2, + color = Primary01, + textAlign = TextAlign.Center + ) + } + + item { + // 로딩 표시 + if (uiState.isLoading) { + CircularProgressIndicator() + } else { + // 컨트롤 버튼들 - WearOS에 맞게 간단하게 + if (!uiState.isTracking) { + // 시작 버튼 + RunCombiButton( + text = "시작", + onClick = { + viewModel.startExercise(selectedPets, exerciseType) + } + ) + } else { + // 운동 중 - 정지/완료 버튼 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // 운동 상태에 따른 버튼 표시 + when { + uiState.isTracking && !uiState.isPaused -> { + // 운동 중 - 일시정지 버튼 + RunCombiButton( + modifier = Modifier.padding(horizontal = 16.dp), + text = "일시정지", + onClick = { viewModel.pauseExercise() } + ) + } + uiState.isTracking && uiState.isPaused -> { + // 일시정지 상태 - 재개 버튼 + RunCombiButton( + modifier = Modifier.padding(horizontal = 16.dp), + text = "재개", + onClick = { viewModel.resumeExercise() } + ) + } + } + + // 완료 버튼 (항상 표시, 정지 상태에서만 활성화) + RunCombiButton( + text = "완료", + modifier = Modifier.padding(horizontal = 16.dp), + onClick = { + if (uiState.isPaused) { + viewModel.finishExercise() + onFinish(uiState.currentTime, uiState.distance) + } else { + // 운동 중이면 먼저 정지 + viewModel.pauseExercise() + } + }, + textColor = if (uiState.isPaused) Color.White else Grey08, + enabledColor = if (uiState.isPaused) MaterialTheme.colors.error else Grey02, + enabled = true + ) + } + } + } + } + + item { + // 에러 표시 + uiState.error?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.body2, + color = Color.Red, + textAlign = TextAlign.Center + ) + } + } + + item { + // 뒤로가기 버튼 - 작게 + RunCombiButton( + text = "뒤로", + onClick = onBack, + modifier = Modifier.padding(top = 4.dp, start = 16.dp, end = 16.dp) + ) + } + } +} + +@Composable +private fun CombiInfo( + member: Member, + pets: List, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "함께하는 콤비", + style = MaterialTheme.typography.body2, + color = Grey08 + ) + + // 유저 정보 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(24.dp) + .background(Primary01, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = member.nickname.take(1), + color = Color.White, + style = MaterialTheme.typography.body2 + ) + } + Text( + text = member.nickname, + style = MaterialTheme.typography.body2, + color = Grey08 + ) + } + + // 펫 정보 + pets.forEach { pet -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + NetworkImage( + imageUrl = pet.profileImageUrl, + drawableResId = android.R.drawable.ic_menu_gallery, + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Text( + text = pet.name, + style = MaterialTheme.typography.body2, + color = Grey08 + ) + } + } + } +} + + +private fun formatTime(milliseconds: Long): String { + val totalSeconds = milliseconds / 1000 + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + return String.format("%02d:%02d:%02d", hours, minutes, seconds) +} + + +@Preview(device = WearDevices.SMALL_ROUND) +@Composable +fun WearExerciseTrackingScreenPreview() { + val sampleMember = Member( + nickname = "테스트유저", + gender = com.combo.runcombi.domain.user.model.Gender.MALE, + height = 175, + weight = 70, + profileImageUrl = "" + ) + + val samplePets = listOf( + Pet( + id = 1, + name = "멍멍이", + age = 3, + weight = 25.0, + runStyle = com.combo.runcombi.domain.user.model.RunStyle.RUNNING, + profileImageUrl = "" + ) + ) + + WearExerciseTrackingScreen( + member = sampleMember, + selectedPets = samplePets, + exerciseType = ExerciseType.RUNNING, + onFinish = { _, _ -> }, + onBack = {} + ) +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTypeSelectScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTypeSelectScreen.kt new file mode 100644 index 0000000..73f0daf --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTypeSelectScreen.kt @@ -0,0 +1,153 @@ +package com.combo.runcombi.wear.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.Text +import androidx.wear.tooling.preview.devices.WearDevices +import com.combo.runcombi.core.designsystem.component.RunCombiButton +import com.combo.runcombi.core.designsystem.theme.Primary01 +import com.combo.runcombi.core.designsystem.theme.Grey08 +import com.combo.runcombi.domain.user.model.Pet +import com.combo.runcombi.walk.model.ExerciseType + +@Composable +fun WearExerciseTypeSelectScreen( + selectedPets: List, + onExerciseTypeSelected: (ExerciseType) -> Unit, + onBack: () -> Unit +) { + val selectedPetNames = selectedPets.joinToString { it.name } + + Scaffold( + timeText = { TimeText() } + ) { + val listState = rememberScalingLazyListState() + + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) + ) { + item { + Text( + text = "운동 선택", + style = MaterialTheme.typography.title2, + color = MaterialTheme.colors.onSurface, + textAlign = TextAlign.Center + ) + } + + item { + Text( + text = "${selectedPetNames}와 함께", + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface, + textAlign = TextAlign.Center + ) + } + + item { + // 운동 타입 버튼들 + ExerciseTypeButton( + text = "걷기", + onClick = { onExerciseTypeSelected(ExerciseType.SLOW_WALKING) } + ) + } + + item { + ExerciseTypeButton( + text = "빠른 걷기", + onClick = { onExerciseTypeSelected(ExerciseType.WALKING) } + ) + } + + item { + ExerciseTypeButton( + text = "조깅", + onClick = { onExerciseTypeSelected(ExerciseType.RUNNING) } + ) + } + + item { + // 뒤로가기 버튼 + RunCombiButton( + text = "뒤로", + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onBack + ) + } + } + } +} + +@Composable +private fun ExerciseTypeButton( + text: String, + onClick: () -> Unit +) { + RunCombiButton( + text = text, + onClick = onClick, + modifier = Modifier.padding(horizontal = 16.dp), + textColor = Color.Black, + enabledColor = Primary01 + ) +} + +@Preview(device = WearDevices.SMALL_ROUND) +@Composable +fun WearExerciseTypeSelectScreenPreview() { + MaterialTheme { + val samplePets = listOf( + Pet( + id = 1, + name = "멍멍이", + age = 3, + weight = 25.0, + runStyle = com.combo.runcombi.domain.user.model.RunStyle.RUNNING, + profileImageUrl = "" + ), + Pet( + id = 2, + name = "야옹이", + age = 2, + weight = 4.5, + runStyle = com.combo.runcombi.domain.user.model.RunStyle.WALKING, + profileImageUrl = "" + ) + ) + + WearExerciseTypeSelectScreen( + selectedPets = samplePets, + onExerciseTypeSelected = {}, + onBack = {} + ) + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseViewModel.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseViewModel.kt new file mode 100644 index 0000000..2231391 --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseViewModel.kt @@ -0,0 +1,172 @@ +package com.combo.runcombi.wear.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.combo.runcombi.common.DomainResult +import com.combo.runcombi.domain.user.model.Pet +import com.combo.runcombi.walk.model.ExerciseType +import com.combo.runcombi.walk.usecase.MidRunUpdateUseCase +import com.combo.runcombi.walk.usecase.StartRunUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class WearExerciseUiState( + val isTracking: Boolean = false, + val isPaused: Boolean = false, + val startTime: Long = 0L, + val pausedTime: Long = 0L, + val totalPausedDuration: Long = 0L, + val distance: Double = 0.0, + val currentTime: String = "00:00:00", + val runId: Int? = null, + val selectedPetIds: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val isCompleted: Boolean = false, +) + +@HiltViewModel +class WearExerciseViewModel @Inject constructor( + private val startRunUseCase: StartRunUseCase, + private val midRunUpdateUseCase: MidRunUpdateUseCase, +) : ViewModel() { + + private val _uiState = MutableStateFlow(WearExerciseUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun startExercise(pets: List, exerciseType: ExerciseType) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + try { + val petIds = pets.map { it.id } + _uiState.value = _uiState.value.copy(selectedPetIds = petIds) + + val result = startRunUseCase(petIds, exerciseType.name) + + when (result) { + is DomainResult.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + isTracking = true, + startTime = System.currentTimeMillis(), + runId = result.data.runId + ) + } + + is DomainResult.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "운동 시작 실패: ${result.message}" + ) + } + + else -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "알 수 없는 오류" + ) + } + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "운동 시작 중 오류 발생: ${e.message}" + ) + } + } + } + + fun pauseExercise() { + _uiState.value = _uiState.value.copy( + isPaused = true, + pausedTime = System.currentTimeMillis() + ) + } + + fun resumeExercise() { + val currentTime = System.currentTimeMillis() + val pausedDuration = currentTime - _uiState.value.pausedTime + _uiState.value = _uiState.value.copy( + isPaused = false, + totalPausedDuration = _uiState.value.totalPausedDuration + pausedDuration, + pausedTime = 0L + ) + } + + fun finishExercise() { + viewModelScope.launch { + val currentState = _uiState.value + if (currentState.runId == null) return@launch + + // 정지 상태가 아니면 먼저 정지 + if (!currentState.isPaused) { + _uiState.value = currentState.copy(isPaused = true) + } + + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + try { + val currentTime = System.currentTimeMillis() + val totalElapsed = + currentTime - currentState.startTime - currentState.totalPausedDuration + val runTimeSeconds = (totalElapsed / 1000).toInt() + + // 선택된 펫 ID들 사용 + val petIds = currentState.selectedPetIds + + // 산책 중간 업데이트 API 호출 + val midRunUpdateResult = midRunUpdateUseCase( + runId = currentState.runId, + runTime = runTimeSeconds, + runDistance = currentState.distance + ) + + when (midRunUpdateResult) { + is DomainResult.Success -> { + // 중간 업데이트 성공 시 운동 완료 처리 + _uiState.value = _uiState.value.copy( + isLoading = false, + isTracking = false, + isPaused = false, + isCompleted = true + ) + } + is DomainResult.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "산책 중간 업데이트 실패: ${midRunUpdateResult.message}" + ) + } + else -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "산책 중간 업데이트 중 알 수 없는 오류" + ) + } + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "운동 기록 저장 중 오류 발생: ${e.message}" + ) + } + } + } + + fun updateTime(timeString: String) { + _uiState.value = _uiState.value.copy(currentTime = timeString) + } + + fun updateDistance(distance: Double) { + _uiState.value = _uiState.value.copy(distance = distance) + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt index 774db33..f568eca 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt @@ -2,10 +2,12 @@ package com.combo.runcombi.wear.presentation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -28,63 +30,41 @@ import com.combo.runcombi.wear.presentation.theme.RunCombi_AndroidTheme fun WearMainScreen( userInfo: UserInfo, onStartExercise: () -> Unit = {}, - onViewHistory: () -> Unit = {}, - onSettings: () -> Unit = {} ) { - val scrollState = rememberScrollState() + val listState = rememberScalingLazyListState() - Column( + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(scrollState) + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) ) { - // 환영 메시지 - Text( - text = "안녕하세요!", - textAlign = TextAlign.Center - ) - - Text( - text = userInfo.member.nickname, - textAlign = TextAlign.Center - ) - - Text( - text = "콤비 ${userInfo.petList.size}마리와 함께", - style = MaterialTheme.typography.body2, - textAlign = TextAlign.Center - ) - - // 운동 시작 버튼 - RunCombiButton( - text = "운동 시작", - onClick = onStartExercise, - modifier = Modifier.padding(vertical = 8.dp) - ) + item { + Text( + text = userInfo.member.nickname, + style = MaterialTheme.typography.title2, + textAlign = TextAlign.Center + ) + } - // 운동 기록 보기 버튼 - RunCombiButton( - text = "운동 기록", - onClick = onViewHistory, - modifier = Modifier.padding(vertical = 4.dp) - ) + item { + Text( + text = "콤비 ${userInfo.petList.size}마리", + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center + ) + } - // 설정 버튼 - RunCombiButton( - text = "설정", - onClick = onSettings, - modifier = Modifier.padding(vertical = 4.dp) - ) + item { + // 운동 시작 버튼 + RunCombiButton( + text = "운동 시작", + onClick = onStartExercise, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp) + ) + } - // 추가 정보 - Text( - text = "오늘도 화이팅!", - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 16.dp) - ) } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearPetSelectScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearPetSelectScreen.kt new file mode 100644 index 0000000..afb1693 --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearPetSelectScreen.kt @@ -0,0 +1,267 @@ +package com.combo.runcombi.wear.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import androidx.wear.tooling.preview.devices.WearDevices +import com.combo.runcombi.core.designsystem.component.NetworkImage +import com.combo.runcombi.core.designsystem.component.RunCombiButton +import com.combo.runcombi.core.designsystem.theme.Grey08 +import com.combo.runcombi.core.designsystem.theme.Primary01 +import com.combo.runcombi.domain.user.model.Member +import com.combo.runcombi.domain.user.model.Pet +import com.combo.runcombi.domain.user.model.UserInfo +import com.combo.runcombi.wear.presentation.theme.RunCombi_AndroidTheme + +data class PetUiModel( + val pet: Pet, + val isSelected: Boolean = false, + val originIndex: Int, + val selectedOrder: Int? = null, +) + +@Composable +fun WearPetSelectScreen( + userInfo: UserInfo, + onStartExercise: (List) -> Unit, + onBack: () -> Unit, +) { + var petUiList by remember { + mutableStateOf( + userInfo.petList.mapIndexed { idx, pet -> + PetUiModel(pet = pet, isSelected = false, originIndex = idx) + }) + } + + val selectedPets = petUiList.filter { it.isSelected }.map { it.pet } + + val listState = rememberScalingLazyListState() + + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) + ) { + item { + Text( + text = "콤비 선택", + style = MaterialTheme.typography.title2, + color = Grey08, + textAlign = TextAlign.Center + ) + } + + item { + Text( + text = userInfo.member.nickname, + style = MaterialTheme.typography.body1, + color = Primary01, + textAlign = TextAlign.Center + ) + } + + // 펫 목록 + items(petUiList) { petUi -> + PetSelectItem( + petUi = petUi, onClick = { + petUiList = togglePetSelection(petUiList, petUi.pet) + }) + } + + item { + // 선택된 펫 표시 + if (selectedPets.isNotEmpty()) { + Text( + text = "${selectedPets.map { it.name }.joinToString()} 선택됨", + style = MaterialTheme.typography.body2, + color = Primary01, + textAlign = TextAlign.Center + ) + } + } + + item { + // 시작 버튼 + RunCombiButton( + text = "시작", + onClick = { onStartExercise(selectedPets) }, + modifier = Modifier.padding(horizontal = 16.dp), + enabled = selectedPets.isNotEmpty() + ) + } + + item { + // 뒤로가기 버튼 + RunCombiButton( + text = "뒤로", + onClick = onBack, + modifier = Modifier.padding(top = 4.dp, start = 16.dp, end = 16.dp) + ) + } + } +} + +@Composable +private fun MemberProfile(member: Member) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // 유저 아바타 (간단한 원형 배경) + Box( + modifier = Modifier + .size(32.dp) + .background(Primary01, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = member.nickname.take(1), + color = Color.White, + style = MaterialTheme.typography.body2 + ) + } + + Text( + text = member.nickname, style = MaterialTheme.typography.body1, color = Grey08 + ) + } +} + +@Composable +private fun PetSelectItem( + petUi: PetUiModel, + onClick: () -> Unit, +) { + Row(modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .background( + if (petUi.isSelected) Primary01.copy(alpha = 0.1f) else Color.Transparent, + RoundedCornerShape(4.dp) + ) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp)) { + // 펫 정보 - 간단하게 + Text( + text = petUi.pet.name, + style = MaterialTheme.typography.body2, + color = if (petUi.isSelected) Primary01 else Grey08, + modifier = Modifier.weight(1f) + ) + + // 선택 표시 + if (petUi.isSelected) { + Text( + text = "✓", color = Primary01, style = MaterialTheme.typography.body1 + ) + } + } +} + +private fun togglePetSelection( + petUiList: List, + pet: Pet, +): List { + val selectedList = + petUiList.filter { it.isSelected }.sortedBy { it.selectedOrder ?: Int.MAX_VALUE } + val tapped = petUiList.find { it.pet == pet } ?: return petUiList + + return if (tapped.isSelected) { + // 선택 해제 + deselectPet(petUiList, pet) + } else { + // 선택 (최대 2마리) + if (selectedList.size >= 2) return petUiList + selectPet(petUiList, pet, selectedList.size) + } +} + +private fun deselectPet(petUiList: List, pet: Pet): List { + val newList = petUiList.map { + if (it.pet == pet) it.copy(isSelected = false, selectedOrder = null) + else it + } + val remainSelected = + newList.filter { it.isSelected }.sortedBy { it.selectedOrder ?: Int.MAX_VALUE } + return if (remainSelected.isNotEmpty()) { + newList.map { + if (it.isSelected) { + val idx = remainSelected.indexOfFirst { sel -> sel.pet == it.pet } + it.copy(selectedOrder = idx) + } else it + } + } else { + newList.map { it.copy(selectedOrder = null) } + } +} + +private fun selectPet(petUiList: List, pet: Pet, nextOrder: Int): List = + petUiList.map { + if (it.pet == pet) it.copy(isSelected = true, selectedOrder = nextOrder) + else it + } + +@Preview(device = WearDevices.SMALL_ROUND) +@Composable +fun WearPetSelectScreenPreview() { + RunCombi_AndroidTheme { + val sampleUserInfo = UserInfo( + member = Member( + nickname = "테스트유저", + gender = com.combo.runcombi.domain.user.model.Gender.MALE, + height = 175, + weight = 70, + profileImageUrl = "" + ), petList = listOf( + Pet( + id = 1, + name = "멍멍이", + age = 3, + weight = 25.0, + runStyle = com.combo.runcombi.domain.user.model.RunStyle.RUNNING, + profileImageUrl = "" + ), Pet( + id = 2, + name = "야옹이", + age = 2, + weight = 4.5, + runStyle = com.combo.runcombi.domain.user.model.RunStyle.WALKING, + profileImageUrl = "" + ) + ), memberStatus = com.combo.runcombi.domain.user.model.MemberStatus.LIVE + ) + + WearPetSelectScreen(userInfo = sampleUserInfo, onStartExercise = {}, onBack = {}) + } +} diff --git a/wear/src/main/res/drawable/ic_pause.xml b/wear/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..f4ec0cc --- /dev/null +++ b/wear/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/wear/src/main/res/drawable/ic_pet_box.xml b/wear/src/main/res/drawable/ic_pet_box.xml new file mode 100644 index 0000000..6f4eede --- /dev/null +++ b/wear/src/main/res/drawable/ic_pet_box.xml @@ -0,0 +1,9 @@ + + + diff --git a/wear/src/main/res/drawable/ic_pet_box_center.xml b/wear/src/main/res/drawable/ic_pet_box_center.xml new file mode 100644 index 0000000..fe26368 --- /dev/null +++ b/wear/src/main/res/drawable/ic_pet_box_center.xml @@ -0,0 +1,9 @@ + + + diff --git a/wear/src/main/res/drawable/ic_pet_defalut.xml b/wear/src/main/res/drawable/ic_pet_defalut.xml new file mode 100644 index 0000000..194d183 --- /dev/null +++ b/wear/src/main/res/drawable/ic_pet_defalut.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/wear/src/main/res/drawable/ic_resume.xml b/wear/src/main/res/drawable/ic_resume.xml new file mode 100644 index 0000000..ce11fc9 --- /dev/null +++ b/wear/src/main/res/drawable/ic_resume.xml @@ -0,0 +1,9 @@ + + + diff --git a/wear/src/main/res/drawable/ic_stop.xml b/wear/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000..89cdc8c --- /dev/null +++ b/wear/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/wear/src/main/res/drawable/ic_user_box.xml b/wear/src/main/res/drawable/ic_user_box.xml new file mode 100644 index 0000000..05cb591 --- /dev/null +++ b/wear/src/main/res/drawable/ic_user_box.xml @@ -0,0 +1,9 @@ + + + diff --git a/wear/src/main/res/drawable/person_profile.xml b/wear/src/main/res/drawable/person_profile.xml new file mode 100644 index 0000000..7f19abc --- /dev/null +++ b/wear/src/main/res/drawable/person_profile.xml @@ -0,0 +1,12 @@ + + + + From a1dc87d17195b6fdd3b4e031be8f1a7f6bb818b4 Mon Sep 17 00:00:00 2001 From: changs97 Date: Mon, 29 Sep 2025 21:59:03 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat:=20wear=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 3 - feature/main/build.gradle.kts | 1 + .../main/wear/WearConnectionManager.kt | 78 ++++- .../runcombi/main/wear/WearSyncService.kt | 3 - wear/build.gradle.kts | 1 + wear/src/main/AndroidManifest.xml | 10 +- .../runcombi/wear/auth/WearTokenManager.kt | 16 +- .../runcombi/wear/data/WearDataSyncService.kt | 19 +- .../runcombi/wear/data/WearTokenReceiver.kt | 62 ++++ .../wear/presentation/MainActivity.kt | 40 ++- .../wear/presentation/WearAuthScreen.kt | 7 +- .../wear/presentation/WearAuthViewModel.kt | 84 +++-- .../WearExerciseTrackingScreen.kt | 37 +-- .../presentation/WearExerciseViewModel.kt | 313 +++++++++++++++++- .../wear/presentation/WearMainScreen.kt | 78 ++++- 16 files changed, 644 insertions(+), 111 deletions(-) create mode 100644 wear/src/main/java/com/combo/runcombi/wear/data/WearTokenReceiver.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb8bd54..3b47b44 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 9932358..43b626a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -54,8 +54,6 @@ - - \ No newline at end of file diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts index 5f2b015..0043eea 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -11,6 +11,7 @@ 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) 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 index acf0e3f..ff70fdf 100644 --- 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 @@ -1,7 +1,5 @@ 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 @@ -11,6 +9,9 @@ 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 @@ -24,20 +25,34 @@ class WearConnectionManager @Inject constructor( 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) } } } -@AndroidEntryPoint class MobileWearDataListenerService : WearableListenerService() { - @Inject - lateinit var wearConnectionManager: WearConnectionManager + 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") + } catch (e: Exception) { + android.util.Log.e("MobileWearDataListenerService", "Failed to initialize DataClient", e) + } + } override fun onDataChanged(dataEvents: DataEventBuffer) { super.onDataChanged(dataEvents) @@ -55,10 +70,10 @@ class MobileWearDataListenerService : WearableListenerService() { // 워치에서 로그인 요청을 받았을 때 CoroutineScope(Dispatchers.IO).launch { try { - wearConnectionManager.syncUserDataToWear() - android.util.Log.d("MobileWearDataListenerService", "User data synced to wear") + sendTokenToWear() + android.util.Log.d("MobileWearDataListenerService", "Token sent to wear") } catch (e: Exception) { - android.util.Log.e("MobileWearDataListenerService", "Failed to sync user data", e) + android.util.Log.e("MobileWearDataListenerService", "Failed to send token to wear", e) } } } @@ -66,5 +81,48 @@ class MobileWearDataListenerService : WearableListenerService() { } } } + + 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 getStoredAccessToken(): String? { + // 고정 토큰 사용 (개발용) + val fixedToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3NTkxNDg3ODYsInN1YiI6IjEzMiIsImV4cCI6MTc1OTc1MzU4Niwicm9sZSI6IlVTRVIifQ.rlUVKbI7BFqcm3W2JryWvqVtOt2PtYiS_qoz7Elcw_hgzlYiwWjaZlAZk1PAwRJmhl0VdddFVOzhd-mle6rRQA" + android.util.Log.d("MobileWearDataListenerService", "고정 토큰 사용: ${fixedToken.take(20)}...") + return fixedToken + } } -*/ 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 index 7fcc48d..c6d1c45 100644 --- 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 @@ -1,7 +1,5 @@ package com.combo.runcombi.main.wear -// 워치 연동 비활성화 -/* import android.content.Context import com.google.android.gms.wearable.DataClient import com.google.android.gms.wearable.DataMap @@ -46,4 +44,3 @@ class WearSyncService @Inject constructor( } } } -*/ diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index fc767b1..9585ee9 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { // 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) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 85b72da..b356080 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -4,6 +4,10 @@ + + + + @@ -36,14 +40,14 @@ - + \ No newline at end of file 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 index ffeab92..33a0d07 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/auth/WearTokenManager.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/auth/WearTokenManager.kt @@ -10,16 +10,11 @@ import javax.inject.Singleton class WearTokenManager @Inject constructor( private val authDataSource: AuthDataSource ) { - companion object { - // 하드코딩된 실제 토큰 - private const val HARDCODED_ACCESS_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3NTg1NDU5MDcsInN1YiI6IjEzMiIsImV4cCI6MTc1OTE1MDcwNywicm9sZSI6IlVTRVIifQ.1Vpd4jIy36CBWsVxasKOWGLegZMWengjJyd8rL4EmHnd7OcLz1Us0ZYX3uwNfhBPMKgpV0upPei-bq_IUbkGtg" - private const val HARDCODED_REFRESH_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3NTg1MzYyNTAsInN1YiI6Ijc5IiwiZXhwIjoxNzYxMTI4MjUwLCJtZW1iZXJJZCI6NzksInJvbGUiOiJVU0VSIn0.f9-eYiqwJ_c5kdZAbHr6j07DYEMVWjkSMpQzGqQbIkLWcHKDHsJ8K8FXSUC2yyO7QjR1xt143cp5lHhbTytnIA" - } suspend fun initializeTokens() { - // 앱 시작 시 하드코딩된 토큰을 저장 - authDataSource.setAccessToken(HARDCODED_ACCESS_TOKEN).collect() - authDataSource.setRefreshToken(HARDCODED_REFRESH_TOKEN).collect() + // 하드코딩된 토큰 초기화 비활성화 + // 모바일로부터 토큰을 받아서 사용 + android.util.Log.d("WearTokenManager", "하드코딩된 토큰 초기화 비활성화됨") } suspend fun getAccessToken(): String? { @@ -34,4 +29,9 @@ class WearTokenManager @Inject constructor( 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/WearDataSyncService.kt b/wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt index 077fb75..1280117 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt @@ -27,19 +27,24 @@ class WearDataSyncService @Inject constructor( val putDataMapRequest = PutDataMapRequest.create("/auth_token") putDataMapRequest.dataMap.putAll(dataMap) + android.util.Log.d("WearDataSyncService", "Sending token to wear: ${accessToken.take(20)}...") + suspendCancellableCoroutine { continuation -> dataClient.putDataItem(putDataMapRequest.asPutDataRequest()) .addOnSuccessListener { dataItem -> + android.util.Log.d("WearDataSyncService", "Token sent successfully to wear") continuation.resume(dataItem) } .addOnFailureListener { exception -> + android.util.Log.e("WearDataSyncService", "Failed to send token to wear", exception) continuation.resumeWithException(exception) } } } suspend fun requestMobileLogin() { - android.util.Log.d("WearDataSyncService", "Creating login request...") + android.util.Log.d("WearDataSyncService", "=== 모바일 로그인 요청 시작 ===") + android.util.Log.d("WearDataSyncService", "DataClient 상태 확인 중...") val dataMap = DataMap().apply { putString("action", "request_login") @@ -49,16 +54,22 @@ class WearDataSyncService @Inject constructor( val putDataMapRequest = PutDataMapRequest.create("/request_login") putDataMapRequest.dataMap.putAll(dataMap) - android.util.Log.d("WearDataSyncService", "Sending login request to mobile...") + android.util.Log.d("WearDataSyncService", "로그인 요청 데이터 생성 완료") + android.util.Log.d("WearDataSyncService", "경로: /request_login") + android.util.Log.d("WearDataSyncService", "액션: request_login") + android.util.Log.d("WearDataSyncService", "타임스탬프: ${System.currentTimeMillis()}") + + android.util.Log.d("WearDataSyncService", "모바일로 로그인 요청 전송 중...") suspendCancellableCoroutine { continuation -> dataClient.putDataItem(putDataMapRequest.asPutDataRequest()) .addOnSuccessListener { dataItem -> - android.util.Log.d("WearDataSyncService", "Login request sent successfully") + android.util.Log.d("WearDataSyncService", "=== 로그인 요청 전송 성공 ===") + android.util.Log.d("WearDataSyncService", "DataItem URI: ${dataItem.uri}") continuation.resume(dataItem) } .addOnFailureListener { exception -> - android.util.Log.e("WearDataSyncService", "Failed to send login request", exception) + android.util.Log.e("WearDataSyncService", "=== 로그인 요청 전송 실패 ===", exception) continuation.resumeWithException(exception) } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/data/WearTokenReceiver.kt b/wear/src/main/java/com/combo/runcombi/wear/data/WearTokenReceiver.kt new file mode 100644 index 0000000..714d1db --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/data/WearTokenReceiver.kt @@ -0,0 +1,62 @@ +package com.combo.runcombi.wear.data + +import android.content.Context +import com.combo.runcombi.wear.auth.WearTokenManager +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 javax.inject.Inject + +@AndroidEntryPoint +class WearTokenReceiver : WearableListenerService() { + + @Inject + lateinit var wearTokenManager: WearTokenManager + + override fun onDataChanged(dataEvents: DataEventBuffer) { + super.onDataChanged(dataEvents) + + android.util.Log.d("WearTokenReceiver", "onDataChanged called with ${dataEvents.count} events") + + for (event in dataEvents) { + if (event.type == DataEvent.TYPE_CHANGED) { + val dataItem = event.dataItem + android.util.Log.d("WearTokenReceiver", "Data changed: ${dataItem.uri.path}") + + when (dataItem.uri.path) { + "/auth_token" -> { + android.util.Log.d("WearTokenReceiver", "=== 모바일로부터 인증 토큰 수신 ===") + val dataMapItem = DataMapItem.fromDataItem(dataItem) + val dataMap = dataMapItem.dataMap + + val accessToken = dataMap.getString("access_token") + val timestamp = dataMap.getLong("timestamp") + + android.util.Log.d("WearTokenReceiver", "토큰 정보:") + android.util.Log.d("WearTokenReceiver", " - 토큰: ${accessToken?.take(20)}...") + android.util.Log.d("WearTokenReceiver", " - 타임스탬프: $timestamp") + + if (accessToken != null) { + android.util.Log.d("WearTokenReceiver", "로컬 저장소에 토큰 저장 중...") + CoroutineScope(Dispatchers.IO).launch { + try { + wearTokenManager.saveAccessTokenFromMobile(accessToken) + android.util.Log.d("WearTokenReceiver", "=== 토큰 저장 성공 ===") + } catch (e: Exception) { + android.util.Log.e("WearTokenReceiver", "=== 토큰 저장 실패 ===", e) + } + } + } else { + android.util.Log.w("WearTokenReceiver", "모바일로부터 null 토큰 수신") + } + } + } + } + } + } +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt index 0843965..db8ebce 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/MainActivity.kt @@ -1,8 +1,11 @@ package com.combo.runcombi.wear.presentation +import android.Manifest +import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -11,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -22,6 +26,16 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { + + private val locationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val hasPermission = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || + permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true + + android.util.Log.d("MainActivity", "위치 권한 요청 결과: $hasPermission") + } + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -30,16 +44,31 @@ class MainActivity : ComponentActivity() { setTheme(android.R.style.Theme_DeviceDefault) setContent { - WearApp() + WearApp( + onRequestLocationPermission = { requestLocationPermission() } + ) } } + + private fun requestLocationPermission() { + android.util.Log.d("MainActivity", "위치 권한 요청 시작") + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } } @Composable -fun WearApp() { +fun WearApp( + onRequestLocationPermission: () -> Unit = {} +) { RunCombi_AndroidTheme { - val viewModel: WearAuthViewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val authViewModel: WearAuthViewModel = hiltViewModel() + val exerciseViewModel: WearExerciseViewModel = hiltViewModel() + val uiState by authViewModel.uiState.collectAsStateWithLifecycle() var currentScreen by remember { mutableStateOf("main") } var selectedPets by remember { mutableStateOf>( @@ -61,6 +90,7 @@ fun WearApp() { WearMainScreen( userInfo = uiState.userInfo!!, onStartExercise = { currentScreen = "petSelect" }, + onRequestLocationPermission = onRequestLocationPermission ) } @@ -125,7 +155,7 @@ fun WearApp() { } else -> { - WearAuthScreen(viewModel = viewModel) + WearAuthScreen(viewModel = authViewModel) } } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt index 94c5898..cd9f685 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthScreen.kt @@ -52,10 +52,13 @@ fun WearAuthScreen( else -> { LoginRequiredScreen( - onLoginClick = { /* TODO: 로그인 기능 */ }, + onLoginClick = { + android.util.Log.d("WearAuthScreen", "로그인 버튼 클릭됨") + viewModel.requestTokenFromMobile() + }, error = uiState.error, onClearError = { viewModel.clearError() }, - isWaitingForResponse = false + isWaitingForResponse = uiState.isWaitingForMobileResponse ) } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt index 9c7bd39..4352bb9 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt @@ -11,6 +11,7 @@ import com.combo.runcombi.domain.user.model.RunStyle import com.combo.runcombi.domain.user.model.UserInfo import com.combo.runcombi.domain.user.usecase.GetUserInfoUseCase import com.combo.runcombi.wear.auth.WearTokenManager +import com.combo.runcombi.wear.data.WearDataSyncService import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -31,57 +32,74 @@ data class WearAuthUiState( @HiltViewModel class WearAuthViewModel @Inject constructor( private val getUserInfoUseCase: GetUserInfoUseCase, - private val wearTokenManager: WearTokenManager + private val wearTokenManager: WearTokenManager, + private val wearDataSyncService: WearDataSyncService ) : ViewModel() { private val _uiState = MutableStateFlow(WearAuthUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { - // 하드코딩된 토큰 초기화 및 유저 정보 조회 - initializeTokensAndLoadUserInfo() + // 모바일로부터 토큰 요청 + requestTokenFromMobile() } fun clearError() { _uiState.value = _uiState.value.copy(error = null) } - private fun initializeTokensAndLoadUserInfo() { + fun requestTokenFromMobile() { + android.util.Log.d("WearAuthViewModel", "=== 고정 토큰 사용 시작 ===") viewModelScope.launch { - _uiState.value = _uiState.value.copy(isLoading = true) + _uiState.value = _uiState.value.copy( + isLoading = true, + isWaitingForMobileResponse = true, + error = null + ) try { - // 하드코딩된 토큰 초기화 - android.util.Log.d("WearAuthViewModel", "토큰 초기화 시작") - wearTokenManager.initializeTokens() - android.util.Log.d("WearAuthViewModel", "토큰 초기화 완료") + // 고정 토큰 사용 + val fixedToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3NTkxNDg3ODYsInN1YiI6IjEzMiIsImV4cCI6MTc1OTc1MzU4Niwicm9sZSI6IlVTRVIifQ.rlUVKbI7BFqcm3W2JryWvqVtOt2PtYiS_qoz7Elcw_hgzlYiwWjaZlAZk1PAwRJmhl0VdddFVOzhd-mle6rRQA" - // 실제 API로 유저 정보 조회 - android.util.Log.d("WearAuthViewModel", "유저 정보 API 호출 시작") - val result = getUserInfoUseCase().first() - android.util.Log.d("WearAuthViewModel", "유저 정보 API 호출 완료: $result") + android.util.Log.d("WearAuthViewModel", "고정 토큰 설정 중...") + android.util.Log.d("WearAuthViewModel", "토큰: ${fixedToken.take(20)}...") - // 결과 타입별 상세 로그 - when (result) { - is DomainResult.Success -> { - android.util.Log.d("WearAuthViewModel", "Success - 데이터: ${result.data}") - } - is DomainResult.Error -> { - android.util.Log.e("WearAuthViewModel", "Error - 코드: ${result.code}, 메시지: ${result.message}") - } - is DomainResult.Exception -> { - android.util.Log.e("WearAuthViewModel", "Exception - 에러: ${result.error}") - } - } + // 고정 토큰을 로컬 저장소에 저장 + wearTokenManager.saveAccessTokenFromMobile(fixedToken) + android.util.Log.d("WearAuthViewModel", "고정 토큰 저장 완료") + + // 잠시 대기 + kotlinx.coroutines.delay(1000) + + android.util.Log.d("WearAuthViewModel", "=== 고정 토큰 사용 성공 ===") + loadUserInfoWithToken() + + } catch (e: Exception) { + android.util.Log.e("WearAuthViewModel", "=== 고정 토큰 사용 중 오류 ===", e) + _uiState.value = _uiState.value.copy( + isLoading = false, + isWaitingForMobileResponse = false, + error = "고정 토큰 사용 중 오류 발생: ${e.message}" + ) + } + } + } + + private fun loadUserInfoWithToken() { + viewModelScope.launch { + try { + android.util.Log.d("WearAuthViewModel", "토큰으로 유저 정보 조회 시작") + val result = getUserInfoUseCase().first() when (result) { is DomainResult.Success -> { - android.util.Log.d("WearAuthViewModel", "유저 정보 조회 성공: ${result.data}") + android.util.Log.d("WearAuthViewModel", "유저 정보 조회 성공") _uiState.value = _uiState.value.copy( isLoading = false, isLoggedIn = true, userInfo = result.data, - memberStatus = result.data.memberStatus + memberStatus = result.data.memberStatus, + isWaitingForMobileResponse = false ) } is DomainResult.Error -> { @@ -89,15 +107,17 @@ class WearAuthViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isLoading = false, isLoggedIn = false, - error = "유저 정보 조회 실패: ${result.message}" + error = "유저 정보 조회 실패: ${result.message}", + isWaitingForMobileResponse = false ) } else -> { - android.util.Log.e("WearAuthViewModel", "알 수 없는 오류: $result") + android.util.Log.e("WearAuthViewModel", "알 수 없는 오류") _uiState.value = _uiState.value.copy( isLoading = false, isLoggedIn = false, - error = "알 수 없는 오류" + error = "알 수 없는 오류", + isWaitingForMobileResponse = false ) } } @@ -106,9 +126,11 @@ class WearAuthViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isLoading = false, isLoggedIn = false, - error = "유저 정보 조회 중 오류 발생: ${e.message}" + error = "유저 정보 조회 중 오류 발생: ${e.message}", + isWaitingForMobileResponse = false ) } } } + } diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTrackingScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTrackingScreen.kt index 690542b..aa9ec98 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTrackingScreen.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseTrackingScreen.kt @@ -76,7 +76,6 @@ fun WearExerciseTrackingScreen( val uiState by viewModel.uiState.collectAsState() var isInitialized by remember { mutableStateOf(false) } - // 초기화 - 운동 시작 LaunchedEffect(selectedPets, exerciseType) { if (!isInitialized && selectedPets.isNotEmpty()) { viewModel.startExercise(selectedPets, exerciseType) @@ -84,21 +83,15 @@ fun WearExerciseTrackingScreen( } } - // 시간 업데이트 및 거리 계산 LaunchedEffect(uiState.isTracking, uiState.isPaused) { while (uiState.isTracking) { delay(1000) - val currentTime = System.currentTimeMillis() - val elapsed = currentTime - uiState.startTime - uiState.totalPausedDuration - val timeString = formatTime(elapsed) - viewModel.updateTime(timeString) - // 거리 계산 (간단한 시뮬레이션 - 실제로는 GPS 데이터 사용) if (!uiState.isPaused) { - val elapsedSeconds = elapsed / 1000.0 - val simulatedSpeed = 3.0 // km/h (평균 걷기 속도) - val newDistance = (simulatedSpeed * elapsedSeconds) / 3600.0 - viewModel.updateDistance(newDistance) + val currentTime = System.currentTimeMillis() + val elapsed = currentTime - uiState.startTime - uiState.totalPausedDuration + val timeString = formatTime(elapsed) + viewModel.updateTime(timeString) } } } @@ -113,7 +106,6 @@ fun WearExerciseTrackingScreen( contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp) ) { item { - // 헤더 - 더 작게 Text( text = "운동 중", style = MaterialTheme.typography.body1, @@ -123,7 +115,6 @@ fun WearExerciseTrackingScreen( } item { - // 시간 표시 Text( text = uiState.currentTime, style = MaterialTheme.typography.title1, @@ -133,7 +124,6 @@ fun WearExerciseTrackingScreen( } item { - // 거리 표시 Text( text = "${String.format("%.1f", uiState.distance)}km", style = MaterialTheme.typography.body2, @@ -143,7 +133,6 @@ fun WearExerciseTrackingScreen( } item { - // 콤비 정보 Text( text = "${member.nickname} + ${selectedPets.map { it.name }.joinToString(", ")}", style = MaterialTheme.typography.body2, @@ -157,7 +146,7 @@ fun WearExerciseTrackingScreen( if (uiState.isLoading) { CircularProgressIndicator() } else { - // 컨트롤 버튼들 - WearOS에 맞게 간단하게 + // 컨트롤 버튼들 if (!uiState.isTracking) { // 시작 버튼 RunCombiButton( @@ -215,7 +204,6 @@ fun WearExerciseTrackingScreen( } item { - // 에러 표시 uiState.error?.let { error -> Text( text = error, @@ -226,13 +214,14 @@ fun WearExerciseTrackingScreen( } } - item { - // 뒤로가기 버튼 - 작게 - RunCombiButton( - text = "뒤로", - onClick = onBack, - modifier = Modifier.padding(top = 4.dp, start = 16.dp, end = 16.dp) - ) + if (uiState.error != null) { + item { + RunCombiButton( + text = "뒤로", + onClick = onBack, + modifier = Modifier.padding(top = 4.dp, start = 16.dp, end = 16.dp) + ) + } } } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseViewModel.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseViewModel.kt index 2231391..7bef819 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseViewModel.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearExerciseViewModel.kt @@ -7,6 +7,20 @@ import com.combo.runcombi.domain.user.model.Pet import com.combo.runcombi.walk.model.ExerciseType import com.combo.runcombi.walk.usecase.MidRunUpdateUseCase import com.combo.runcombi.walk.usecase.StartRunUseCase +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import androidx.core.content.ContextCompat +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import android.os.Looper +import android.Manifest +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,19 +41,36 @@ data class WearExerciseUiState( val isLoading: Boolean = false, val error: String? = null, val isCompleted: Boolean = false, + val lastLocation: Location? = null, + val isLocationTracking: Boolean = false, + val hasLocationPermission: Boolean = false, + val needsLocationPermission: Boolean = false, ) @HiltViewModel class WearExerciseViewModel @Inject constructor( private val startRunUseCase: StartRunUseCase, private val midRunUpdateUseCase: MidRunUpdateUseCase, + @ApplicationContext private val context: Context, ) : ViewModel() { private val _uiState = MutableStateFlow(WearExerciseUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) + private var locationCallback: LocationCallback? = null + private var lastLocation: Location? = null + private var totalDistance: Double = 0.0 + + init { + checkLocationPermission() + } + fun startExercise(pets: List, exerciseType: ExerciseType) { viewModelScope.launch { + android.util.Log.d("WearExerciseViewModel", "=== 운동 시작 요청 ===") + android.util.Log.d("WearExerciseViewModel", "펫: ${pets.map { it.id }}, 운동타입: ${exerciseType.name}") + _uiState.value = _uiState.value.copy(isLoading = true, error = null) try { @@ -50,15 +81,23 @@ class WearExerciseViewModel @Inject constructor( when (result) { is DomainResult.Success -> { + android.util.Log.d("WearExerciseViewModel", "운동 시작 성공 - runId: ${result.data.runId}") + _uiState.value = _uiState.value.copy( isLoading = false, isTracking = true, startTime = System.currentTimeMillis(), - runId = result.data.runId + runId = result.data.runId, + distance = 0.0, + totalPausedDuration = 0L ) + + android.util.Log.d("WearExerciseViewModel", "운동 시작 성공 후 위치 추적 시작") + startLocationTracking() } is DomainResult.Error -> { + android.util.Log.e("WearExerciseViewModel", "운동 시작 실패: ${result.message}") _uiState.value = _uiState.value.copy( isLoading = false, error = "운동 시작 실패: ${result.message}" @@ -66,6 +105,7 @@ class WearExerciseViewModel @Inject constructor( } else -> { + android.util.Log.e("WearExerciseViewModel", "알 수 없는 오류") _uiState.value = _uiState.value.copy( isLoading = false, error = "알 수 없는 오류" @@ -73,6 +113,7 @@ class WearExerciseViewModel @Inject constructor( } } } catch (e: Exception) { + android.util.Log.e("WearExerciseViewModel", "운동 시작 중 오류 발생", e) _uiState.value = _uiState.value.copy( isLoading = false, error = "운동 시작 중 오류 발생: ${e.message}" @@ -82,29 +123,62 @@ class WearExerciseViewModel @Inject constructor( } fun pauseExercise() { + android.util.Log.d("WearExerciseViewModel", "=== 운동 일시정지 시작 ===") + android.util.Log.d("WearExerciseViewModel", "일시정지 전 상태 - isPaused: ${_uiState.value.isPaused}, isTracking: ${_uiState.value.isTracking}") + _uiState.value = _uiState.value.copy( isPaused = true, pausedTime = System.currentTimeMillis() ) + + android.util.Log.d("WearExerciseViewModel", "일시정지 시간 설정: ${_uiState.value.pausedTime}") + + stopLocationTracking() + + android.util.Log.d("WearExerciseViewModel", "=== 운동 일시정지 완료 ===") } fun resumeExercise() { + android.util.Log.d("WearExerciseViewModel", "=== 운동 재개 시작 ===") + android.util.Log.d("WearExerciseViewModel", "재개 전 상태 - isPaused: ${_uiState.value.isPaused}, totalPausedDuration: ${_uiState.value.totalPausedDuration}ms") + val currentTime = System.currentTimeMillis() val pausedDuration = currentTime - _uiState.value.pausedTime + val newTotalPausedDuration = _uiState.value.totalPausedDuration + pausedDuration + + android.util.Log.d("WearExerciseViewModel", "일시정지 시간 계산:") + android.util.Log.d("WearExerciseViewModel", " - 현재 시간: ${currentTime}") + android.util.Log.d("WearExerciseViewModel", " - 일시정지 시작 시간: ${_uiState.value.pausedTime}") + android.util.Log.d("WearExerciseViewModel", " - 이번 일시정지 시간: ${pausedDuration}ms") + android.util.Log.d("WearExerciseViewModel", " - 총 일시정지 시간: ${newTotalPausedDuration}ms") + _uiState.value = _uiState.value.copy( isPaused = false, - totalPausedDuration = _uiState.value.totalPausedDuration + pausedDuration, + totalPausedDuration = newTotalPausedDuration, pausedTime = 0L ) + + startLocationTracking() + + android.util.Log.d("WearExerciseViewModel", "=== 운동 재개 완료 ===") } fun finishExercise() { + android.util.Log.d("WearExerciseViewModel", "=== 운동 종료 시작 ===") + viewModelScope.launch { val currentState = _uiState.value - if (currentState.runId == null) return@launch + android.util.Log.d("WearExerciseViewModel", "현재 상태 - runId: ${currentState.runId}, isTracking: ${currentState.isTracking}, isPaused: ${currentState.isPaused}") + + if (currentState.runId == null) { + android.util.Log.w("WearExerciseViewModel", "runId가 null이므로 운동 종료 불가") + return@launch + } + + stopLocationTracking() - // 정지 상태가 아니면 먼저 정지 if (!currentState.isPaused) { + android.util.Log.d("WearExerciseViewModel", "운동 중이므로 먼저 일시정지") _uiState.value = currentState.copy(isPaused = true) } @@ -112,37 +186,45 @@ class WearExerciseViewModel @Inject constructor( try { val currentTime = System.currentTimeMillis() - val totalElapsed = - currentTime - currentState.startTime - currentState.totalPausedDuration + val totalElapsed = currentTime - currentState.startTime - currentState.totalPausedDuration val runTimeSeconds = (totalElapsed / 1000).toInt() + val runTimeMinutes = (runTimeSeconds / 60).toInt() // 분 단위로 변환 - // 선택된 펫 ID들 사용 - val petIds = currentState.selectedPetIds + android.util.Log.d("WearExerciseViewModel", "운동 종료 데이터:") + android.util.Log.d("WearExerciseViewModel", " - 시작 시간: ${currentState.startTime}") + android.util.Log.d("WearExerciseViewModel", " - 현재 시간: ${currentTime}") + android.util.Log.d("WearExerciseViewModel", " - 총 일시정지 시간: ${currentState.totalPausedDuration}ms") + android.util.Log.d("WearExerciseViewModel", " - 실제 운동 시간: ${totalElapsed}ms (${runTimeSeconds}초, ${runTimeMinutes}분)") + android.util.Log.d("WearExerciseViewModel", " - 총 거리: ${currentState.distance}m") + android.util.Log.d("WearExerciseViewModel", " - 펫 ID들: ${currentState.selectedPetIds}") - // 산책 중간 업데이트 API 호출 + android.util.Log.d("WearExerciseViewModel", "midRunUpdate API 호출 시작") val midRunUpdateResult = midRunUpdateUseCase( runId = currentState.runId, - runTime = runTimeSeconds, + runTime = runTimeMinutes, // 분 단위로 전송 runDistance = currentState.distance ) when (midRunUpdateResult) { is DomainResult.Success -> { - // 중간 업데이트 성공 시 운동 완료 처리 + android.util.Log.d("WearExerciseViewModel", "midRunUpdate API 호출 성공") _uiState.value = _uiState.value.copy( isLoading = false, isTracking = false, isPaused = false, isCompleted = true ) + android.util.Log.d("WearExerciseViewModel", "운동 완료 처리 완료") } is DomainResult.Error -> { + android.util.Log.e("WearExerciseViewModel", "midRunUpdate API 호출 실패: ${midRunUpdateResult.message}") _uiState.value = _uiState.value.copy( isLoading = false, error = "산책 중간 업데이트 실패: ${midRunUpdateResult.message}" ) } else -> { + android.util.Log.e("WearExerciseViewModel", "midRunUpdate API 호출 중 알 수 없는 오류") _uiState.value = _uiState.value.copy( isLoading = false, error = "산책 중간 업데이트 중 알 수 없는 오류" @@ -150,12 +232,15 @@ class WearExerciseViewModel @Inject constructor( } } } catch (e: Exception) { + android.util.Log.e("WearExerciseViewModel", "운동 종료 중 오류 발생", e) _uiState.value = _uiState.value.copy( isLoading = false, error = "운동 기록 저장 중 오류 발생: ${e.message}" ) } } + + android.util.Log.d("WearExerciseViewModel", "=== 운동 종료 완료 ===") } fun updateTime(timeString: String) { @@ -169,4 +254,210 @@ class WearExerciseViewModel @Inject constructor( fun clearError() { _uiState.value = _uiState.value.copy(error = null) } + + private fun checkLocationPermission(): Boolean { + val hasFineLocation = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + val hasCoarseLocation = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + val hasPermission = hasFineLocation || hasCoarseLocation + + android.util.Log.d("WearExerciseViewModel", "=== 위치 권한 체크 ===") + android.util.Log.d("WearExerciseViewModel", "FINE_LOCATION: $hasFineLocation") + android.util.Log.d("WearExerciseViewModel", "COARSE_LOCATION: $hasCoarseLocation") + android.util.Log.d("WearExerciseViewModel", "권한 있음: $hasPermission") + + _uiState.value = _uiState.value.copy( + hasLocationPermission = hasPermission, + needsLocationPermission = !hasPermission + ) + + return hasPermission + } + + fun requestLocationPermission() { + android.util.Log.d("WearExerciseViewModel", "위치 권한 요청") + _uiState.value = _uiState.value.copy(needsLocationPermission = true) + } + + fun updateLocationPermissionStatus() { + android.util.Log.d("WearExerciseViewModel", "위치 권한 상태 업데이트") + checkLocationPermission() + } + + private fun startLocationTracking() { + android.util.Log.d("WearExerciseViewModel", "=== 위치 추적 시작 시도 ===") + android.util.Log.d("WearExerciseViewModel", "현재 상태 - isTracking: ${_uiState.value.isTracking}, isPaused: ${_uiState.value.isPaused}") + + if (_uiState.value.isPaused || !_uiState.value.isTracking) { + android.util.Log.w("WearExerciseViewModel", "위치 추적 조건 불만족 - isPaused: ${_uiState.value.isPaused}, isTracking: ${_uiState.value.isTracking}") + return + } + + if (!checkLocationPermission()) { + android.util.Log.e("WearExerciseViewModel", "위치 권한 없음") + _uiState.value = _uiState.value.copy( + error = "위치 권한이 필요합니다.\n메인 화면에서 '위치 권한 허용' 버튼을 눌러주세요.", + needsLocationPermission = true + ) + return + } + + if (!checkLocationService()) { + android.util.Log.e("WearExerciseViewModel", "위치 서비스 비활성화") + _uiState.value = _uiState.value.copy(error = "위치 서비스를 활성화해주세요") + return + } + + try { + android.util.Log.d("WearExerciseViewModel", "위치 요청 설정 중...") + val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) + .setWaitForAccurateLocation(false) + .setMinUpdateIntervalMillis(500) + .setMaxUpdateDelayMillis(1000) + .build() + + android.util.Log.d("WearExerciseViewModel", "위치 콜백 설정 중...") + locationCallback = object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + android.util.Log.d("WearExerciseViewModel", "=== 위치 콜백 호출됨 ===") + android.util.Log.d("WearExerciseViewModel", "위치 개수: ${locationResult.locations.size}") + + locationResult.locations.forEachIndexed { index, location -> + android.util.Log.d("WearExerciseViewModel", "위치 $index: lat=${location.latitude}, lng=${location.longitude}, accuracy=${location.accuracy}m") + } + + locationResult.lastLocation?.let { location -> + android.util.Log.d("WearExerciseViewModel", "마지막 위치 사용: lat=${location.latitude}, lng=${location.longitude}") + updateLocationData(location) + } ?: run { + android.util.Log.w("WearExerciseViewModel", "위치 콜백에서 null 위치 수신") + } + } + } + + android.util.Log.d("WearExerciseViewModel", "위치 업데이트 요청 중...") + fusedLocationClient.requestLocationUpdates( + locationRequest, + locationCallback!!, + Looper.getMainLooper() + ) + + _uiState.value = _uiState.value.copy(isLocationTracking = true) + android.util.Log.d("WearExerciseViewModel", "=== 위치 추적 요청 성공 ===") + android.util.Log.d("WearExerciseViewModel", "위치 콜백 등록됨: ${locationCallback != null}") + + requestCurrentLocation() + + } catch (e: SecurityException) { + android.util.Log.e("WearExerciseViewModel", "=== 위치 권한 없음 (SecurityException) ===", e) + _uiState.value = _uiState.value.copy(error = "위치 권한이 필요합니다") + } catch (e: Exception) { + android.util.Log.e("WearExerciseViewModel", "=== 위치 추적 시작 실패 ===", e) + _uiState.value = _uiState.value.copy(error = "위치 추적 시작 실패: ${e.message}") + } + } + + private fun stopLocationTracking() { + android.util.Log.d("WearExerciseViewModel", "=== 위치 추적 중지 시작 ===") + + locationCallback?.let { callback -> + android.util.Log.d("WearExerciseViewModel", "위치 업데이트 제거 중...") + fusedLocationClient.removeLocationUpdates(callback) + android.util.Log.d("WearExerciseViewModel", "위치 업데이트 제거 완료") + } ?: run { + android.util.Log.w("WearExerciseViewModel", "위치 콜백이 null이므로 제거할 것이 없음") + } + + locationCallback = null + _uiState.value = _uiState.value.copy(isLocationTracking = false) + android.util.Log.d("WearExerciseViewModel", "=== 위치 추적 중지 완료 ===") + } + + private fun updateLocationData(location: Location) { + android.util.Log.d("WearExerciseViewModel", "=== 위치 데이터 업데이트 시작 ===") + android.util.Log.d("WearExerciseViewModel", "새로운 위치 - lat=${location.latitude}, lng=${location.longitude}, accuracy=${location.accuracy}m, time=${location.time}") + android.util.Log.d("WearExerciseViewModel", "현재 상태 - isPaused: ${_uiState.value.isPaused}, isTracking: ${_uiState.value.isTracking}") + + if (_uiState.value.isPaused || !_uiState.value.isTracking) { + android.util.Log.w("WearExerciseViewModel", "일시정지 또는 추적 중단 상태이므로 위치 업데이트 무시") + return + } + + lastLocation?.let { prevLocation -> + val distance = prevLocation.distanceTo(location).toDouble() + totalDistance += distance + + android.util.Log.d("WearExerciseViewModel", "거리 계산:") + android.util.Log.d("WearExerciseViewModel", " - 이전 위치: lat=${prevLocation.latitude}, lng=${prevLocation.longitude}") + android.util.Log.d("WearExerciseViewModel", " - 현재 위치: lat=${location.latitude}, lng=${location.longitude}") + android.util.Log.d("WearExerciseViewModel", " - 추가 거리: ${distance}m") + android.util.Log.d("WearExerciseViewModel", " - 총 거리: ${totalDistance}m") + + _uiState.value = _uiState.value.copy( + distance = totalDistance, + lastLocation = location + ) + + android.util.Log.d("WearExerciseViewModel", "UI 상태 업데이트 완료 - 거리: ${_uiState.value.distance}m") + } ?: run { + android.util.Log.d("WearExerciseViewModel", "첫 번째 위치 설정 - lat=${location.latitude}, lng=${location.longitude}") + _uiState.value = _uiState.value.copy(lastLocation = location) + } + + lastLocation = location + android.util.Log.d("WearExerciseViewModel", "=== 위치 데이터 업데이트 완료 ===") + } + + private fun checkLocationService(): Boolean { + return try { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val gpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + val networkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + + android.util.Log.d("WearExerciseViewModel", "=== 위치 서비스 체크 ===") + android.util.Log.d("WearExerciseViewModel", "GPS 활성화: $gpsEnabled") + android.util.Log.d("WearExerciseViewModel", "Network 활성화: $networkEnabled") + + val isEnabled = gpsEnabled || networkEnabled + android.util.Log.d("WearExerciseViewModel", "위치 서비스 활성화됨: $isEnabled") + + isEnabled + } catch (e: Exception) { + android.util.Log.e("WearExerciseViewModel", "위치 서비스 체크 실패", e) + true // 체크 실패 시에도 위치 추적 시도 + } + } + + private fun requestCurrentLocation() { + android.util.Log.d("WearExerciseViewModel", "=== 현재 위치 즉시 요청 ===") + + try { + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + if (location != null) { + android.util.Log.d("WearExerciseViewModel", "마지막 알려진 위치 수신: lat=${location.latitude}, lng=${location.longitude}") + updateLocationData(location) + } else { + android.util.Log.w("WearExerciseViewModel", "마지막 알려진 위치가 null") + } + }.addOnFailureListener { exception -> + android.util.Log.e("WearExerciseViewModel", "마지막 위치 요청 실패", exception) + } + } catch (e: SecurityException) { + android.util.Log.e("WearExerciseViewModel", "마지막 위치 요청 권한 없음", e) + } catch (e: Exception) { + android.util.Log.e("WearExerciseViewModel", "마지막 위치 요청 실패", e) + } + } + + override fun onCleared() { + super.onCleared() + stopLocationTracking() + } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt index f568eca..268380b 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearMainScreen.kt @@ -1,5 +1,8 @@ package com.combo.runcombi.wear.presentation +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -9,11 +12,18 @@ import androidx.wear.compose.foundation.lazy.ScalingLazyColumn import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text import androidx.wear.tooling.preview.devices.WearDevices @@ -30,9 +40,45 @@ import com.combo.runcombi.wear.presentation.theme.RunCombi_AndroidTheme fun WearMainScreen( userInfo: UserInfo, onStartExercise: () -> Unit = {}, + onRequestLocationPermission: () -> Unit = {}, ) { + val context = LocalContext.current val listState = rememberScalingLazyListState() + var hasLocationPermission by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + fun checkPermissions(): Boolean { + val hasFineLocation = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + val hasCoarseLocation = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + return hasFineLocation || hasCoarseLocation + } + + // 초기 권한 체크 + hasLocationPermission = checkPermissions() + android.util.Log.d("WearMainScreen", "초기 권한 상태: $hasLocationPermission") + + // 권한이 없을 때만 주기적으로 체크 + while (!hasLocationPermission) { + kotlinx.coroutines.delay(2000) + val newPermissionState = checkPermissions() + android.util.Log.d("WearMainScreen", "권한 재체크: $newPermissionState") + if (newPermissionState != hasLocationPermission) { + android.util.Log.d("WearMainScreen", "권한 상태 변경: $hasLocationPermission -> $newPermissionState") + hasLocationPermission = newPermissionState + break + } + } + } + ScalingLazyColumn( state = listState, modifier = Modifier.fillMaxSize(), @@ -56,13 +102,31 @@ fun WearMainScreen( ) } - item { - // 운동 시작 버튼 - RunCombiButton( - text = "운동 시작", - onClick = onStartExercise, - modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp) - ) + if (!hasLocationPermission) { + item { + Text( + text = "위치 권한이 필요합니다", + style = MaterialTheme.typography.caption1, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + item { + RunCombiButton( + text = "위치 권한 허용", + onClick = onRequestLocationPermission, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp) + ) + } + } else { + item { + RunCombiButton( + text = "운동 시작", + onClick = onStartExercise, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 16.dp) + ) + } } } From ab99744a3a0b8e36278bdbcd8de1b0fccaf00eb3 Mon Sep 17 00:00:00 2001 From: changs97 Date: Mon, 29 Sep 2025 22:20:01 +0900 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20wear=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/wear/WearConnectionManager.kt | 95 +++++++++++---- .../wear/data/WearConnectionChecker.kt | 109 ++++++++++++++++++ .../runcombi/wear/data/WearDataSyncService.kt | 17 +-- .../runcombi/wear/data/WearTokenReceiver.kt | 14 ++- .../com/combo/runcombi/wear/di/WearModule.kt | 18 +++ .../wear/presentation/WearAuthViewModel.kt | 71 +++++++++--- 6 files changed, 277 insertions(+), 47 deletions(-) create mode 100644 wear/src/main/java/com/combo/runcombi/wear/data/WearConnectionChecker.kt create mode 100644 wear/src/main/java/com/combo/runcombi/wear/di/WearModule.kt 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 index ff70fdf..46b8b7b 100644 --- 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 @@ -49,34 +49,76 @@ class MobileWearDataListenerService : WearableListenerService() { 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") - for (event in dataEvents) { - if (event.type == DataEvent.TYPE_CHANGED) { - val dataItem = event.dataItem - android.util.Log.d("MobileWearDataListenerService", "Data changed: ${dataItem.uri.path}") - - when (dataItem.uri.path) { - "/request_login" -> { - android.util.Log.d("MobileWearDataListenerService", "Received login request from wear") - // 워치에서 로그인 요청을 받았을 때 - 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) - } + 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) + } } } } @@ -119,10 +161,23 @@ class MobileWearDataListenerService : WearableListenerService() { } } + 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? { - // 고정 토큰 사용 (개발용) - val fixedToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3NTkxNDg3ODYsInN1YiI6IjEzMiIsImV4cCI6MTc1OTc1MzU4Niwicm9sZSI6IlVTRVIifQ.rlUVKbI7BFqcm3W2JryWvqVtOt2PtYiS_qoz7Elcw_hgzlYiwWjaZlAZk1PAwRJmhl0VdddFVOzhd-mle6rRQA" - android.util.Log.d("MobileWearDataListenerService", "고정 토큰 사용: ${fixedToken.take(20)}...") - return fixedToken + // 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/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> -> + capabilityClient.getAllCapabilities(com.google.android.gms.wearable.CapabilityClient.FILTER_REACHABLE) + .addOnCompleteListener { task -> + if (task.isSuccessful) { + continuation.resume(task.result) + } else { + continuation.resumeWithException( + task.exception ?: Exception("Failed to get capabilities") + ) + } + } + } + + android.util.Log.d("WearConnectionChecker", "사용 가능한 Capability 수: ${capabilities.size}") + capabilities.forEach { (name, info) -> + android.util.Log.d( + "WearConnectionChecker", + "Capability: $name - 노드 수: ${info.nodes.size}" + ) + } + + capabilities.isNotEmpty() + } catch (e: Exception) { + android.util.Log.e("WearConnectionChecker", "Capability 확인 실패", e) + false + } + } +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt b/wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt index 1280117..469ee07 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/data/WearDataSyncService.kt @@ -44,25 +44,20 @@ class WearDataSyncService @Inject constructor( suspend fun requestMobileLogin() { android.util.Log.d("WearDataSyncService", "=== 모바일 로그인 요청 시작 ===") - android.util.Log.d("WearDataSyncService", "DataClient 상태 확인 중...") - val dataMap = DataMap().apply { - putString("action", "request_login") - putLong("timestamp", System.currentTimeMillis()) - } - - val putDataMapRequest = PutDataMapRequest.create("/request_login") - putDataMapRequest.dataMap.putAll(dataMap) + val authDataReq = PutDataMapRequest.create("/authRequest").apply { + dataMap.putString("String", "GIVE ME LOGIN") + dataMap.putLong("timestamp", System.currentTimeMillis()) // 매번 다른 데이터로 인식되도록 + }.asPutDataRequest().setUrgent() android.util.Log.d("WearDataSyncService", "로그인 요청 데이터 생성 완료") - android.util.Log.d("WearDataSyncService", "경로: /request_login") - android.util.Log.d("WearDataSyncService", "액션: request_login") + android.util.Log.d("WearDataSyncService", "경로: /authRequest") android.util.Log.d("WearDataSyncService", "타임스탬프: ${System.currentTimeMillis()}") android.util.Log.d("WearDataSyncService", "모바일로 로그인 요청 전송 중...") suspendCancellableCoroutine { continuation -> - dataClient.putDataItem(putDataMapRequest.asPutDataRequest()) + dataClient.putDataItem(authDataReq) .addOnSuccessListener { dataItem -> android.util.Log.d("WearDataSyncService", "=== 로그인 요청 전송 성공 ===") android.util.Log.d("WearDataSyncService", "DataItem URI: ${dataItem.uri}") diff --git a/wear/src/main/java/com/combo/runcombi/wear/data/WearTokenReceiver.kt b/wear/src/main/java/com/combo/runcombi/wear/data/WearTokenReceiver.kt index 714d1db..906e83b 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/data/WearTokenReceiver.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/data/WearTokenReceiver.kt @@ -23,7 +23,7 @@ class WearTokenReceiver : WearableListenerService() { android.util.Log.d("WearTokenReceiver", "onDataChanged called with ${dataEvents.count} events") - for (event in dataEvents) { + dataEvents.forEach { event -> if (event.type == DataEvent.TYPE_CHANGED) { val dataItem = event.dataItem android.util.Log.d("WearTokenReceiver", "Data changed: ${dataItem.uri.path}") @@ -55,6 +55,18 @@ class WearTokenReceiver : WearableListenerService() { android.util.Log.w("WearTokenReceiver", "모바일로부터 null 토큰 수신") } } + "/authStatus" -> { + android.util.Log.d("WearTokenReceiver", "=== 모바일로부터 로그인 상태 수신 ===") + val dataMapItem = DataMapItem.fromDataItem(dataItem) + val dataMap = dataMapItem.dataMap + + val isLoggedIn = dataMap.getBoolean("isLoggedIn") + android.util.Log.d("WearTokenReceiver", "로그인 상태: $isLoggedIn") + + if (!isLoggedIn) { + android.util.Log.w("WearTokenReceiver", "모바일에서 로그인되지 않음") + } + } } } } diff --git a/wear/src/main/java/com/combo/runcombi/wear/di/WearModule.kt b/wear/src/main/java/com/combo/runcombi/wear/di/WearModule.kt new file mode 100644 index 0000000..e02b365 --- /dev/null +++ b/wear/src/main/java/com/combo/runcombi/wear/di/WearModule.kt @@ -0,0 +1,18 @@ +package com.combo.runcombi.wear.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object WearModule { + + @Provides + @Singleton + fun provideContext(@ApplicationContext context: Context): Context = context +} diff --git a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt index 4352bb9..e70dc5c 100644 --- a/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt +++ b/wear/src/main/java/com/combo/runcombi/wear/presentation/WearAuthViewModel.kt @@ -12,6 +12,7 @@ import com.combo.runcombi.domain.user.model.UserInfo import com.combo.runcombi.domain.user.usecase.GetUserInfoUseCase import com.combo.runcombi.wear.auth.WearTokenManager import com.combo.runcombi.wear.data.WearDataSyncService +import com.combo.runcombi.wear.data.WearConnectionChecker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,7 +34,8 @@ data class WearAuthUiState( class WearAuthViewModel @Inject constructor( private val getUserInfoUseCase: GetUserInfoUseCase, private val wearTokenManager: WearTokenManager, - private val wearDataSyncService: WearDataSyncService + private val wearDataSyncService: WearDataSyncService, + private val wearConnectionChecker: WearConnectionChecker, ) : ViewModel() { private val _uiState = MutableStateFlow(WearAuthUiState()) @@ -49,7 +51,7 @@ class WearAuthViewModel @Inject constructor( } fun requestTokenFromMobile() { - android.util.Log.d("WearAuthViewModel", "=== 고정 토큰 사용 시작 ===") + android.util.Log.d("WearAuthViewModel", "=== 모바일 토큰 요청 시작 ===") viewModelScope.launch { _uiState.value = _uiState.value.copy( isLoading = true, @@ -58,28 +60,67 @@ class WearAuthViewModel @Inject constructor( ) try { - // 고정 토큰 사용 - val fixedToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE3NTkxNDg3ODYsInN1YiI6IjEzMiIsImV4cCI6MTc1OTc1MzU4Niwicm9sZSI6IlVTRVIifQ.rlUVKbI7BFqcm3W2JryWvqVtOt2PtYiS_qoz7Elcw_hgzlYiwWjaZlAZk1PAwRJmhl0VdddFVOzhd-mle6rRQA" + // 1. Google Play Services 상태 확인 + android.util.Log.d("WearAuthViewModel", "=== 연결 상태 확인 시작 ===") + val googlePlayServicesOk = wearConnectionChecker.checkGooglePlayServices() + android.util.Log.d("WearAuthViewModel", "Google Play Services: $googlePlayServicesOk") - android.util.Log.d("WearAuthViewModel", "고정 토큰 설정 중...") - android.util.Log.d("WearAuthViewModel", "토큰: ${fixedToken.take(20)}...") + if (!googlePlayServicesOk) { + _uiState.value = _uiState.value.copy( + isLoading = false, + isWaitingForMobileResponse = false, + error = "Google Play Services가 사용 불가능합니다. 워치를 다시 시작해주세요." + ) + return@launch + } + + // 2. Wearable 연결 상태 확인 + val wearableConnected = wearConnectionChecker.checkWearableConnection() + android.util.Log.d("WearAuthViewModel", "Wearable 연결: $wearableConnected") + + if (!wearableConnected) { + _uiState.value = _uiState.value.copy( + isLoading = false, + isWaitingForMobileResponse = false, + error = "모바일과 워치가 연결되지 않았습니다. 모바일 앱이 실행 중인지 확인해주세요." + ) + return@launch + } + + // 3. Capability 확인 + val capabilityOk = wearConnectionChecker.checkCapability() + android.util.Log.d("WearAuthViewModel", "Capability: $capabilityOk") - // 고정 토큰을 로컬 저장소에 저장 - wearTokenManager.saveAccessTokenFromMobile(fixedToken) - android.util.Log.d("WearAuthViewModel", "고정 토큰 저장 완료") + android.util.Log.d("WearAuthViewModel", "모바일로 로그인 요청 전송 중...") + wearDataSyncService.requestMobileLogin() + android.util.Log.d("WearAuthViewModel", "모바일로 로그인 요청 전송 완료") - // 잠시 대기 - kotlinx.coroutines.delay(1000) + android.util.Log.d("WearAuthViewModel", "모바일 응답 대기 중... (3초)") + kotlinx.coroutines.delay(3000) - android.util.Log.d("WearAuthViewModel", "=== 고정 토큰 사용 성공 ===") - loadUserInfoWithToken() + android.util.Log.d("WearAuthViewModel", "로컬 저장소에서 토큰 확인 중...") + val accessToken = wearTokenManager.getAccessToken() + android.util.Log.d("WearAuthViewModel", "토큰 확인 결과: ${accessToken != null}") + if (accessToken != null) { + android.util.Log.d("WearAuthViewModel", "=== 토큰 수신 성공 ===") + android.util.Log.d("WearAuthViewModel", "토큰: ${accessToken.take(20)}...") + loadUserInfoWithToken() + } else { + android.util.Log.w("WearAuthViewModel", "=== 토큰 수신 실패 ===") + android.util.Log.w("WearAuthViewModel", "모바일 앱이 실행 중인지 확인 필요") + _uiState.value = _uiState.value.copy( + isLoading = false, + isWaitingForMobileResponse = false, + error = "모바일로부터 토큰을 받지 못했습니다. 모바일 앱이 실행 중인지 확인해주세요." + ) + } } catch (e: Exception) { - android.util.Log.e("WearAuthViewModel", "=== 고정 토큰 사용 중 오류 ===", e) + android.util.Log.e("WearAuthViewModel", "=== 모바일 토큰 요청 중 오류 ===", e) _uiState.value = _uiState.value.copy( isLoading = false, isWaitingForMobileResponse = false, - error = "고정 토큰 사용 중 오류 발생: ${e.message}" + error = "모바일 토큰 요청 중 오류 발생: ${e.message}" ) } }