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